Compare commits

..
Author SHA1 Message Date
Mike Schwarz 02e7fd1083 fix(tui): keep a daemon-only start on its default bind, and point at the way back to the kernel (#3181)
A daemon started from the TUI's crash cart or start-and-return page used to run `rig daemon start --no-kernel --host 127.0.0.1 --port <port>`. `--host` is an explicit bind, so the daemon listened on 127.0.0.1 alone and other machines on the tailnet couldn't reach it until it was restarted. For the default `http://127.0.0.1:<port>` target the TUI now passes only `--port`, so the daemon uses its default bind (127.0.0.1, plus the Tailscale address when one is present). A `localhost` or `[::1]` target still names its host, a configured bind host is honoured as it is by a plain `rig daemon start`, and `--no-kernel`, the port and the local-target check are unchanged.

After a daemon-only start, which leaves every seat as it was, the start-and-return page says no seats were started and points at the kernel, when one is listed, to see its seats and resume one; with no kernel rig it says to choose a rig or press `k`. The crash cart's notice points at that page with `S`; its mention of the kernel isn't checked against the listed rigs.

A configured non-loopback bind host still can't be reached from the TUI's default loopback target, as with a daemon started any other way.
2026-11-13 16:49:49 -08:00
Dery Ferdika 45d390d157 feat(portability): an opt-in --strict exit status when the report finds matches (#3202)
`scripts/portability-report.mjs` gains an opt-in `--strict` flag. The report is produced exactly as before, and the run then exits 1 when it found matches. Without the flag nothing changes: it exits 0 with or without findings, and 2 on an operational error such as a bad argument or a git failure.

Exit 1 means the report found matches, not that a change is unsafe or must be rejected. The checks are heuristic: a missed match is possible, and an empty report is not a safety claim. OpenRig's own portability workflow doesn't pass `--strict` and stays advisory.
2026-11-13 16:42:05 -08:00
8 changed files with 73 additions and 35 deletions

No files matched your search

@@ -70,24 +70,11 @@ codex mcp add openrig --env OPENRIG_SESSION_NAME=<address> -- rig mcp serve --fr
- **VS Code:** verified on 1.145. Older versions, such as 1.110, don't load Agent Plugins.
- **Cursor:** verified on 3.27, as a local plugin.
- **Copilot CLI:** verified on 1.0.107. Install from this repository's marketplace, and trust the folder when Copilot
CLI asks:
- **Copilot CLI:** not yet verified; the steps follow its documentation.
```bash
copilot plugin marketplace add mvschwarz/openrig
copilot plugin install openrig@openrig-app
```
Unlike the other apps, this installs the plugin from this repository's main branch, not from your OpenRig package, so
its skill can be newer than the OpenRig you run.
Copilot CLI also loads the skills in `~/.agents/skills`. Where OpenRig's own skills are installed there, it can choose
OpenRig's `openrig-skills` skill before this plugin's `working-with-an-openrig-team`. If it does, it may ask for a
skill pack that isn't there and get `pack_not_found`: ask it to use `working-with-an-openrig-team`.
In VS Code and Cursor, install the plugin from this folder with the app's own install-from-folder step. In VS Code
that's **Chat: Install Plugin from Source**; in Cursor, copy the folder to `~/.cursor/plugins/local/openrig` and reload.
The app starts one `openrig` server, `rig mcp serve --front-door`, from the root `mcp.json`, and should load the skill.
Install the plugin from this folder with the app's own install-from-folder step. In VS Code that's **Chat: Install Plugin
from Source**; in Cursor, copy the folder to `~/.cursor/plugins/local/openrig` and reload. The app starts one `openrig`
server, `rig mcp serve --front-door`, from the root `mcp.json`, and should load the skill.
- **No seat:** these apps can't set a seat for a plugin's server, so it starts with none. An app started from a shell
where `OPENRIG_SESSION_NAME` (or the older `RIGGED_SESSION_NAME`) is set passes it on, but only for that launch: on
@@ -103,20 +90,14 @@ The app starts one `openrig` server, `rig mcp serve --front-door`, from the root
- Environment export needs an HTTP work grant, which a plugin's local server never has.
- **To give the app a seat,** add your own MCP server that runs `rig mcp serve --front-door` with
`OPENRIG_SESSION_NAME=<address>` in its environment, and turn the plugin's `openrig` server off, so the app runs one
OpenRig server. The plugin's skill stays installed. Verified on VS Code 1.145, Cursor 3.27 and Copilot CLI 1.0.107,
where the team's questions then reach you and your answers are recorded, across a reopen:
OpenRig server. The plugin's skill stays installed. Verified on VS Code 1.145 and Cursor 3.27, where the team's
questions then reach you and your answers are recorded, across a reopen:
- **VS Code:** add it to your user `mcp.json` under a name other than `openrig`, such as `openrig-seat`, and disable
the plugin's `openrig` in **MCP: List Servers**. Two servers named `openrig` are hard to tell apart, and starting
one can stop the other.
- **Cursor:** add it to `.cursor/mcp.json` (the project's or your user one), turn it on, and disable
**openrig · Plugin** in **Customize → MCPs**.
- **Copilot CLI:** add it, then turn the plugin's server off. Copilot CLI saves both, so the seat holds in new
sessions:
```bash
copilot mcp add openrig-seat --env OPENRIG_SESSION_NAME=<address> -- rig mcp serve --front-door
copilot mcp disable openrig
```
- **Copilot CLI:** add it the CLI's own way and turn the plugin's server off; not yet verified.
## What it doesn't do
+4 -2
View File
@@ -6,6 +6,8 @@ export function daemonStartArgs(target: string): string[] {
|| url.username || url.password || url.search || url.hash || url.pathname !== "/") {
throw new Error("This target is not a local daemon endpoint. Reconnect after its owner starts it; no local daemon was started.");
}
return ["daemon", "start", "--no-kernel", "--host", url.hostname.replace(/^\[|\]$/g, ""),
"--port", url.port || "80"];
// The daemon's default bind already listens on 127.0.0.1, with Tailscale beside it when present; naming that host would
// make it the only listener. Another loopback spelling isn't in the default bind, so it's still named.
const host = url.hostname.replace(/^\[|\]$/g, "");
return ["daemon", "start", "--no-kernel", ...(host === "127.0.0.1" ? [] : ["--host", host]), "--port", url.port || "80"];
}
+2
View File
@@ -389,6 +389,8 @@ async function run(): Promise<void> {
} else {
void refreshCrashCart();
void live?.refresh();
// Daemon only: no seat was started. Start and return lists the rigs, the kernel first, to bring one back.
if (startup) view.dispatch({ type: "notice", message: "Daemon started; this start didn't start any seats. Press S to choose seats to bring back, the kernel's first." });
}
});
return;
+10
View File
@@ -163,6 +163,8 @@ export class StartupController {
const failure = s.notice;
await this.refresh();
if (!confirmed) { s.notice = failure; s.detail = failure; this.changed(); }
// refresh() moves the page on; read it again rather than through the narrowed `s.page`.
else if (this.state.page === "rigs") { s.notice = daemonOnlyNotice(s.rigs); this.changed(); }
return;
}
if (s.page === "rigs" && key === "k" && !s.rigs.some((r) => r.name === "kernel")) {
@@ -244,6 +246,14 @@ export class StartupController {
}
}
/** After a daemon-only start: this start brought back no seat, and the existing way to bring one back is the rig list
* here, where the kernel comes first. It says nothing about why any seat isn't running. */
export function daemonOnlyNotice(rigs: Array<{ name: string }>): string {
return rigs.some((r) => r.name === "kernel")
? "Daemon started; this start didn't start any seats. The kernel is listed first: choose it to see its seats and resume one."
: "Daemon started; this start didn't start any seats. Choose a rig, or press k to set up a kernel.";
}
export function startupLines(s: StartupState): Array<{ text: string; action?: Action }> {
const button = (text: string, key: string) => ({ text, action: { type: "startup" as const, key } });
const lines: Array<{ text: string; action?: Action }> = [
@@ -6,10 +6,16 @@ import { resolveCrashCartKey } from "../src/crash-cart/keys.js";
describe("local startup target", () => {
it("starts only the intended local daemon, leaving occupant selection to the user", () => {
// The default bind is 127.0.0.1 plus Tailscale when present, so naming 127.0.0.1 would drop the Tailscale listener.
expect(daemonStartArgs("http://127.0.0.1:17433")).toEqual([
"daemon", "start", "--no-kernel", "--host", "127.0.0.1", "--port", "17433",
"daemon", "start", "--no-kernel", "--port", "17433",
]);
});
it.each([["http://localhost:7433", "localhost"], ["http://[::1]:7433", "::1"]])(
"names %s's host, which the default bind doesn't listen on", (target, host) => {
expect(daemonStartArgs(target)).toEqual(["daemon", "start", "--no-kernel", "--host", host, "--port", "7433"]);
},
);
it.each(["https://example.test", "http://10.0.0.2:7433", "http://user:password@localhost:7433", "http://localhost:7433/other"])(
"does not turn %s into local startup authority", (target) => {
expect(() => daemonStartArgs(target)).toThrow("no local daemon was started");
+6 -1
View File
@@ -1,6 +1,6 @@
import { describe, it, expect, vi } from "vitest";
import { DaemonClient, launchNodeNotice } from "../src/daemon-client.js";
import { StartupController, startupLines, type StartupSeat } from "../src/startup.js";
import { StartupController, daemonOnlyNotice, startupLines, type StartupSeat } from "../src/startup.js";
import { createViewState, emptySnapshot } from "../src/state.js";
import { renderScreen } from "../src/render.js";
@@ -118,6 +118,11 @@ describe("TUI startup choices", () => {
expect(f.startDaemon).toHaveBeenCalledTimes(1);
expect(f.posts).toEqual([]);
expect(f.controller.state.page).toBe("rigs");
// It names the existing way back for the kernel, and claims nothing about why it isn't running.
expect(f.controller.state.notice).toBe("Daemon started; this start didn't start any seats. The kernel is listed first: choose it to see its seats and resume one.");
});
it("with no kernel rig, a daemon-only start points at choosing a rig or setting up a kernel", () => {
expect(daemonOnlyNotice([{ name: "work" }])).toBe("Daemon started; this start didn't start any seats. Choose a rig, or press k to set up a kernel.");
});
it("decline and refresh invalidate fresh consent with no launch effect", async () => {
const f = fixture(); await chooseOperator(f);
+8 -5
View File
@@ -1,8 +1,8 @@
#!/usr/bin/env node
// Lists lines added by a change that contain values tied to one machine, network or account, so
// the author can replace them with portable ones or keep them on purpose. Findings never fail it: a finding is a question, not a verdict. It exits 0
// with or without findings and 2 on an operational error (bad argument, git failure).
// the author can replace them with portable ones or keep them on purpose. A finding is a question, not a verdict: the default
// exits 0 with or without findings, --strict exits 1 when findings exist, and 2 is an operational error (bad argument, git failure).
//
// What it looks for:
// 1. Credentials: keys, tokens, passwords, key blocks.
@@ -17,6 +17,7 @@
// node scripts/portability-report.mjs --staged # what is staged for the next commit
// add --out report.md to also write the report to a file
// add --locations-only to list file and line without quoting the matched text (for public CI output)
// add --strict to exit 1 when findings exist (the default stays advisory: exit 0 either way)
import { execFileSync } from "node:child_process";
import { writeFileSync, realpathSync } from "node:fs";
@@ -133,11 +134,12 @@ export function renderReport(findings, label, { locationsOnly = false } = {}) {
}
function parseArguments(argv) {
const options = { staged: false, locationsOnly: false };
const options = { staged: false, locationsOnly: false, strict: false };
for (let index = 0; index < argv.length; index += 1) {
const key = argv[index];
if (key === "--staged") options.staged = true;
else if (key === "--locations-only") options.locationsOnly = true;
else if (key === "--strict") options.strict = true;
else if (["--from", "--to", "--out", "--repo"].includes(key)) options[key.slice(2)] = argv[++index];
else throw new Error(`Unknown argument: ${key}`);
}
@@ -163,12 +165,13 @@ export function main(argv = process.argv.slice(2)) {
label = `${from.slice(0, 8)}..${to.length > 12 ? to.slice(0, 8) : to}`;
}
const diff = git(repo, ["-c", "core.quotePath=false", "diff", "-U0", "--no-color", "--src-prefix=a/", "--dst-prefix=b/", "--diff-filter=ACMR", ...range]);
const report = renderReport(findPortabilityIssues(addedLines(diff)), label, {
const findings = findPortabilityIssues(addedLines(diff));
const report = renderReport(findings, label, {
locationsOnly: options.locationsOnly,
});
process.stdout.write(report);
if (options.out) writeFileSync(options.out, report);
return 0;
return options.strict && findings.length > 0 ? 1 : 0;
}
function invokedDirectly() {
+29
View File
@@ -173,3 +173,32 @@ test("strips Git's separator tab and leaves C-quoted filenames quoted", () => {
}
assert.deepEqual(addedLines("+++ b/ordinary name.md\t\n@@ -0,0 +1 @@\n+see /Users/alice/project\n"), [{ file: "ordinary name.md", line: 1, text: "see /Users/alice/project" }]);
});
test("--strict exits 1 on findings, keeps the advisory default at 0, and exits 0 on a clean stage", async () => {
const { mkdtempSync, writeFileSync, rmSync } = await import("node:fs");
const { tmpdir } = await import("node:os");
const { join } = await import("node:path");
const { fileURLToPath } = await import("node:url");
const { execFileSync, spawnSync } = await import("node:child_process");
const repo = mkdtempSync(join(tmpdir(), "openrig-portability-strict-"));
const script = fileURLToPath(new URL("./portability-report.mjs", import.meta.url));
try {
execFileSync("git", ["init", "--quiet", repo]);
execFileSync("git", ["-C", repo, "config", "user.email", "[email protected]"]);
execFileSync("git", ["-C", repo, "config", "user.name", "t"]);
writeFileSync(join(repo, "a.md"), "see /Users/alice/work\n");
execFileSync("git", ["-C", repo, "add", "--", "a.md"]);
const strictWithFindings = spawnSync(process.execPath, [script, "--repo", repo, "--staged", "--strict"], { encoding: "utf8" });
assert.equal(strictWithFindings.status, 1, strictWithFindings.stdout + strictWithFindings.stderr);
const advisoryWithFindings = spawnSync(process.execPath, [script, "--repo", repo, "--staged"], { encoding: "utf8" });
assert.equal(advisoryWithFindings.status, 0, advisoryWithFindings.stdout + advisoryWithFindings.stderr);
execFileSync("git", ["-C", repo, "commit", "--quiet", "-m", "seed"]);
writeFileSync(join(repo, "b.md"), "Nothing machine-specific here.\n");
execFileSync("git", ["-C", repo, "add", "--", "b.md"]);
const strictClean = spawnSync(process.execPath, [script, "--repo", repo, "--staged", "--strict"], { encoding: "utf8" });
assert.equal(strictClean.status, 0, strictClean.stdout + strictClean.stderr);
} finally { rmSync(repo, { recursive: true, force: true }); }
});