Files
openrig/packages/cli/test/client-headers.test.ts
Mike Schwarz c0da35be27 feat(seat): move a live seat between Codex and Claude Code, successor first (#1895)
## What this gives a person

When a provider reaches its usage limit, or a seat would do better on the other runtime, a person or their operator agent can move that one live seat between Codex and Claude Code without rebuilding the team:

```bash
rig seat set-runtime dev-impl@my-rig --runtime claude-code --reason "Codex limit" --dry-run
rig seat set-runtime dev-impl@my-rig --runtime claude-code --reason "Codex limit"
```

The seat keeps its address, connections, open queue rows and in-progress claims, and its successor gets a handover of what it was doing. The new runtime takes over before the old one stops. If anything goes wrong before the cutover, the current occupant keeps the seat, and `rig seat status` says so.

This builds on the design in #1214 by @Aschenbath (a staging session with a pane swap, a durable switch record, one commit transaction, recovery that rolls back before commit and finishes forward after, and re-resolving the seat's materials for the target), rebuilt on today's main with the changes below.

## How it works

1. **Preview (`--dry-run`).** It lists the target's settings and where each comes from (an explicit option, the seat's agent spec for the target runtime, or the runtime's default), what is cleared, startup slash commands a switch doesn't run, and the costs. A seat that can't switch (another runtime, more than one pane, no current occupant, a target that isn't installed, materials that don't resolve, a switch already open) is reported here, before anything stops.
2. **Successor first, under the seat's lifecycle lease.** The switch is journaled (`seat_runtime_switches`), then the successor starts in a staging session with the seat's name, a reserved occupant generation and the target's own materials (its seat skill storage, projections and startup files, with the identity's `runtime:` line rewritten).
3. **Handover and confirmation.** The successor receives the predecessor's screen, the seat recap and its open rows, and confirms with `rig seat set-runtime <seat> --confirm <switch-id> --note "<what I'll do first>"`. The daemon accepts that only from the successor's own session and reserved generation. This is the evidence that the successor can carry on: its model read the handover, its tools run, and it reaches OpenRig as the seat. With no confirmation within 180 s (`--confirm-timeout`), nothing commits, the staged successor is retired, and the predecessor keeps the seat.
4. **Cutover.** The panes are swapped, then one transaction commits the seat's runtime, cleared settings, permission selection, startup context, session, binding and tenure. It moves in-progress claims from the predecessor's generation to the successor's, takes a snapshot for the switch (restore tier, so the newest default restore matches the new runtime), and records a `seat.runtime_changed` event. The predecessor is then retired and the successor is told it now holds the seat.
5. **Recovery.** At daemon start, and with `rig seat set-runtime <seat> --recover`, an interrupted switch is finished from its record: before commit it is rolled back (panes swapped back, staged successor retired); after commit it is finished forward and never handed back. Where the panes match neither side, nothing is touched and status says why.

**Who holds the seat** is decided by one fact, the switch's `committed_at`, which is set in the same transaction as the configuration. Its `phase` records physical progress, and `attention` records why recovery stopped.

## The checks stay narrow

During a switch, the predecessor and successor share the seat's session name. To tell them apart, the CLI now sends the caller's own occupant generation (`X-OpenRig-Occupant-Generation`, from the seat's `OPENRIG_OCCUPANT_GENERATION`). The daemon refuses only the two generations a switch record names, on the seat it names, on queue create, claim, unclaim, update, handoff and handoff-and-complete, send and broadcast, and every mutating seat route (`set-model`, `set-effort`, `set-permissions`, `set-cwd`, `handover`, `stop`, and the rest):
- `occupant_retired_by_switch`: the predecessor after commit;
- `switch_not_committed`: the staged successor before commit, except its own `--confirm` and `--recover`. A successor whose switch was abandoned stays fenced while its retirement is unconfirmed (the switch keeps attention), and recovery at the next daemon start retires it; a failed retirement isn't a release of custody.

No header, an unknown generation, re-adopted seats and every other seat behave exactly as before. The activity hook ignores a retired predecessor and a staged successor, and keeps the staged successor's native identity on the switch record rather than the seat.

## Refusals and error codes

The operation itself: `invalid_runtime_settings`, `runtime_unchanged` (it names `set-model`, `set-effort` and `set-permissions`), `runtime_switch_unsupported`, `runtime_target_unavailable`, `runtime_switch_pending`, `runtime_switch_failed` (`committed: false`), and `runtime_switch_confirmation_refused` on the confirm route. The CLI adds `runtime_switch_outcome_unknown` when it stops waiting (it points to `rig seat status`). On existing paths, there are two: `occupant_retired_by_switch` and `switch_not_committed`. The existing lease refusal `draft_aware_lifecycle` is unchanged.

A `rig send` to a seat that is switching isn't refused or kept waiting. The daemon holds it at once on the existing held path (`outcome: retained`, `reason: runtime_switch_in_progress`) and says the seat is switching and until when; a send that had already been waiting when the seat moved says nothing was typed and to send again. Both are on the send's own answer, so the CLI makes no extra request.

## Costs (also in the preview and `docs/reference/getting-started.md`)

- A new conversation: native history isn't converted; only the handover carries over.
- The same address, a different pane and scrollback.
- The team file isn't rewritten. The database and `rig export` show the new runtime, while an imported YAML keeps the old one, so a later converge reports a deferred `change_runtime`.
- Source settings are cleared, not mapped: model, effort, Codex profile and an explicit native permission choice. A model or effort the agent spec declares for the source runtime doesn't apply either. Member and rig permission policy are kept.
- Two processes until cutover.
- Messages sent to the seat during the switch are held, not typed, and need sending again afterwards.
- A failed switch can leave the target's prepared files.
- Codex and Claude Code, single-pane seats only.

## Known limits

- Claude launch additions such as an agent file aren't applied to a switched seat.
- Startup slash commands aren't run; the preview lists them.
- On a seat with the draft-aware typing guard, a person typing into the old pane during the switch stops it (the guard protects their draft), and the predecessor keeps the seat.
- A successor's context-usage sidecar may be cleared at commit by the name-keyed invalidator; it is rewritten on the next reading.

## Tests

All with fakes (no real agent seats). Each test states what the person or calling agent sees.
- **New:**
  - `seat-runtime-switch-service`: preview, both directions, confirmation, no confirmation leaving the predecessor, exact claims, recovery at each point, and the fence on a switch's two processes. An abandoned successor that couldn't be retired stays fenced until recovery retires it, and a retired predecessor is refused on `set-model` and 11 other mutating seat routes while reads and other callers are unchanged. These cases failed first against the code without the fence.
  - `runtime-switch-custody`: the two refusals and the unchanged cases. A send to a switching seat is held at once without being typed; a send after commit goes as usual; a send that waited while the seat moved says so; a seat with no switch is unchanged.
  - `runtime-switch-materials`: settings from the seat's agent spec per runtime, cleared settings, target storage and startup files.
  - `seat-runtime-switch-route`.
  - CLI `seat-set-runtime` and `send-runtime-switch` (one request, the daemon's answer shown), plus a block in `client-headers`.
- **Daemon:** 38 files, the new ones plus the neighbouring suites (handover, launcher, queue, transport, activity, snapshots, restore, startup, tmux, migrations, stream subscriptions): 1,385 passed, 5 todo.
- **CLI:** `seat-set-runtime`, `send-runtime-switch`, `client-headers`, `local-owner-routing`, `send`, `send-header`, `seat` and `seat-lifecycle-verbs`: 8 files, 256 passed.
- **Build and docs:** the daemon build and CLI `tsc --noEmit` are clean, the first-run reference check reports 0 failures, and the docs guard passes.

Not run here: switches between real Claude Code and Codex seats in both directions, a real confirmation and a successor stuck at an approval prompt, a seat switching itself, real interruptions with a daemon restart, and a real restore after a switch. These need real accounts.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-10-24 10:01:10 -07:00

280 lines
15 KiB
TypeScript

import { describe, it, expect, vi, afterEach } from "vitest";
import * as fs from "node:fs";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import { DaemonClient, remoteDaemonClient, senderIdentityHeaders, SENDER_IDENTITY_HEADER, OCCUPANT_GENERATION_HEADER } from "../src/client.js";
import { resolveOriginSelfHostId } from "../src/daemon-lifecycle.js";
/** Walk every non-test, non-dist .ts under a package's src/, returning [absPath, contents]. */
function srcFiles(pkg: string): Array<[string, string]> {
// cwd-INDEPENDENT: derive the path from THIS test file's location, never process.cwd(). The old
// `path.resolve(process.cwd(), "..", pkg, "src")` vacuously returned [] when vitest ran from the repo
// root, silently no-op'ing the grep-guards below (clause 1's `.toEqual([])` would false-green). See the
// P18 guard rework in send.test.ts for the same fix.
const here = path.dirname(fileURLToPath(import.meta.url)); // .../packages/cli/test
const root = path.resolve(here, "..", "..", pkg, "src"); // .../packages/<pkg>/src
const out: Array<[string, string]> = [];
const walk = (dir: string): void => {
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, e.name);
if (e.isDirectory()) { if (e.name !== "node_modules" && e.name !== "dist" && e.name !== "test") walk(full); }
else if (e.isFile() && e.name.endsWith(".ts") && !e.name.endsWith(".d.ts")) out.push([full, fs.readFileSync(full, "utf8")]);
}
};
if (fs.existsSync(root)) walk(root);
return out;
}
function mockFetch(handler: (url: string, init: RequestInit) => Promise<Response>): typeof fetch {
return handler as unknown as typeof fetch;
}
describe("DaemonClient header merge", () => {
it("post() merges options.headers into the request", async () => {
let capturedHeaders: Record<string, string> = {};
const client = new DaemonClient("http://localhost:7433", {
fetchImpl: mockFetch(async (_url, init) => {
capturedHeaders = Object.fromEntries(
Object.entries(init.headers as Record<string, string>),
);
return new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}),
});
await client.post("/test", { data: 1 }, {
headers: { Authorization: "Bearer my-token" },
});
expect(capturedHeaders["Authorization"]).toBe("Bearer my-token");
expect(capturedHeaders["Content-Type"]).toBe("application/json");
});
it("get() merges options.headers into the request", async () => {
let capturedHeaders: Record<string, string> = {};
const client = new DaemonClient("http://localhost:7433", {
fetchImpl: mockFetch(async (_url, init) => {
capturedHeaders = Object.fromEntries(
Object.entries((init.headers ?? {}) as Record<string, string>),
);
return new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}),
});
await client.get("/test", { headers: { Authorization: "Bearer token-2" } });
expect(capturedHeaders["Authorization"]).toBe("Bearer token-2");
});
it("post() without options.headers works normally", async () => {
const client = new DaemonClient("http://localhost:7433", {
fetchImpl: mockFetch(async () => {
return new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}),
});
const res = await client.post("/test", { data: 1 });
expect(res.status).toBe(200);
});
});
// ── P18 sender-provenance: the seat identity is derived from the env ONCE at the transport chokepoint ──
describe("DaemonClient sender-identity header (P18)", () => {
const savedSession = process.env.OPENRIG_SESSION_NAME;
const savedRigged = process.env.RIGGED_SESSION_NAME;
afterEach(() => {
if (savedSession === undefined) delete process.env.OPENRIG_SESSION_NAME; else process.env.OPENRIG_SESSION_NAME = savedSession;
if (savedRigged === undefined) delete process.env.RIGGED_SESSION_NAME; else process.env.RIGGED_SESSION_NAME = savedRigged;
});
const captureHeaders = () => {
const box: { headers: Record<string, string> } = { headers: {} };
const client = new DaemonClient("http://localhost:7433", {
fetchImpl: mockFetch(async (_url, init) => {
box.headers = Object.fromEntries(Object.entries((init.headers ?? {}) as Record<string, string>));
return new Response("{}", { status: 200, headers: { "Content-Type": "application/json" } });
}),
});
return { client, box };
};
it("derives the identity header from OPENRIG_SESSION_NAME", () => {
process.env.OPENRIG_SESSION_NAME = "dev50-driver@rig";
expect(senderIdentityHeaders()).toEqual({ [SENDER_IDENTITY_HEADER]: "dev50-driver@rig" });
});
it("carries an authenticated caller's seat instead of the process env when the client is built for that caller", () => {
process.env.OPENRIG_SESSION_NAME = "env-seat@rig";
expect(senderIdentityHeaders(undefined, "alice@team")).toEqual({ [SENDER_IDENTITY_HEADER]: "alice@team" });
expect(senderIdentityHeaders("mm2-openrig1", "alice@team")).toEqual({ [SENDER_IDENTITY_HEADER]: "alice@team@mm2-openrig1" });
});
it("emits NO header when the seat env is absent (the daemon refuses; the CLI never fabricates)", () => {
delete process.env.OPENRIG_SESSION_NAME;
delete process.env.RIGGED_SESSION_NAME;
expect(senderIdentityHeaders()).toEqual({});
});
it("stamps the seat identity on EVERY request at the ONE transport chokepoint", async () => {
process.env.OPENRIG_SESSION_NAME = "alice@rig";
const { client, box } = captureHeaders();
await client.post("/inbox/drop", { destinationSession: "bob@rig", body: "x" });
expect(box.headers[SENDER_IDENTITY_HEADER]).toBe("alice@rig");
});
it("the transport identity is AUTHORITATIVE — a caller cannot override it via options.headers", async () => {
process.env.OPENRIG_SESSION_NAME = "alice@rig";
const { client, box } = captureHeaders();
await client.post("/inbox/drop", { body: "x" }, { headers: { [SENDER_IDENTITY_HEADER]: "mallory@rig" } });
expect(box.headers[SENDER_IDENTITY_HEADER]).toBe("alice@rig"); // the seat env wins, not the caller's claim
});
});
describe("slice 16: a seat's request carries its own occupant generation", () => {
const saved = { session: process.env.OPENRIG_SESSION_NAME, generation: process.env.OPENRIG_OCCUPANT_GENERATION };
afterEach(() => {
if (saved.session === undefined) delete process.env.OPENRIG_SESSION_NAME; else process.env.OPENRIG_SESSION_NAME = saved.session;
if (saved.generation === undefined) delete process.env.OPENRIG_OCCUPANT_GENERATION; else process.env.OPENRIG_OCCUPANT_GENERATION = saved.generation;
});
it("so a runtime switch can tell the seat's two processes apart", () => {
process.env.OPENRIG_SESSION_NAME = "dev-impl@rig";
process.env.OPENRIG_OCCUPANT_GENERATION = "0b9d8c7e-1111-4222-8333-444455556666";
expect(senderIdentityHeaders()).toEqual({ [SENDER_IDENTITY_HEADER]: "dev-impl@rig", [OCCUPANT_GENERATION_HEADER]: "0b9d8c7e-1111-4222-8333-444455556666" });
});
it("no generation without one in the seat env, and none for a client acting for another caller", () => {
process.env.OPENRIG_SESSION_NAME = "dev-impl@rig";
delete process.env.OPENRIG_OCCUPANT_GENERATION;
expect(senderIdentityHeaders()).toEqual({ [SENDER_IDENTITY_HEADER]: "dev-impl@rig" });
process.env.OPENRIG_OCCUPANT_GENERATION = "0b9d8c7e-1111-4222-8333-444455556666";
expect(senderIdentityHeaders(undefined, "alice@team")).toEqual({ [SENDER_IDENTITY_HEADER]: "alice@team" });
});
it("a malformed generation isn't sent", () => {
process.env.OPENRIG_SESSION_NAME = "dev-impl@rig";
process.env.OPENRIG_OCCUPANT_GENERATION = "not a generation\nX-Injected: 1";
expect(senderIdentityHeaders()).toEqual({ [SENDER_IDENTITY_HEADER]: "dev-impl@rig" });
});
});
// ── A4 — HTTP-path origin-triple carry: a REMOTE-targeting client stamps the ORIGIN triple so the
// remote daemon's wrapPaneEnvelope renders the ORIGIN host, not the destination's. Identity stays
// DERIVED (env session + THIS host's selfHostId), never caller-supplied. These lock the composition
// mechanism; they would fail against the pre-A4 stamp (which ignored the arg and emitted 2-part). ──
describe("A4 — origin-triple carry (senderIdentityHeaders + remoteDaemonClient)", () => {
const savedSession = process.env.OPENRIG_SESSION_NAME;
const savedRigged = process.env.RIGGED_SESSION_NAME;
afterEach(() => {
if (savedSession === undefined) delete process.env.OPENRIG_SESSION_NAME; else process.env.OPENRIG_SESSION_NAME = savedSession;
if (savedRigged === undefined) delete process.env.RIGGED_SESSION_NAME; else process.env.RIGGED_SESSION_NAME = savedRigged;
});
it("2-part session + originSelfHostId ⇒ composes the ORIGIN triple member@rig@selfHostId", () => {
process.env.OPENRIG_SESSION_NAME = "dev50@v-rig";
expect(senderIdentityHeaders("mm2-openrig1")).toEqual({ [SENDER_IDENTITY_HEADER]: "dev50@v-rig@mm2-openrig1" });
});
it("pin 6 — an already-3-part origin is preserved VERBATIM, never re-stamped with this host", () => {
process.env.OPENRIG_SESSION_NAME = "dev50@v-rig@origin-host";
expect(senderIdentityHeaders("relay-host")).toEqual({ [SENDER_IDENTITY_HEADER]: "dev50@v-rig@origin-host" });
});
it("pin 4 — no originSelfHostId (LOCAL client) ⇒ 2-part, byte-identical to today", () => {
process.env.OPENRIG_SESSION_NAME = "dev50@v-rig";
expect(senderIdentityHeaders()).toEqual({ [SENDER_IDENTITY_HEADER]: "dev50@v-rig" });
});
it("pin 5 — absent selfHostId (fetchSelfHostId fail-open ⇒ empty) ⇒ 2-part, no new failure mode", () => {
process.env.OPENRIG_SESSION_NAME = "dev50@v-rig";
expect(senderIdentityHeaders("")).toEqual({ [SENDER_IDENTITY_HEADER]: "dev50@v-rig" });
});
it("no session ⇒ NO header regardless of originSelfHostId (the daemon refuses; CLI never fabricates)", () => {
delete process.env.OPENRIG_SESSION_NAME;
delete process.env.RIGGED_SESSION_NAME;
expect(senderIdentityHeaders("mm2-openrig1")).toEqual({});
});
it("pin 5 (fail-open, whole resolution) — resolveOriginSelfHostId SWALLOWS a throwing local probe ⇒ undefined; a remote op NEVER depends on local daemon health", async () => {
const savedUrl = process.env.OPENRIG_URL; const savedRurl = process.env.RIGGED_URL;
delete process.env.OPENRIG_URL; delete process.env.RIGGED_URL; // force the getDaemonStatus branch
try {
// deps whose probe throws — getDaemonStatus is OUTSIDE fetchSelfHostId's own catch, so only a
// whole-body try/catch keeps this fail-open (the regression the full gate caught).
const deps = { fetch: async () => { throw new Error("local probe exploded"); }, exists: () => { throw new Error("boom"); } } as never;
await expect(resolveOriginSelfHostId(deps)).resolves.toBeUndefined();
} finally {
if (savedUrl === undefined) delete process.env.OPENRIG_URL; else process.env.OPENRIG_URL = savedUrl;
if (savedRurl === undefined) delete process.env.RIGGED_URL; else process.env.RIGGED_URL = savedRurl;
}
});
it("remoteDaemonClient ⇒ the constructed client stamps the TRIPLE on the wire (via the injected factory)", async () => {
process.env.OPENRIG_SESSION_NAME = "dev50@v-rig";
const box: { headers: Record<string, string> } = { headers: {} };
const factory = (url: string) => new DaemonClient(url, {
fetchImpl: (async (_u: string, init: RequestInit) => {
box.headers = Object.fromEntries(Object.entries((init.headers ?? {}) as Record<string, string>));
return new Response("{}", { status: 200, headers: { "Content-Type": "application/json" } });
}) as unknown as typeof fetch,
});
const client = remoteDaemonClient(factory, "http://vps-b:7433", "mm2-openrig1");
expect(client.originSelfHostId).toBe("mm2-openrig1");
await client.post("/api/transport/broadcast", { text: "hi" });
expect(box.headers[SENDER_IDENTITY_HEADER]).toBe("dev50@v-rig@mm2-openrig1"); // ORIGIN triple on the wire
});
it("a LOCAL client (not built via remoteDaemonClient) stamps 2-part on the wire — the scope stays narrow", async () => {
process.env.OPENRIG_SESSION_NAME = "dev50@v-rig";
const box: { headers: Record<string, string> } = { headers: {} };
const client = new DaemonClient("http://localhost:7433", {
fetchImpl: (async (_u: string, init: RequestInit) => {
box.headers = Object.fromEntries(Object.entries((init.headers ?? {}) as Record<string, string>));
return new Response("{}", { status: 200, headers: { "Content-Type": "application/json" } });
}) as unknown as typeof fetch,
});
expect(client.originSelfHostId).toBeUndefined();
await client.post("/api/transport/send", { text: "hi" });
expect(box.headers[SENDER_IDENTITY_HEADER]).toBe("dev50@v-rig"); // unchanged — no triple on the local path
});
// ── A4 GREP-GUARD — the two structural laws, asserted as EFFECTS (canonicity, not comments). ──
describe("grep-guard (structural properties, not conventions)", () => {
it("clause 1 — NO remote client is constructed directly: zero clientFactory(<registry host>.url) in src (all 5 route through remoteDaemonClient)", () => {
// Non-empty operand: a `.toEqual([])` over an empty walk would vacuously pass (absence ≠ proof).
expect(srcFiles("cli").length, "src walk returned no files — cwd/path resolution is broken").toBeGreaterThan(0);
const hits: string[] = [];
for (const [file, body] of srcFiles("cli")) {
body.split("\n").forEach((line, i) => {
// a registry host url passed straight to clientFactory — the remote-targeting pattern the fix routes.
if (/clientFactory\(\s*(host|httpHost)\.url\s*\)/.test(line)) hits.push(`${path.basename(file)}:${i + 1}`);
});
}
expect(hits, `direct remote constructions must route through remoteDaemonClient; found: ${hits.join(", ")}`).toEqual([]);
});
it("clause 2 — pin 3 structurally true: `.originSelfHostId =` is ASSIGNED in EXACTLY ONE place (remoteDaemonClient, client.ts)", () => {
const hits: string[] = [];
for (const [file, body] of srcFiles("cli")) {
body.split("\n").forEach((line, i) => {
const t = line.trim();
if (t.startsWith("//") || t.startsWith("*") || t.startsWith("/*")) return; // skip comments
// an ASSIGNMENT to the field (`x.originSelfHostId = …`), not the declaration (`originSelfHostId?: string`).
if (/\.originSelfHostId\s*=\s*[^=]/.test(line)) hits.push(`${path.basename(file)}:${i + 1}`);
});
}
expect(hits.length, `originSelfHostId must be assigned in exactly ONE place; found: ${hits.join(", ")}`).toBe(1);
expect(hits[0]).toMatch(/^client\.ts:/);
});
});
});