Compare commits

...
Author SHA1 Message Date
dev-driver 7f35e11fec fix(restore): a kept pane keeps its binding through the takeover, and a failed registration never kills it
The delivery guard resolves a pane only through the seat's binding, so clearing it before the takeover left TERM, KILL and the respawn with no target. The binding now stays for a kept pane, and the launch accepts it when it names the kept session and pane. The cleanup after a failed registration removes only a session this launch created: a kept pane is the person's own, and is left for recovery.
2026-10-25 09:45:28 +00:00
dev-driver f0f2147905 test(restore): a kept pane's takeover passes the daemon's delivery guard, and a failed registration leaves the person's session alone
The relaunch in place signals and respawns the seat's pane through the real TmuxAdapter behind a real SeatDeliveryGuard, under the seat's lifecycle lease, as a daemon wires it. A DB failure at the registration after the respawn must not kill the kept session.
2026-10-25 09:45:28 +00:00
dev-driver 337bd64926 fix(provider): an unknown switch outcome after a thrown lease still carries the seat's report 2026-10-25 08:47:40 +00:00
dev-driver 8cc6468dd3 fix(provider): a Claude seat's switch keeps its pane and names the source the seat bills
The seat is relaunched from the snapshot in its own pane (its process ended in place, the pane and scrollback kept) by a
keep-pane option on restore's single-seat launch, under the seat's lifecycle lease, revalidated after a wait. Only a refusal
before the seat was touched reads as unchanged; a thrown or unresumed relaunch reads unknown with the snapshot's recovery.
The token step unsets what outranks the token for Claude's process and survives the managed reduction; a relaunch's token
warning is carried; --stored-login names an inherited token; the printed way back quotes its path.
2026-10-25 08:38:26 +00:00
dev-driver da78915c5e test: a Claude seat's switch keeps its pane, names the source the seat bills, serializes on the seat, and reports uncertain relaunches as unknown 2026-10-25 08:38:16 +00:00
dev-driver b71bd5245c fix(cli): a timed-out switch points at the restart that finished after it was sent
rig seat restart --plan reports lastRestart's outcome and finishedAt, not a start time, so the timeout text names finishedAt.
2026-10-25 08:09:16 +00:00
dev-driver 44a39774d9 Merge origin/main into feat/claude-seat-token
main now carries #1972 (1de69844). The two conflicts were list ends: migration 138 sits after main's new 136 in ALL_MIGRATIONS and in the full test fixture.
2026-10-25 08:09:03 +00:00
dev-driver 76d18b8311 Merge remote-tracking branch 'origin/feat/provider-switch-seat' into feat/claude-seat-token
# Conflicts:
#	packages/cli/src/commands/provider.ts
#	scripts/skill-edge-digests.generated.json
2026-10-25 07:26:02 +00:00
dev-driver 5ecb4241ba fix(provider): a waited switch whose seat moved homes reports seat_home_changed before any refusal from the new home 2026-10-25 07:12:55 +00:00
dev-driver 96a9ff69ba fix(provider): a switch that waited on its home checks the seat again first; printed restarts paste as they are; a timeout says it may be queued
- A switch that waited behind another on the same Codex home reruns the seat's precheck before it saves or activates
  anything. A refusal leaves the login alone. A seat that moved to another home meanwhile gets seat_home_changed, and
  neither home is touched. A switch that didn't wait goes on as before; its restart still validates the seat itself.
- Each printed rig seat restart carries a --reason, so it runs as pasted.
- After a CLI timeout, the switch may still be queued behind another on the home, with nothing changed yet. The CLI
  now says it has acted once the seat's plan shows a lastRestart that started after this command, and not to switch
  again before then.
2026-10-25 06:57:38 +00:00
dev-driver 3a9c5008ab test: a seat switch that waited checks the seat again before changing the login; printed restarts paste as they are; a timed-out switch may still be queued 2026-10-25 06:57:38 +00:00
dev-driver 5927a36e69 feat(provider): rig provider switch --seat moves a Claude seat to its own subscription token on the same conversation
A Claude seat keeps its config dir and gets its own CLAUDE_CODE_OAUTH_TOKEN, read from a token file as Claude starts
(--to-account <label> --token-file <path>; --stored-login moves it back). The move uses restore's own resume path,
which restore already offers for an idle Claude pane: rig seat stop, then rig launch <rig> <seat> --snapshot-id.
- Before anything changes: the reference must be valid, its file must apply (checked, never read), the seat must be
  running, nothing Claude Code ranks above the token may be set (each named with its remedy), and a snapshot taken now
  must resume the seat's conversation.
- Then it sets the reference, stops the seat, and launches only that seat from that snapshot. claude-resume reruns
  --resume <the same conversation> with the seat's token step, in a new tmux session at the same address.
- A failed stop puts the reference back. A relaunch that doesn't report a resumed conversation reads unknown, with its
  recovery command and the way back, and is never retried.
- The report names the label and file (never the contents), the conversation, the way back, and what /status shows
  after a tool turn. The label is the operator's, not an observed account.

No new process-identity code and no /proc, so it isn't Linux-only. Seats sharing the config dir keep their own login:
Claude Code reads the token from that process's environment and doesn't save it (the authentication docs).
2026-10-25 06:47:18 +00:00
dev-driver 86bd3c36f4 test: the provider routes lend a Claude seat's relaunch and pass its token file or the stored login through 2026-10-25 06:46:27 +00:00
dev-driver f2a2eada63 test: rig provider switch --seat moves a Claude seat to its own token on the same conversation, through restore's resume path 2026-10-25 06:41:51 +00:00
dev-driver 1b276a986f Merge remote-tracking branch 'origin/feat/provider-switch-seat' into feat/claude-seat-token 2026-10-25 06:39:27 +00:00
dev-driver 0a7babd88f test: the classic token-step cases give the launch environment an instance home, as a seat has 2026-10-25 06:31:16 +00:00
dev-driver b74dd594b6 feat(claude): rig seat status shows a seat's own token reference, and a launch that didn't apply it is recorded
- rig seat status (and its JSON, claude_token) names the reference by label and file, and whether that file would give
  Claude the token at the next launch. It checks the file without reading it. When it wouldn't apply, it says Claude
  starts on the config dir's stored login, and how to fix it.
- A launch that starts Claude without the seat's token (the file missing, empty or unreadable, or a pane shell that
  can't run the token step) records seat.claude_token_not_applied in the event log: the label, the file path and the
  reason, never the value. Recording is best-effort; the launch still starts and reports it.
2026-10-25 06:23:21 +00:00
dev-driver faec860c6f test: rig seat status names a Claude seat's own token and whether it would apply; a launch without it is recorded 2026-10-25 06:23:21 +00:00
dev-driver 31334e5f81 fix(claude): the classic launch's sender keeps the bound session's narrowed type 2026-10-25 06:19:06 +00:00
dev-driver 157cf502e1 fix(provider): a seat switch keeps each way back, runs one at a time per home, and tells peers they stop at their next refresh
- A save of the home's current login that fails for any reason but "no active login" (unsafe_path, io_error, a name
  already taken) stops the switch before activation: failed_safely, outgoing_login_not_saved, nothing changed.
- The recovery copy keeps milliseconds in its name and never replaces a saved login, so an earlier receipt's undo
  restores its own bytes.
- Switches on one Codex home (by real path) run one at a time in the daemon, so a second switch can't activate another
  login while the first seat restarts.
- A peer on the same home keeps its current login only until its next token refresh, when Codex (0.164.0 auth manager)
  stops it with "signed in to another account" until it's restarted. The report gives each one its
  rig seat restart <peer> --keep-conversation, and the help, the skill, the failed-restart message and the restart's
  peer line now say so. The CLI labels other seats by their home.
- rig provider switch waits as long as rig seat restart does (120 s; it was the 5 s default), and a CLI timeout reads
  unknown with how to check, never a failure.
2026-10-25 06:13:54 +00:00
dev-driver 3e6c3ea667 test: a provider switch keeps each way back, runs one at a time per home, and tells peers they stop at their next refresh
- A home whose current login can't be saved first (io_error, unsafe_path) changes nothing: the switch stops before
  activation with outgoing_login_not_saved.
- Each switch keeps its own recovery copy (milliseconds in the name, never overwritten), so an earlier receipt's undo
  restores its own login. Real files in a temporary Codex home; only the restart is stubbed.
- Two switches on one home run one at a time, so the first restart loads the login its receipt names.
- Each same-home peer gets its restart step; the failed-restart message names the next-token-refresh stop.
- The CLI waits as long as rig seat restart does, labels each peer by its home, and reports a CLI timeout as unknown.
2026-10-25 06:11:44 +00:00
dev-driver 2123324b67 Merge remote-tracking branch 'origin/main' into feat/provider-switch-seat
# Conflicts:
#	scripts/skill-edge-digests.generated.json
2026-10-25 06:09:38 +00:00
dev-driver e9d37c1acf wip(claude): D1 fallback (launch on the stored login, warn), migration 138, the classic launch's token step
Desk D1/D2 (qitem-20261025-dd-provider-switch-claude-d60p, 05:47Z). Not finished: rig seat status, a
durable record that the reference wasn't applied, and the seat-attention question.
2026-10-25 05:54:27 +00:00
dev-driver 14cde2080a test: an unreadable seat token file starts Claude on its stored login and says so; the classic launch runs the token step in a POSIX pane 2026-10-25 05:54:27 +00:00
dev-driver c7e5c2ff3b feat(claude): name what would outrank a seat's own subscription token before it moves
ANTHROPIC_AUTH_TOKEN, ANTHROPIC_API_KEY, a cloud provider switch or an apiKeyHelper would bill a
different source than the seat's token. The check reads the seat's launch environment and the user,
project and project-local settings it loads (the repository root's, for a worktree), names each one
with its remedy, edits nothing, and says what it couldn't read (managed settings).
2026-10-25 05:46:36 +00:00
dev-driver 661a66e126 test: what would outrank a Claude seat's own token is named, with its remedy, before the seat moves 2026-10-25 05:46:34 +00:00
dev-driver 8854a217b9 feat(claude): a seat's own subscription token file, kept by reference and read as Claude starts
A Claude seat can carry { label, tokenFile } (nodes.claude_token_ref). Its managed launch reads the
file in the launch script, after the environment is reduced, so the token reaches only that Claude
process and never OpenRig's records, the launch command or argv. An unreadable or empty file stops
the launch rather than run Claude on the config dir's stored login.
2026-10-25 05:40:37 +00:00
dev-driver d584b49003 test: a Claude seat's own subscription token file reaches only its Claude process, by reference 2026-10-25 05:38:22 +00:00
dev-driver 9d9e3aa558 fix(provider): keep the provider types free of the restart module's imports
The reactive tap's import closure must not reach pane capture; a type import of PeerReport pulled in
seat-restart-in-place.ts. The peer shape is declared in the provider types instead.
2026-10-25 04:47:05 +00:00
dev-driver 8f1693b796 feat(provider): rig provider switch --seat moves one Codex seat to a login saved in its home
The switch plans the seat's in-place restart first, so a refusal changes nothing; then it saves the
home's outgoing login, activates the chosen one (rig auth's file operations, now in the daemon so
both can use them) and restarts the seat in place with the same conversation and launch settings.
Peers on the home are listed, uptake is left to rig seat restart --verify, and an uncertain restart
is reported, never retried. Codex only and Linux only, like the restart it uses.
2026-10-25 04:10:02 +00:00
dev-driver 60a669fd6b test: rig provider switch --seat moves one seat to a login saved in the Codex home it runs on 2026-10-25 04:08:02 +00:00
31 changed files with 1468 additions and 70 deletions

No files matched your search

+78 -16
View File
@@ -22,8 +22,8 @@ export const USAGE_ACROSS_HOSTS_REFRESH_TIMEOUT_MS = 75_000;
type Peer = { logicalId: string; sessionName: string | null; home?: "same" | "different" | "unknown"; effect: string; step?: string };
type SeatView = { home?: string; activity?: string; peers: Peer[] };
type SwitchReport = { home: string; account: string; loginChanged: boolean; undo: string | null; peers: Peer[];
restart?: { recoveryCommand?: string }; next?: string };
type SwitchReport = { home?: string; account: string; tokenFile?: string; conversation?: string | null; loginChanged: boolean; undo: string | null; peers: Peer[];
restart?: { recoveryCommand?: string; remainingStep?: string }; next?: string; warnings?: string[]; tokenApplied?: boolean };
// A switch waits as long as `rig seat restart` does: the seat's in-place restart runs inside it.
const SWITCH_TIMEOUT_MS = 120_000;
@@ -38,6 +38,23 @@ function printSeat(seat: SeatView): void {
}
}
type AccountOpts = { toAccount?: string; tokenFile?: string; storedLogin?: boolean };
/** The account a switch names: a Codex login's saved name, a Claude token's label with its file, or a Claude seat's stored
* login. Null when neither --to-account nor --stored-login was given. */
function targetAccount(opts: AccountOpts): { toAccount: string; tokenFile?: string | null } | null {
if (opts.storedLogin) return { toAccount: "stored-login", tokenFile: null };
if (!opts.toAccount) return null;
return { toAccount: opts.toAccount, ...(opts.tokenFile !== undefined ? { tokenFile: opts.tokenFile } : {}) };
}
function printClaude(claude: { label: string; tokenFile: string | null; checked: string[]; notChecked: string[]; instead?: string }): void {
console.log(`Claude token: ${claude.tokenFile ? `"${claude.label}", read from ${claude.tokenFile} when Claude starts` : "none (the config dir's stored login)"}`);
if (claude.instead) console.log(`instead of the stored login, the seat would use ${claude.instead}`);
if (claude.checked.length) console.log(`checked for anything that outranks it: ${claude.checked.join("; ")}`);
if (claude.notChecked.length) console.log(`not checked (the seat's /status shows what it uses): ${claude.notChecked.join("; ")}`);
}
export function providerCommand(depsOverride?: StatusDeps): Command {
const cmd = new Command("provider").description(
"Provider accounts and usage signals, and moving one Codex seat to another login saved in its Codex home",
@@ -491,31 +508,44 @@ export function providerCommand(depsOverride?: StatusDeps): Command {
.command("precheck")
.description("Whether a seat can move to a login: its in-place restart plan (nothing is stopped) and the login saved in the Codex home it runs on")
.requiredOption("--seat <s>", "The seat session")
.requiredOption("--to-account <a>", "The login's saved name in the seat's Codex home (rig auth list --codex-home <home>)")
.option("--to-account <a>", "Codex: the login's saved name in the seat's Codex home (rig auth list --codex-home <home>). Claude: your label for the token")
.option("--token-file <path>", "Claude: the absolute path of a 0600 file holding the seat's own subscription token; it's read only as Claude starts")
.option("--stored-login", "Claude: move the seat back to its config dir's stored login")
.option("--json", "Output as parseable JSON")
.action(async (opts: { seat: string; toAccount: string; json?: boolean }) => {
.action(async (opts: AccountOpts & { seat: string; json?: boolean }) => {
const target = targetAccount(opts);
if (!target) {
console.error("Name the account: --to-account <name> (with --token-file <path> for a Claude seat), or --stored-login for a Claude seat.");
process.exitCode = 1;
return;
}
const client = await getClient(getDeps());
if (!client) {
process.exitCode = 1;
return;
}
const qs = new URLSearchParams({ seat: opts.seat, toAccount: opts.toAccount });
const res = await client.get<{ safe: boolean; reasons?: string[]; message?: string; seat?: SeatView }>(`/api/provider/precheck?${qs.toString()}`);
const qs = new URLSearchParams({ seat: opts.seat, toAccount: target.toAccount,
...(target.tokenFile === null ? { storedLogin: "1" } : target.tokenFile !== undefined ? { tokenFile: target.tokenFile } : {}) });
const res = await client.get<{ safe: boolean; reasons?: string[]; message?: string; seat?: SeatView;
claude?: Parameters<typeof printClaude>[0] }>(`/api/provider/precheck?${qs.toString()}`);
if (handleHttpError(res, "precheck")) return;
if (opts.json) console.log(JSON.stringify(res.data));
else {
console.log(res.data.safe ? "SAFE" : `UNSAFE: ${(res.data.reasons ?? []).join(", ")}`);
if (res.data.message) console.log(res.data.message);
if (res.data.seat) printSeat(res.data.seat);
if (res.data.claude) printClaude(res.data.claude);
}
if (!res.data.safe) process.exitCode = 1; // let scripts gate on an unsafe verdict
});
cmd
.command("switch")
.description("Move one Codex seat to another login saved in the Codex home it runs on, keeping its conversation (Codex only, Linux only)")
.description("Move one seat to another subscription login, keeping its conversation: a Codex seat to a login saved in its Codex home (Linux only), a Claude seat to its own token file")
.requiredOption("--seat <s>", "The seat session")
.requiredOption("--to-account <a>", "The login's saved name in the seat's Codex home (rig auth list --codex-home <home>)")
.option("--to-account <a>", "Codex: the login's saved name in the seat's Codex home (rig auth list --codex-home <home>). Claude: your label for the token")
.option("--token-file <path>", "Claude: the absolute path of a 0600 file holding the seat's own subscription token; it's read only as Claude starts")
.option("--stored-login", "Claude: move the seat back to its config dir's stored login")
.option("--force-unsafe", "Kept for scripts; a seat's checks can't be overridden, since each means nothing safe to restart or no such login")
.option("--json", "Output as parseable JSON")
.addHelpText("after", `
@@ -529,11 +559,33 @@ One switch runs at a time per home; a switch that waited checks the seat again b
isn't verified until the seat completes a tool turn: run the printed rig seat restart --keep-conversation --verify
--expect-account. An uncertain restart is reported, never retried.
Limits of this first increment:
- Codex seats only. A Claude seat is refused (rebind_unsupported_for_runtime).
Codex limits:
- Linux only: the in-place restart reads the running process from /proc, so elsewhere it refuses before anything changes.
- Same home: the login must already be saved in the home the seat runs on; the seat doesn't move to another home.`)
.action(async (opts: { seat: string; toAccount: string; forceUnsafe?: boolean; json?: boolean }) => {
- Same home: the login must already be saved in the home the seat runs on; the seat doesn't move to another home.
A Claude seat keeps its config dir and gets its own subscription token: --to-account <your label> --token-file <absolute path
to a 0600 file holding the token>. Only the path is kept, as the seat's reference; the launch reads the file as Claude starts,
so the token never passes through OpenRig. First the daemon checks that the file would apply and that nothing Claude Code ranks
above the token is set (a cloud provider, ANTHROPIC_AUTH_TOKEN, ANTHROPIC_API_KEY, an apiKeyHelper), and refuses with each one
and its remedy if it is. When the token applies, the launch also unsets those names for Claude's process, so the pane shell's
own exports can't outrank it. It takes a snapshot and checks that the snapshot resumes the seat's conversation. Then, holding
the seat so no other stop, launch or switch runs on it in between, it sets the reference and relaunches the seat from that
snapshot in its own pane: Claude ends there, the pane and its scrollback stay, and restore resumes the same conversation in it.
Work in flight when it stopped doesn't carry over. Only a refusal before Claude was touched reads as unchanged; a relaunch that
doesn't report a resumed conversation reads unknown, with the snapshot's recovery command, and isn't retried.
Seats sharing the config dir keep their own login: Claude Code reads the token from that one process's environment and doesn't
save it. That environment is Claude's, so the tools, hooks and MCP servers it starts inherit the token, and one that prints its
environment writes it into the transcript. A token-authenticated seat can make model requests but can't use claude.ai
connectors or Remote Control; its local MCP servers still work. --stored-login moves it back to the config dir's stored login;
a CLAUDE_CODE_OAUTH_TOKEN the seat still receives outranks that login, and the report says which token it will use. Claude's
/status shows token auth with no account email, so the label is yours, not an observed account.`)
.action(async (opts: AccountOpts & { seat: string; forceUnsafe?: boolean; json?: boolean }) => {
const target = targetAccount(opts);
if (!target) {
console.error("Name the account: --to-account <name> (with --token-file <path> for a Claude seat), or --stored-login for a Claude seat.");
process.exitCode = 1;
return;
}
const client = await getClient(getDeps());
if (!client) {
process.exitCode = 1;
@@ -545,7 +597,7 @@ Limits of this first increment:
try {
res = await client.post<SwitchResponse["data"]>("/api/provider/switch", {
seat: opts.seat,
toAccount: opts.toAccount,
...target,
forceUnsafe: opts.forceUnsafe ?? false,
}, { timeoutMs: SWITCH_TIMEOUT_MS });
} catch (err) {
@@ -553,8 +605,9 @@ Limits of this first increment:
// The daemon may still be switching: a timeout is neither a failure nor a reason to send it again.
res = { status: 200, data: { outcome: "unknown", reasons: ["switch_outcome_unknown"],
message: `The CLI timed out waiting for the daemon. The switch may still be running, or queued behind another switch on the same Codex home; a queued switch hasn't changed anything yet. `
+ `It has restarted the seat once rig seat restart ${opts.seat} --keep-conversation --plan shows a lastRestart that started after ${startedAt}. `
+ `Until then an eligible plan, or an older lastRestart, doesn't mean it ended, so don't run the switch again: a second one would queue behind it.` } };
+ `For a Codex seat, it has restarted the seat once rig seat restart ${opts.seat} --keep-conversation --plan shows a lastRestart that finished after ${startedAt}. `
+ `Until then an eligible plan, or an older lastRestart, doesn't mean it ended, so don't run the switch again: a second one would queue behind it. `
+ `For a Claude seat, rig seat status ${opts.seat} shows its token reference and whether it runs; a second switch would wait for this one.` } };
}
if (handleHttpError(res, "switch")) return;
if (opts.json) console.log(JSON.stringify(res.data));
@@ -562,10 +615,19 @@ Limits of this first increment:
const { outcome, reasons, message, seat } = res.data;
console.log(reasons?.length ? `${outcome ?? "unknown"}: ${reasons.join(", ")}` : outcome ?? "unknown");
if (message) console.log(message);
if (seat) {
if (seat?.home) {
console.log(`home: ${seat.home}${seat.loginChanged ? ` (now "${seat.account}")` : ""}`);
if (seat.undo) console.log(`to switch the home back: ${seat.undo}`);
} else if (seat) {
console.log(`Claude token: ${seat.tokenFile ? `"${seat.account}", read from ${seat.tokenFile}` : "the config dir's stored login"}${seat.loginChanged
? seat.tokenApplied === false ? " (reference saved; the token didn't take effect)" : " (set)" : ""}`);
for (const warning of seat.warnings ?? []) console.log(`warning: ${warning}`);
if (seat.conversation) console.log(`conversation: ${seat.conversation}`);
if (seat.undo) console.log(`to switch back: ${seat.undo}`);
}
if (seat) {
if (seat.restart?.recoveryCommand) console.log(`recovery: ${seat.restart.recoveryCommand}`);
if (!seat.home && seat.restart?.remainingStep) console.log(seat.restart.remainingStep);
printSeat({ peers: seat.peers });
if (seat.next) console.log(`next: ${seat.next}`);
}
+6
View File
@@ -67,6 +67,7 @@ interface SeatStatusResponse {
source: "member" | "rig" | "daemon" | "native_default";
ignored?: { source: string; value: string };
};
claude_token?: { label: string; token_file: string; file: "readable" | "unusable"; problem?: string; notice?: string };
session_reason?: string | null;
startup_status: string | null;
occupant_lifecycle: string;
@@ -202,6 +203,11 @@ function printHuman(status: SeatStatusResponse): void {
console.log(`Runtime home (${h.variable}) for launches: ${display(h.home)} (${from}; configured, not proof of the login a running session uses)`);
if (h.ignored) console.log(` Ignored: its ${h.ignored.source} env sets ${h.variable}=${h.ignored.value}, which isn't an absolute path`);
}
if (status.claude_token) {
const t = status.claude_token;
console.log(`Claude subscription token: ${t.label}, read from ${t.token_file} at each launch (${t.file === "readable" ? "readable now" : "won't apply at the next launch"})`);
if (t.notice) console.log(` ${t.notice}`);
}
if (status.typingGuard) {
const g = status.typingGuard;
const mode = g.effectiveLevel ?? g.effectiveMode ?? (g.effective ? "on" : "off");
+17 -1
View File
@@ -249,7 +249,23 @@ describe("rig provider switch --seat tells the person what follows", () => {
expect(logs.join("\n")).toContain("rig seat restart s1 --keep-conversation --plan");
// A switch still queued behind another on the home hasn't restarted anything, so an eligible plan isn't the end of it.
expect(logs.join("\n")).toContain("queued behind another switch on the same Codex home");
expect(logs.join("\n")).toMatch(/lastRestart that started after \d{4}-\d{2}-\d{2}T/);
expect(logs.join("\n")).toMatch(/lastRestart that finished after \d{4}-\d{2}-\d{2}T/);
expect(logs.join("\n")).toContain("don't run the switch again");
});
it("a Claude seat's switch prints a token that didn't take effect, the launch's warnings, and what to do after an unknown relaunch", async () => {
const warning = "dev-a@rig: This seat's Claude subscription token (work) wasn't applied: the seat's pane shell can't run the token step.";
const notApplied = switchDeps(async () => ({ status: 200, data: { outcome: "rebind_in_progress", message: "Relaunched dev-a@rig in its own pane.",
seat: { account: "work", tokenFile: "/tokens/work", conversation: "conv-1", loginChanged: true, tokenApplied: false, warnings: [warning], undo: null, peers: [] } } }));
const shown = (await run(["switch", "--seat", "dev-a@rig", "--to-account", "work", "--token-file", "/tokens/work"], notApplied)).logs;
expect(shown).toContain(`Claude token: "work", read from /tokens/work (reference saved; the token didn't take effect)`);
expect(shown).toContain(`warning: ${warning}`);
const unknown = switchDeps(async () => ({ status: 200, data: { outcome: "unknown", reasons: ["relaunch_failed"], message: "Its process may have been ended.",
seat: { account: "work", tokenFile: "/tokens/work", loginChanged: true, undo: null, peers: [],
restart: { recoveryCommand: "rig launch rig dev.a --snapshot-id snap-1", remainingStep: "If the pane shows no running Claude, stop the seat first." } } } }));
const after = await run(["switch", "--seat", "dev-a@rig", "--to-account", "work", "--token-file", "/tokens/work"], unknown);
expect(after.exitCode).toBe(1);
expect(after.logs).toContain("recovery: rig launch rig dev.a --snapshot-id snap-1");
expect(after.logs).toContain("If the pane shows no running Claude, stop the seat first.");
});
});
@@ -1019,8 +1019,8 @@ first-run trust settings) into the seat's home.
### Move one Codex seat to another login
For a Codex seat on Linux. The login must already be saved in the Codex home the seat runs on (`rig seat status <seat>`
shows it). Claude seats, other operating systems, and moving a seat to another home aren't supported by this command
yet: say so instead of improvising.
shows it). Other operating systems, and moving a seat to another home, aren't supported by this command yet: say so instead
of improvising. A Claude seat moves differently; see the next section.
1. **Save each login once, in that home:** `CODEX_HOME=<home> codex login` (the person signs in), then
`CODEX_HOME=<home> rig auth save <name>`. `rig auth list --codex-home <home>` lists them.
@@ -1036,6 +1036,29 @@ yet: say so instead of improvising.
`rig seat restart <seat> --keep-conversation --verify --expect-account <its Account line>`. Until then the move is
unconfirmed. An uncertain restart is reported; never restart it a second time.
### Move one Claude seat to its own subscription token
The seat keeps its config dir and gets its own `CLAUDE_CODE_OAUTH_TOKEN`, read from a file as Claude starts. OpenRig keeps only
the file's path and your label, never the token.
1. **Prepare the token once:** the person runs `claude setup-token`, which prints a one-year subscription token, and saves it to a
file only their user can read (`chmod 600`). Never paste the token into a message, a row or a command line.
2. **Check:** `rig provider precheck --seat <seat> --to-account <label> --token-file <absolute path>`. It refuses, changing
nothing, if the file wouldn't apply, the seat isn't running, or something Claude Code ranks above the token is set (a cloud
provider, `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_API_KEY`, an `apiKeyHelper`). It names each one and how to remove it for that
seat. When the token applies, the launch unsets those names for Claude's process, so the pane shell's own exports can't
outrank it. Managed settings can't be read; the seat's `/status` shows what it actually uses.
3. **Move:** `rig provider switch --seat <seat> --to-account <label> --token-file <absolute path>`. It takes a snapshot that
resumes the seat's conversation, sets the reference, and relaunches the seat from that snapshot in its own pane: the pane
and its scrollback stay, but work in flight when Claude ended doesn't carry over. Seats sharing the config dir keep their
own login. `--stored-login` moves the seat back; if the seat still receives a `CLAUDE_CODE_OAUTH_TOKEN`, Claude uses that
instead of the stored login, and the report names it.
4. **Confirm:** after the seat completes a real tool-using turn, its `/status` shows token auth, with no account email. The
label is yours, not an account Claude observed: say so. A token session can't use claude.ai connectors or Remote Control.
The token is in Claude's environment, so the tools, hooks and MCP servers it starts inherit it; don't have it print its
environment. A relaunch that didn't resume reads unknown, with its recovery command; never run the switch again for it.
`rig seat status <seat>` shows the reference, and says when its file won't apply at the next launch.
### Manage subscriptions across a fleet (when the person asks you to)
**This applies only when the person has asked you to manage their subscriptions.** Otherwise, don't take this job on. Installing or upgrading OpenRig appoints no agent, launches no bundle and starts no
@@ -1,5 +1,6 @@
import { withManagedRestoreReads, type RestoreReadPolicy } from "../domain/managed-restore-read-access.js";
import { operationalLaunchArgs } from "./kernel-authority.js";
import { claudeSeatTokenFallbackNotice } from "../domain/claude-seat-token.js";
import nodePath from "node:path";
import fs from "node:fs";
import { createHash, randomUUID } from "node:crypto";
@@ -286,6 +287,24 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
pane: binding.runtimeSwitchFrom.pane, generation: binding.runtimeSwitchFrom.generation } } : {}) }, binding.permissionMode);
} catch (error) { return { ok: false, error: (error as Error).message }; }
}
// A seat's own subscription token: the managed launch carries it; the classic launch gets its step in the staged
// subshell. A launch that can't apply it still starts, and says so.
const seatToken = managed ? null : this.claudeManagedLaunch?.seatTokenLaunch?.(binding.nodeId) ?? null;
const tokenWarnings: string[] = [...(managed?.warnings ?? seatToken?.warnings ?? [])];
const session = binding.tmuxSession;
const sendClassic = async (cmd: string) => {
if (!this.seatLaunchEnvironment) {
if (seatToken) tokenWarnings.push(this.claudeManagedLaunch?.tokenNotApplied?.(binding.nodeId, seatToken.ref, "this launch path can't run the token step") ?? claudeSeatTokenFallbackNotice(seatToken.ref, "this launch path can't run the token step"));
return this.tmux.sendText(session, cmd);
}
const staged = await this.seatLaunchEnvironment.command(session, cmd, { nodeId: binding.nodeId, generation: binding.launchGeneration,
runtime: this.runtime, ...(seatToken ? { claudeTokenStep: seatToken.step } : {}) });
if (seatToken && !staged.includes(seatToken.step)) {
tokenWarnings.push(this.claudeManagedLaunch?.tokenNotApplied?.(binding.nodeId, seatToken.ref, "the seat's pane shell can't run the token step") ?? claudeSeatTokenFallbackNotice(seatToken.ref, "the seat's pane shell can't run the token step"));
}
return this.tmux.sendShellCommand(session, staged, undefined, { sourceInPane: true });
};
const launchNotes = () => (tokenWarnings.length ? { warnings: [...tokenWarnings] } : {});
const posture = claudePostureFlag(process.env, binding.launchPosture, binding.permissionMode);
const appliedLaunch = observeClaudePermission(posture);
const permissionArgs = (nativeSessionId: string) => withManagedRestoreReads(operationalLaunchArgs(this.runtime, binding), {
@@ -337,9 +356,7 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
// the codex fork path) — a hostile id must not leave --resume.
: `${rendererPrefix}claude ${permissionMode}${modelArg}${effortArg}${advisorArg}${additionArg} --resume ${shellQuote(parentId)} --fork-session --session-id ${forkSessionId} --name ${opts.name}`;
const textResult = managed ? await this.tmux.sendShellCommand(binding.tmuxSession, cmd, managed.assertCurrent)
: this.seatLaunchEnvironment
? await this.tmux.sendShellCommand(binding.tmuxSession, await this.seatLaunchEnvironment.command(binding.tmuxSession, cmd, { nodeId: binding.nodeId, generation: binding.launchGeneration, runtime: this.runtime }), undefined, { sourceInPane: true })
: await this.tmux.sendText(binding.tmuxSession, cmd);
: await sendClassic(cmd);
if (!textResult.ok) {
return { ok: false, error: `Failed to send launch command: ${textResult.message}` };
}
@@ -349,7 +366,7 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
}
// As with a fresh launch, nothing is read back from Claude's session files: a same-name
// file can belong to an earlier occupant or another seat sharing the config directory.
return { ok: true, resumeToken: forkSessionId, resumeType: "claude_id", appliedLaunch };
return { ok: true, resumeToken: forkSessionId, resumeType: "claude_id", appliedLaunch, ...launchNotes() };
}
const generatedSessionId = opts.resumeToken ? null : this.sessionIdFactory();
@@ -362,9 +379,7 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
: `${rendererPrefix}claude ${permissionMode}${modelArg}${effortArg}${advisorArg}${additionArg} --session-id ${generatedSessionId} --name ${opts.name}`;
const textResult = managed ? await this.tmux.sendShellCommand(binding.tmuxSession, cmd, managed.assertCurrent)
: this.seatLaunchEnvironment
? await this.tmux.sendShellCommand(binding.tmuxSession, await this.seatLaunchEnvironment.command(binding.tmuxSession, cmd, { nodeId: binding.nodeId, generation: binding.launchGeneration, runtime: this.runtime }), undefined, { sourceInPane: true })
: await this.tmux.sendText(binding.tmuxSession, cmd);
: await sendClassic(cmd);
if (!textResult.ok) {
return { ok: false, error: `Failed to send launch command: ${textResult.message}` };
}
@@ -384,12 +399,12 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
// The process readiness pinned first, if it observed one: the caller records it, not a later read.
const launchedProcess = launch.process ? { launchedProcess: { ...launch.process } } : {};
if (!verification.ok) return { ...verification, ...launchedProcess };
return { ok: true, resumeToken: opts.resumeToken, resumeType: "claude_id", appliedLaunch, ...launchedProcess };
return { ok: true, resumeToken: opts.resumeToken, resumeType: "claude_id", appliedLaunch, ...launchedProcess, ...launchNotes() };
}
// Fresh launches already have an explicit --session-id. A same-name file
// can belong to another seat sharing the Claude config directory.
return { ok: true, resumeToken: generatedSessionId!, resumeType: "claude_id", appliedLaunch };
return { ok: true, resumeToken: generatedSessionId!, resumeType: "claude_id", appliedLaunch, ...launchNotes() };
}
async checkReady(binding: NodeBinding, options?: ReadinessOptions): Promise<ReadinessResult> {
+21 -6
View File
@@ -1,5 +1,6 @@
import { withManagedRestoreReads, type RestoreReadPolicy } from "../domain/managed-restore-read-access.js";
import { operationalLaunchArgs } from "./kernel-authority.js";
import { claudeSeatTokenFallbackNotice } from "../domain/claude-seat-token.js";
import { setTimeout as sleep } from "node:timers/promises";
import type { TmuxAdapter } from "./tmux.js";
import type { SeatLaunchEnvironment } from "../domain/seat-launch-environment.js";
@@ -15,7 +16,9 @@ import { seatLaunchAdditionArgs, type SeatLaunchAdditions } from "../domain/seat
export type ResumeResult =
// `launchedProcess` (Claude): the process this resume's own identity check observed (#1077).
| { ok: true; appliedLaunch?: AppliedLaunchObservation; launchedProcess?: ClaudeLaunchedProcess }
| { ok: true; appliedLaunch?: AppliedLaunchObservation; launchedProcess?: ClaudeLaunchedProcess;
/** Launch notes for the person, such as a seat token that wasn't applied. */
warnings?: string[] }
// Non-terminal: a chooser or an inconclusive observation must preserve the
// launch. Attention is not proof of native identity or successful continuity.
| { ok: false; code: "attention_required"; message: string; evidence?: string }
@@ -94,6 +97,9 @@ export class ClaudeResumeAdapter {
managed = await this.options.claudeManagedLaunch!.prepare({ nodeId: nodeId!, cwd, session: tmuxSessionName }, selectedPermissionMode);
} catch (error) { return { ok: false, code: "permission_selection_refused", message: (error as Error).message }; }
}
// A seat's own subscription token, as on a fresh launch (claude-code-adapter.ts).
const seatToken = managed || !nodeId ? null : this.options.claudeManagedLaunch?.seatTokenLaunch?.(nodeId) ?? null;
const tokenWarnings: string[] = [...(managed?.warnings ?? seatToken?.warnings ?? [])];
const choice = { kernelAuthority, teamPermissionDefault, nonInterruptive, launchPosture: resolvedPosture, permissionMode: selectedPermissionMode };
const posture = claudePostureFlag(process.env, resolvedPosture, selectedPermissionMode);
const appliedLaunch = observeClaudePermission(posture);
@@ -105,10 +111,19 @@ export class ClaudeResumeAdapter {
const cmd = managed ? managed.command(["--permission-mode", selectedPermissionMode!, ...launchArgs, ...(model ? ["--model", model] : []), ...(effort ? ["--effort", effort] : []), ...(advisor ? ["--advisor", advisor] : []), ...additionArgs, "--resume", resumeToken!], claudeResumeLaunchEnv(resumeToken!))
: `${claudeResumeLaunchPrefix(resumeToken!)}${claudeClassicRendererEnvPrefix(process.env)}claude ${permissionMode}${modelArg}${effortArg}${advisorArg}${additionArg} --resume ${shellQuote(resumeToken!)}`;
const textResult = managed ? await this.tmux.sendShellCommand(tmuxSessionName, cmd, managed.assertCurrent)
: this.options.seatLaunchEnvironment
? await this.tmux.sendShellCommand(tmuxSessionName, await this.options.seatLaunchEnvironment.command(tmuxSessionName, cmd, { runtime: "claude-code", nodeId }), undefined, { sourceInPane: true })
: await this.tmux.sendText(tmuxSessionName, cmd);
const sendClassic = async () => {
if (!this.options.seatLaunchEnvironment) {
if (seatToken) tokenWarnings.push(this.options.claudeManagedLaunch?.tokenNotApplied?.(nodeId!, seatToken.ref, "this launch path can't run the token step") ?? claudeSeatTokenFallbackNotice(seatToken.ref, "this launch path can't run the token step"));
return this.tmux.sendText(tmuxSessionName, cmd);
}
const staged = await this.options.seatLaunchEnvironment.command(tmuxSessionName, cmd, { runtime: "claude-code", nodeId,
...(seatToken ? { claudeTokenStep: seatToken.step } : {}) });
if (seatToken && !staged.includes(seatToken.step)) {
tokenWarnings.push(this.options.claudeManagedLaunch?.tokenNotApplied?.(nodeId!, seatToken.ref, "the seat's pane shell can't run the token step") ?? claudeSeatTokenFallbackNotice(seatToken.ref, "the seat's pane shell can't run the token step"));
}
return this.tmux.sendShellCommand(tmuxSessionName, staged, undefined, { sourceInPane: true });
};
const textResult = managed ? await this.tmux.sendShellCommand(tmuxSessionName, cmd, managed.assertCurrent) : await sendClassic();
if (!textResult.ok) {
// sendText failed — nothing in the buffer, no cleanup needed
return { ok: false, code: "resume_failed", message: textResult.message };
@@ -127,7 +142,7 @@ export class ClaudeResumeAdapter {
code: "attention_required",
message: `Claude resume observation unavailable; launch retained: ${error instanceof Error ? error.message : String(error)}`,
}));
return result.ok ? { ...result, appliedLaunch } : result;
return result.ok ? { ...result, appliedLaunch, ...(tokenWarnings.length ? { warnings: tokenWarnings } : {}) } : result;
}
/** #86: as the launch adapter's probe, an unmanaged native install's version label reads as `claude` once its OS
+2
View File
@@ -12,6 +12,7 @@ import { streamRetentionFloorSchema } from "./migrations/131_stream_retention_fl
import { seatRuntimeSwitchesSchema } from "./migrations/132_seat_runtime_switches.js";
import { streamReaderActionsSchema } from "./migrations/133_stream_reader_actions.js";
import { seatIdentityProcessEvidenceSchema } from "./migrations/135_seat_identity_process_evidence.js";
import { nodeClaudeTokenRefSchema } from "./migrations/138_node_claude_token_ref.js";
import { bindingsSessionsSchema } from "./migrations/002_bindings_sessions.js";
import { eventsSchema } from "./migrations/003_events.js";
import { snapshotsSchema } from "./migrations/004_snapshots.js";
@@ -271,4 +272,5 @@ export const ALL_MIGRATIONS: Migration[] = [
claudeTokenReadingsSchema,
seatIdentityProcessEvidenceSchema,
nodeLaunchRecipeSchema,
nodeClaudeTokenRefSchema,
];
@@ -0,0 +1,9 @@
import type { Migration } from "../migrate.js";
// A Claude seat's own subscription credential, as a reference only: { label, tokenFile } JSON. Never the token.
export const nodeClaudeTokenRefSchema: Migration = {
name: "138_node_claude_token_ref.sql",
sql: `
ALTER TABLE nodes ADD COLUMN claude_token_ref TEXT;
`,
};
@@ -0,0 +1,67 @@
import fs from "node:fs";
import path from "node:path";
/** What Claude Code ranks above `CLAUDE_CODE_OAUTH_TOKEN` (https://code.claude.com/docs/en/authentication, "Authentication
* precedence"): a cloud provider, then `ANTHROPIC_AUTH_TOKEN`, then `ANTHROPIC_API_KEY`, then an `apiKeyHelper`. Any of
* them would bill a different source than the seat's own subscription token. */
export const OUTRANKING_TOKEN_ENV = [
"CLAUDE_CODE_USE_BEDROCK", "CLAUDE_CODE_USE_VERTEX", "CLAUDE_CODE_USE_FOUNDRY", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY",
] as const;
export interface CredentialConflict {
name: string;
source: string;
/** What removes it for this seat. OpenRig changes no owner configuration to make a move pass. */
remedy: string;
}
export interface CredentialPrecedence {
conflicts: CredentialConflict[];
/** Asked for a move back to the stored login: a `CLAUDE_CODE_OAUTH_TOKEN` the seat would still receive, which Claude Code
* uses ahead of the stored login. */
instead?: CredentialConflict[];
checked: string[];
/** What the check couldn't read. The restarted seat's /status is the evidence for these. */
notChecked: string[];
}
/** One settings file's `apiKeyHelper` and outranking `env` entries, and with `storedLogin` its `env` block's
* `CLAUDE_CODE_OAUTH_TOKEN`. An absent file has none. */
export function settingsFileConflicts(file: string, scope: string, read: (file: string) => string = (f) => fs.readFileSync(f, "utf8"),
opts: { storedLogin?: boolean } = {}): { conflicts: CredentialConflict[]; instead?: CredentialConflict[]; checked?: string; notChecked?: string } {
let text: string;
try { text = read(file); } catch (err) {
return (err as NodeJS.ErrnoException).code === "ENOENT"
? { conflicts: [], checked: `${scope} settings ${file} (absent)` }
: { conflicts: [], notChecked: `${scope} settings ${file} (unreadable)` };
}
let settings: { apiKeyHelper?: unknown; env?: unknown };
try { settings = JSON.parse(text) as typeof settings; } catch { return { conflicts: [], notChecked: `${scope} settings ${file} (not valid JSON)` }; }
const remedy = `remove it from ${file} (OpenRig doesn't edit settings files)`;
const conflicts: CredentialConflict[] = [];
if (settings?.apiKeyHelper !== undefined) conflicts.push({ name: "apiKeyHelper", source: `${scope} settings ${file}`, remedy });
const env = settings?.env && typeof settings.env === "object" ? settings.env as Record<string, unknown> : {};
for (const name of OUTRANKING_TOKEN_ENV) {
if (env[name] !== undefined) conflicts.push({ name, source: `the env block of ${scope} settings ${file}`, remedy });
}
const instead = opts.storedLogin && env.CLAUDE_CODE_OAUTH_TOKEN !== undefined
? [{ name: "CLAUDE_CODE_OAUTH_TOKEN", source: `the env block of ${scope} settings ${file}`, remedy }] : [];
return { conflicts, ...(instead.length ? { instead } : {}), checked: `${scope} settings ${file}` };
}
/** Where Claude Code reads a project's local settings: the git repository root, or the main checkout's root for a worktree
* (https://code.claude.com/docs/en/settings); null outside a repository. */
export function projectRootForLocalSettings(cwd: string): string | null {
for (let dir = path.resolve(cwd); ; dir = path.dirname(dir)) {
const dotGit = path.join(dir, ".git");
let stat: fs.Stats | null = null;
try { stat = fs.statSync(dotGit); } catch { /* not here */ }
if (stat?.isDirectory()) return dir;
if (stat?.isFile()) {
const gitdir = /^gitdir:\s*(.+)$/m.exec(fs.readFileSync(dotGit, "utf8"))?.[1]?.trim();
const main = gitdir ? path.resolve(dir, gitdir).split(`${path.sep}.git${path.sep}worktrees${path.sep}`)[0] : undefined;
return main && main !== path.resolve(dir, gitdir!) ? main : dir;
}
if (path.dirname(dir) === dir) return null;
}
}
@@ -2,6 +2,7 @@ import { execFile } from "node:child_process";
import { accessSync, closeSync, constants, openSync, readSync, realpathSync, statSync } from "node:fs";
import path from "node:path";
import type Database from "better-sqlite3";
import type { RigEvent } from "./types.js";
import { publicSeatEnvironment } from "./seat-launch-environment.js";
import { SEAT_ENV_KEYS_VAR, parseStoredSeatEnv, validateSeatEnvName } from "./seat-env.js";
import { CLAUDE_PLUGIN_DIRS_ENV, SEAT_NODE_ENV, SEAT_PROJECT_DIR_ENV, seatCorePluginDir } from "./claude-core-plugin-env.js";
@@ -12,6 +13,8 @@ import { parseClaudePermissionModes } from "./permission-drift.js";
import { validateNativePermissionSelection } from "./native-permission-selection.js";
import { seatLaunchAdditionsForLaunch } from "./seat-launch-additions.js";
import { effectiveRuntimeHome } from "./seat-runtime-home.js";
import { claudeSeatTokenFallbackNotice, claudeSeatTokenFileProblem, claudeSeatTokenStep, readClaudeSeatTokenRef, type ClaudeSeatTokenRef } from "./claude-seat-token.js";
import { OUTRANKING_TOKEN_ENV, projectRootForLocalSettings, settingsFileConflicts, type CredentialConflict, type CredentialPrecedence } from "./claude-credential-precedence.js";
export interface ClaudeLaunchTarget {
nodeId: string;
@@ -121,6 +124,74 @@ export class ClaudeManagedLaunch {
return row;
}
/** The launch step that gives the seat's Claude its own subscription token (`rig provider switch --seat`), and the
* launch's warning when the file won't apply (checked without reading it); null for a seat without a reference.
* Launches without an explicit permission mode use it too, through the seat launch environment. */
seatTokenLaunch(nodeId: string, opts: { removeOnFailure?: boolean } = {}): { ref: ClaudeSeatTokenRef; step: string; warnings: readonly string[] } | null {
const ref = readClaudeSeatTokenRef(this.db, nodeId);
if (!ref) return null;
const problem = claudeSeatTokenFileProblem(ref);
return { ref, step: claudeSeatTokenStep(ref, shellQuote, opts), warnings: Object.freeze(problem ? [this.tokenNotApplied(nodeId, ref, problem)] : []) };
}
/** Where a launch that didn't apply the seat's token is recorded (the daemon's event log); unset, it's only reported. */
onTokenNotApplied?: (event: Extract<RigEvent, { type: "seat.claude_token_not_applied" }>) => void;
/** A launch is starting Claude without the seat's own token: record it, and return what the launch reports. */
tokenNotApplied(nodeId: string, ref: ClaudeSeatTokenRef, reason: string): string {
try {
const rig = this.db.prepare("SELECT rig_id FROM nodes WHERE id = ?").get(nodeId) as { rig_id?: string } | undefined;
this.onTokenNotApplied?.({ type: "seat.claude_token_not_applied", rigId: rig?.rig_id ?? "", nodeId, label: ref.label, tokenFile: ref.tokenFile, reason });
} catch { /* recording is best-effort; the launch still starts and reports it */ }
return claudeSeatTokenFallbackNotice(ref, reason);
}
/** What would outrank the seat's own subscription token at its next launch: names its launch environment carries, and
* the settings files it loads. When the token applies, the launch unsets those names for Claude's process, so a pane
* shell's own exports can't outrank it. With `storedLogin`, also a `CLAUDE_CODE_OAUTH_TOKEN` it would receive, which Claude
* uses ahead of the stored login; a pane shell's own exports can't be read then. Managed settings (a file, MDM or the
* server) aren't read; the restarted seat's /status is the evidence for those. */
async credentialPrecedence(nodeId: string, opts: { storedLogin?: boolean } = {}): Promise<CredentialPrecedence> {
const target = this.target(nodeId);
const cwd = target.cwd!;
const conflicts: CredentialConflict[] = [];
const instead: CredentialConflict[] = [];
const names = opts.storedLogin ? [...OUTRANKING_TOKEN_ENV, "CLAUDE_CODE_OAUTH_TOKEN"] : OUTRANKING_TOKEN_ENV;
const found = (name: string) => name === "CLAUDE_CODE_OAUTH_TOKEN" ? instead : conflicts;
for (const name of names) {
if (this.sessionEnv[name]) {
found(name).push({ name, source: "the daemon's environment, which every managed Claude launch receives",
remedy: `unset ${name} where the daemon starts, or take it out of recovery.provider_auth_env_allowlist; either reaches every seat` });
}
}
const declared = await this.seatEnvKeys(nodeId, target.session ?? undefined);
for (const name of names) {
if (declared.includes(name)) {
found(name).push({ name, source: "this seat's env: (member or rig) or a one-off launch name", remedy: `remove ${name} from this seat's env: in its rig spec` });
}
}
const checked = [opts.storedLogin ? "the launch environment"
: "the launch environment (a pane shell's own exports are unset for Claude's process when the token applies)"];
const notChecked = ["managed settings (a managed-settings.json file, MDM or the server)",
...(opts.storedLogin ? ["the pane shell's own exports (its startup files), which a launch without an explicit permission mode keeps"] : [])];
const root = projectRootForLocalSettings(cwd);
const files: Array<[string, string]> = [
[path.join(this.configPaths(cwd, nodeId).configDir, "settings.json"), "user"],
[path.join(cwd, ".claude", "settings.json"), "project"],
[path.join(root ?? cwd, ".claude", "settings.local.json"), "project local"],
// Claude Code also reads a local file an earlier version left in the starting directory.
...(root && root !== path.resolve(cwd) ? [[path.join(cwd, ".claude", "settings.local.json"), "project local"] as [string, string]] : []),
];
for (const [file, scope] of files) {
const settings = settingsFileConflicts(file, scope, undefined, opts);
conflicts.push(...settings.conflicts);
instead.push(...(settings.instead ?? []));
if (settings.checked) checked.push(settings.checked);
if (settings.notChecked) notChecked.push(settings.notChecked);
}
return { conflicts, ...(instead.length ? { instead } : {}), checked, notChecked };
}
/** Bootstrap shares prepare's stored-node cwd when the binding omits it. */
boundCwd(nodeId: string): string {
return this.target(nodeId).cwd!;
@@ -180,6 +251,8 @@ export class ClaudeManagedLaunch {
async prepare(request: ClaudeLaunchTarget, mode: string): Promise<{
assertCurrent: () => void; command: (args: readonly string[], launchEnv?: Readonly<Record<string, string>>) => string; configDir: string; executable: string; executableIsScript: boolean;
/** Launch notes for the person, such as a seat token that won't apply. */
warnings: readonly string[];
}> {
const target = Object.freeze({ ...request, ...(request.runtimeSwitch ? { runtimeSwitch: Object.freeze({ ...request.runtimeSwitch }) } : {}) });
const switching = target.runtimeSwitch;
@@ -246,10 +319,17 @@ export class ClaudeManagedLaunch {
const otherwise = (key: string) => selectedKeys.includes(key) ? "" : ` else unset ${key};`;
const terminal = terminalKeys.map(key => `if [ -n "\${${key}-}" ]; then export ${key};${otherwise(key)} fi`);
terminal.push(`if [ -n "$${WAYLAND_DISPLAY_VAR}" ]; then export WAYLAND_DISPLAY="$${WAYLAND_DISPLAY_VAR}";${otherwise("WAYLAND_DISPLAY")} fi`);
return Object.freeze({ assertCurrent, configDir: context.configDir, executable: context.executable, executableIsScript: context.executableIsScript, command: (args: readonly string[], launchEnv: Readonly<Record<string, string>> = {}) => {
// A seat's own subscription token (rig provider switch --seat) is read from its file as Claude starts, after the
// removals are worked out, so the value never passes through OpenRig. The reduction keeps the name the step sets; when the
// file can't be read, Claude still starts, and a token the reduction would have removed is removed by the step instead.
const tokenKept = selectedKeys.includes("CLAUDE_CODE_OAUTH_TOKEN");
const token = this.seatTokenLaunch(target.nodeId, { removeOnFailure: !tokenKept });
const tokenStep = token ? `${token.step} && ` : "";
const warnings = token?.warnings ?? Object.freeze([]);
return Object.freeze({ assertCurrent, warnings, configDir: context.configDir, executable: context.executable, executableIsScript: context.executableIsScript, command: (args: readonly string[], launchEnv: Readonly<Record<string, string>> = {}) => {
assertCurrent();
const launch = Object.entries(launchEnv).map(([key, value]) => `export ${shellQuote(`${key}=${value}`)}`);
const keep = [...selectedKeys, ...terminalKeys, "WAYLAND_DISPLAY", ...Object.keys(launchEnv)];
const keep = [...selectedKeys, ...terminalKeys, "WAYLAND_DISPLAY", ...Object.keys(launchEnv), ...(token ? ["CLAUDE_CODE_OAUTH_TOKEN"] : [])];
// env -0 keeps multiline values out of the name parser. The existing daemon's Node reads
// that pipe with an empty environment (no pane NODE_OPTIONS); only removal names reach argv.
const removalArgs = `const data=require("node:fs").readFileSync(0,"utf8"); if(!data.endsWith("\\0"))process.exit(1); `
@@ -258,7 +338,7 @@ export class ClaudeManagedLaunch {
const clear = `__openrig_remove=$(/usr/bin/env -0 | /usr/bin/env -i ${shellQuote(process.execPath)} -e ${shellQuote(removalArgs)}) && eval "$__openrig_remove"`;
return `${WAYLAND_DISPLAY_ASSIGNMENT}; cd ${shellQuote(cwd)} && {
${[...assignments, ...forwarded, ...seatForwarded, ...projectForwarded, ...terminal, ...launch].join("\n")}
${clear} && exec /usr/bin/env "$@" ${[shellQuote(context.executable), ...args.map(shellQuote)].join(" ")}
${clear} && ${tokenStep}exec /usr/bin/env "$@" ${[shellQuote(context.executable), ...args.map(shellQuote)].join(" ")}
}`;
} });
}
@@ -0,0 +1,76 @@
import type Database from "better-sqlite3";
import fs from "node:fs";
import path from "node:path";
import { OUTRANKING_TOKEN_ENV } from "./claude-credential-precedence.js";
/** A Claude seat's own subscription credential: an operator label and an absolute token-file path. A reference only: the
* launch script reads the file when Claude starts, so the value never passes through OpenRig. The label is the
* operator's name for it, not an account the provider observed. */
export interface ClaudeSeatTokenRef {
label: string;
tokenFile: string;
}
const LABEL_RE = /^[A-Za-z0-9][A-Za-z0-9._@-]{0,63}$/;
/** Why a label or token-file path can't be a seat's reference, or null when it can. */
export function claudeSeatTokenRefProblem(ref: ClaudeSeatTokenRef): string | null {
if (!LABEL_RE.test(ref.label)) return "the label must start with a letter or digit, then use letters, digits, '.', '_', '@' or '-' (at most 64)";
if (!path.isAbsolute(ref.tokenFile) || /[\0\n\r]/.test(ref.tokenFile)) return "the token file must be an absolute path on one line";
return null;
}
/** The stored reference, or null when it's absent or unreadable. */
export function parseClaudeSeatTokenRef(value: unknown): ClaudeSeatTokenRef | null {
if (typeof value !== "string" || value === "") return null;
try {
const { label, tokenFile } = JSON.parse(value) as { label?: unknown; tokenFile?: unknown };
if (typeof label !== "string" || typeof tokenFile !== "string") return null;
return claudeSeatTokenRefProblem({ label, tokenFile }) === null ? { label, tokenFile } : null;
} catch {
return null;
}
}
/** A seat's reference, read where a launch needs it; null before the column exists. */
export function readClaudeSeatTokenRef(db: Database.Database, nodeId: string): ClaudeSeatTokenRef | null {
const columns = db.prepare("PRAGMA table_info(nodes)").all() as Array<{ name: string }>;
if (!columns.some((column) => column.name === "claude_token_ref")) return null;
const row = db.prepare("SELECT claude_token_ref AS ref FROM nodes WHERE id = ?").get(nodeId) as { ref?: unknown } | undefined;
return parseClaudeSeatTokenRef(row?.ref);
}
/** Why the seat's token file won't give Claude its token at the next launch, or null when it's readable and not empty. It
* checks the file without reading its contents. */
export function claudeSeatTokenFileProblem(ref: ClaudeSeatTokenRef): string | null {
let stat: fs.Stats;
try { stat = fs.statSync(ref.tokenFile); } catch { return `the token file ${ref.tokenFile} doesn't exist`; }
if (!stat.isFile()) return `${ref.tokenFile} isn't a regular file`;
if (stat.size === 0) return `the token file ${ref.tokenFile} is empty`;
try { fs.accessSync(ref.tokenFile, fs.constants.R_OK); } catch { return `the token file ${ref.tokenFile} isn't readable by the daemon's user`; }
return null;
}
/** What a launch that couldn't apply the seat's token says, wherever it's reported. */
export function claudeSeatTokenFallbackNotice(ref: ClaudeSeatTokenRef, problem: string): string {
return `This seat's Claude subscription token (${ref.label}) wasn't applied: ${problem}. Claude started without it, on its config dir's `
+ "stored login (or a token the daemon gives every seat), which may be another account. Fix the file, or choose another with "
+ "rig provider switch --seat.";
}
/** The launch-script step that gives Claude the seat's token, read from its file as Claude starts. With the token, the
* names Claude Code ranks above it are unset for this process only (the pane shell's own exports included), so it's the
* token Claude uses. When the file can't be read or is empty, Claude still starts, on the config dir's stored login, and
* the pane says so; any token the launch already carried is kept unless `removeOnFailure`, and an empty one is never
* exported. */
export function claudeSeatTokenStep(ref: ClaudeSeatTokenRef, quote: (value: string) => string, opts: { removeOnFailure?: boolean } = {}): string {
const notice = claudeSeatTokenFallbackNotice(ref, `${ref.tokenFile} couldn't be read when Claude started`);
return `{ __openrig_token=$(/bin/cat -- ${quote(ref.tokenFile)} 2>/dev/null); if [ -n "$__openrig_token" ]; `
+ `then unset ${OUTRANKING_TOKEN_ENV.join(" ")}; CLAUDE_CODE_OAUTH_TOKEN=$__openrig_token; export CLAUDE_CODE_OAUTH_TOKEN; `
+ `else ${opts.removeOnFailure ? "unset CLAUDE_CODE_OAUTH_TOKEN; " : ""}printf '%s\\n' ${quote(`OpenRig: ${notice}`)} >&2; fi; unset __openrig_token; }`;
}
/** Whether a launch warning is the one a launch that couldn't apply the seat's token gives. */
export function isClaudeSeatTokenFallback(warning: string): boolean {
return warning.includes("This seat's Claude subscription token (") && warning.includes(") wasn't applied: ");
}
+40 -5
View File
@@ -19,6 +19,7 @@ import { withoutRuntimeHomes } from "./seat-runtime-home.js";
import { SEAT_NODE_ENV, SEAT_PROJECT_DIR_ENV, seatCodexCorePluginDir, seatCorePluginDir, withPluginDirs } from "./claude-core-plugin-env.js";
import { seatSkillPluginDirForNode } from "./seat-skill-storage.js";
import { seatLaunchAdditionsForLaunch } from "./seat-launch-additions.js";
import { terminatePaneInPlace, type PaneCutoverTiming } from "./pane-cutover.js";
import type { Session, Binding } from "./types.js";
@@ -46,6 +47,9 @@ interface LaunchOpts {
* seat whose selection includes the vendored openrig-core plugin loads it. Without this the node's saved startup
* context is read. */
pluginPaths?: readonly string[];
/** Launch into this pane of the seat's existing session (named by `sessionName`) instead of a new session: its process
* is ended in place and a shell started there, so the pane and its scrollback stay. */
keepPane?: string;
}
interface NodeLauncherDeps {
@@ -74,6 +78,8 @@ interface NodeLauncherDeps {
* skips option application entirely.
*/
tmuxOptionDefaults?: TmuxOptionDefaultsApplier;
/** How long a kept pane's process gets to exit, per signal (default: polled every 100 ms, 3 s each). */
paneExit?: PaneCutoverTiming;
}
export class NodeLauncher {
@@ -88,6 +94,7 @@ export class NodeLauncher {
private pluginsDir: string | undefined;
private defaultSilenceWindowSeconds: number;
private tmuxOptionDefaults: TmuxOptionDefaultsApplier | null;
private paneExit: PaneCutoverTiming;
constructor(deps: NodeLauncherDeps) {
// Hard runtime invariant: all domain services must share the same db handle.
@@ -113,6 +120,7 @@ export class NodeLauncher {
this.pluginsDir = deps.pluginsDir;
this.defaultSilenceWindowSeconds = deps.defaultSilenceWindowSeconds ?? 3;
this.tmuxOptionDefaults = deps.tmuxOptionDefaults ?? null;
this.paneExit = deps.paneExit ?? { pollMs: 100, timeoutMs: 3_000, sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)) };
}
async launchNode(
@@ -136,7 +144,10 @@ export class NodeLauncher {
return guard.lifecycle([node.id], () => this.launchNode(rigId, logicalId, opts));
}
if (node.binding !== null) {
// A kept pane keeps its binding through the takeover, so the delivery guard can still resolve it.
const keptBinding = opts?.keepPane !== undefined && node.binding?.tmuxPane === opts.keepPane
&& node.binding?.tmuxSession === (opts.sessionName ?? deriveSessionName(rig.rig.name, logicalId));
if (node.binding !== null && !keptBinding) {
return { ok: false, code: "already_bound", message: `Node ${logicalId} is already bound` };
}
@@ -185,7 +196,9 @@ export class NodeLauncher {
OPENRIG_OCCUPANT_GENERATION: occupantGeneration ?? undefined,
});
const openRigEnv = launchEnv(opts?.env);
const tmuxResult = await this.tmuxAdapter.createSession(sessionName, sessionCwd, openRigEnv);
const tmuxResult = opts?.keepPane
? await this.takeOverPane(sessionName, opts.keepPane, sessionCwd, openRigEnv)
: await this.tmuxAdapter.createSession(sessionName, sessionCwd, openRigEnv);
if (!tmuxResult.ok) {
return { ok: false, code: tmuxResult.code, message: tmuxResult.message };
}
@@ -210,14 +223,14 @@ export class NodeLauncher {
// Observe the pane before the DB transaction. A concrete pane is committed
// with session+binding; an unresolved pane gets a durable named verdict in
// that same transaction rather than carrying an unexplained NULL forward.
const paneObservation = await observeSolePane(this.tmuxAdapter, sessionName);
const paneObservation = opts?.keepPane ? { ok: true as const, pane: opts.keepPane } : await observeSolePane(this.tmuxAdapter, sessionName);
// 3a2. OPR.0.4.6.02 S1 — apply the daemon's tmux option defaults to the
// JUST-CREATED session via the shared applier (mouse/status session-scope
// + set-clipboard/copy-command server-scope). Touches only this new
// session, never an existing one (BR-1 never-retro). Option-set failures
// come back as non-fatal launch warnings.
if (this.tmuxOptionDefaults) {
if (this.tmuxOptionDefaults && !opts?.keepPane) {
launchWarnings.push(...(await this.tmuxOptionDefaults.applyToFreshSession(sessionName)));
}
@@ -278,7 +291,9 @@ export class NodeLauncher {
persistedEvent = txn();
} catch (err) {
// DB failed — best-effort tmux cleanup
await this.tmuxAdapter.killSession(sessionName);
// Only a session this launch created is removed. A kept pane is the person's own, with its scrollback, so it's left
// for recovery.
if (!opts?.keepPane) await this.tmuxAdapter.killSession(sessionName);
return {
ok: false,
code: "db_error",
@@ -307,6 +322,26 @@ export class NodeLauncher {
};
}
/** A kept pane's takeover, as a handover's cutover does it: its process ends in place (the pane survives it, with its
* scrollback), then a shell starts in it with this launch's environment, for the runtime to launch into. Nothing is
* touched when the pane isn't in the session. */
private async takeOverPane(sessionName: string, pane: string, cwd: string | undefined, env: Record<string, string>):
Promise<{ ok: true; warnings?: string[] } | { ok: false; code: string; message: string }> {
let panes: Array<{ id: string }> = [];
try { panes = await this.tmuxAdapter.listPanes(sessionName); } catch { /* an unreadable session holds no pane to keep */ }
if (!panes.some((candidate) => candidate.id === pane)) {
return { ok: false, code: "pane_not_found", message: `Pane ${pane} isn't in tmux session "${sessionName}", so nothing was stopped.` };
}
const ended = await terminatePaneInPlace(this.tmuxAdapter, pane, this.paneExit, "The seat's process");
if (!ended.ok) return { ok: false, code: "pane_process_not_ended", message: ended.message };
const shell = (await this.tmuxAdapter.getDefaultShell()) ?? "/bin/sh";
const respawned = await this.tmuxAdapter.respawnPane(pane, shell, { ...(cwd ? { cwd } : {}), env });
if (!respawned.ok) {
return { ok: false, code: "pane_not_respawned", message: `The seat's process in pane ${pane} ended, but a shell couldn't start there: ${respawned.message}` };
}
return { ok: true, ...(respawned.warnings ? { warnings: respawned.warnings } : {}) };
}
/**
* Put each one-off name back to what the session would have had without it: `baseline` has the
* value the spec or OpenRig set for it, and a name with none is unset (the server's own value, if
@@ -0,0 +1,141 @@
import { shellQuote } from "../../adapters/shell-quote.js";
import { claudeSeatTokenFileProblem, claudeSeatTokenRefProblem, isClaudeSeatTokenFallback, type ClaudeSeatTokenRef } from "../claude-seat-token.js";
import type { ClaudeSwitchView, PrecheckReason, PrecheckResult } from "./provider-types.js";
import type { ClaudeCredentialPrecedence, ClaudeSeatRelaunch, ProviderPrecheckInput, ProviderSwitchInput, ProviderSwitchResult, SeatSwitchReport } from "./provider-service.js";
/** The account a Claude seat moves to when it goes back to its config dir's stored login (no token reference). */
export const STORED_LOGIN = "stored-login";
type ClaudeSeat = NonNullable<ReturnType<ClaudeSeatRelaunch["seat"]>>;
type ClaudeCheck =
| { ok: false; verdict: Extract<PrecheckResult, { safe: false }> }
| { ok: true; verdict: Extract<PrecheckResult, { safe: true }>; ref: ClaudeSeatTokenRef | null; instead: string | null };
/** A Claude seat's checks, before anything changes: the reference it would get, that its file would give Claude the token,
* that the seat is running, and that nothing Claude Code ranks above the token is set. A move back to the stored login also
* finds a `CLAUDE_CODE_OAUTH_TOKEN` the seat would still receive, and says it's what the seat will use. Managed settings (a
* file, MDM or the server) can't be read; the relaunched seat's /status covers them. */
export async function checkClaudeSeat(input: ProviderPrecheckInput, seat: ClaudeSeat, precedence: ClaudeCredentialPrecedence | undefined): Promise<ClaudeCheck> {
const refuse = (reason: PrecheckReason, message: string, claude?: ClaudeSwitchView): ClaudeCheck =>
({ ok: false, verdict: { safe: false, reasons: [reason], message, ...(claude ? { claude } : {}) } });
if (!precedence) return refuse("rebind_unsupported_for_runtime", "This daemon can't check what would outrank a Claude seat's own token, so it doesn't move Claude seats.");
if (input.tokenFile === undefined) {
return refuse("token_file_required", "A Claude seat moves to its own subscription token with --to-account <label> --token-file <absolute path to a 0600 "
+ "file holding the token>, or back to its config dir's stored login with --stored-login.");
}
const ref = input.tokenFile === null ? null : { label: input.toAccount, tokenFile: input.tokenFile };
if (ref) {
const problem = claudeSeatTokenRefProblem(ref);
if (problem) return refuse("invalid_token_reference", `${problem}.`);
const fileProblem = claudeSeatTokenFileProblem(ref);
if (fileProblem) return refuse("token_file_unusable", `${fileProblem}, so Claude would start without it. Nothing changed.`);
}
if (!seat.running) {
return refuse("seat_not_running", `${input.seat} isn't running, so there's no conversation to resume. Launch it first: rig launch ${seat.rigName} ${seat.logicalId}.`);
}
const found = await precedence(seat.nodeId, ref ? {} : { storedLogin: true });
const instead = found.instead?.length
? `the CLAUDE_CODE_OAUTH_TOKEN from ${found.instead.map((c) => `${c.source} (${c.remedy})`).join("; ")}` : null;
const claude: ClaudeSwitchView = { label: ref?.label ?? STORED_LOGIN, tokenFile: ref?.tokenFile ?? null, checked: found.checked, notChecked: found.notChecked,
...(instead ? { instead } : {}) };
if (found.conflicts.length) {
return refuse("credential_outranks_token", "Claude Code would use these instead, so nothing changed: "
+ `${found.conflicts.map((c) => `${c.name} (${c.source}; ${c.remedy})`).join("; ")}.`, claude);
}
return { ok: true, verdict: { safe: true, claude }, ref, instead };
}
/** Move one Claude seat in its own pane, by restore's own resume path: under the seat's lifecycle lease, a snapshot taken
* now that resumes the seat, its token reference set, then only that seat relaunched from that snapshot in its pane (its
* process ended in place, the pane and its scrollback kept), which reruns `claude --resume <its conversation>` with the
* seat's token step. Only a refusal before the seat's process was touched reads as unchanged; anything after it that
* doesn't report a resumed conversation reads `unknown`, with the snapshot's recovery, and is never retried. */
export async function switchClaudeSeat(input: ProviderSwitchInput, relaunch: ClaudeSeatRelaunch, seat: ClaudeSeat,
precedence: ClaudeCredentialPrecedence | undefined): Promise<ProviderSwitchResult> {
let touched = false;
try {
return await relaunch.exclusive<ProviderSwitchResult>(seat.nodeId, async () => {
// A switch that waited for the seat goes on with the seat as it is now.
const current = relaunch.seat(input.seat);
if (!current || current.nodeId !== seat.nodeId) {
return { outcome: "failed_safely", reasons: ["seat_changed"], message: `${input.seat} changed while this switch waited for it. Nothing changed.` };
}
return await switchHeld(input, relaunch, current, precedence, () => { touched = true; });
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (!touched) {
const code = (err as { code?: unknown }).code;
return { outcome: "failed_safely", reasons: [typeof code === "string" ? code : "seat_unavailable"],
message: `${input.seat} couldn't be switched (${message}). Nothing changed.` };
}
// Its reference was set before the error; what the seat runs isn't known.
return { outcome: "unknown", reasons: ["relaunch_threw"],
seat: { seat: input.seat, account: input.tokenFile === null ? STORED_LOGIN : input.toAccount, loginChanged: true, peers: [], undo: null },
message: `${input.seat}'s relaunch stopped with an error after its process may have been ended (${message}). Check the pane with rig capture ${input.seat}. Not retried.` };
}
}
async function switchHeld(input: ProviderSwitchInput, relaunch: ClaudeSeatRelaunch, seat: ClaudeSeat,
precedence: ClaudeCredentialPrecedence | undefined, touching: () => void): Promise<ProviderSwitchResult> {
const checked = await checkClaudeSeat(input, seat, precedence);
if (!checked.ok) {
const { reasons, message } = checked.verdict;
return { outcome: "failed_safely", reasons: reasons as [string, ...string[]], ...(message ? { message } : {}) };
}
const { nodeId, rigId, rigName, logicalId } = seat;
if (!seat.pane) {
return { outcome: "failed_safely", reasons: ["no_bound_pane"], message: `${input.seat} has no bound tmux pane to relaunch it in. Nothing changed.` };
}
const snapshot = relaunch.snapshot(rigId, nodeId);
if (!snapshot.resumes) {
return { outcome: "failed_safely", reasons: ["conversation_not_resumable"],
message: `Snapshot ${snapshot.snapshotId} ${snapshot.why}, so relaunching ${input.seat} wouldn't resume its conversation. Nothing changed.` };
}
const before = relaunch.tokenRef(nodeId);
const account = checked.ref?.label ?? STORED_LOGIN;
const report: SeatSwitchReport = { seat: input.seat, account, ...(checked.ref ? { tokenFile: checked.ref.tokenFile } : {}),
conversation: snapshot.conversation, loginChanged: false, peers: [],
// The label's characters are limited; a path can hold any but a newline, so it's quoted.
undo: before
? `rig provider switch --seat ${input.seat} --to-account ${before.label} --token-file ${shellQuote(before.tokenFile)}`
: `rig provider switch --seat ${input.seat} --stored-login` };
touching();
relaunch.setTokenRef(nodeId, checked.ref);
let relaunched: Awaited<ReturnType<ClaudeSeatRelaunch["relaunchInPane"]>>;
try { relaunched = await relaunch.relaunchInPane(rigId, logicalId, snapshot.snapshotId); }
catch (err) { relaunched = { ok: false, effect: "unknown", code: "relaunch_threw", message: err instanceof Error ? err.message : String(err) }; }
if (!relaunched.ok && relaunched.effect === "none") {
relaunch.setTokenRef(nodeId, before);
return { outcome: "failed_safely", reasons: [`relaunch_${relaunched.code.replace(/-/g, "_")}`], seat: report,
message: `${input.seat} wasn't relaunched (${relaunched.code}: ${relaunched.message}) and wasn't touched, so it keeps running as it was, with its token reference as it was.` };
}
report.loginChanged = true;
if (!relaunched.ok) {
const recoveryCommand = `rig launch ${rigName} ${logicalId} --snapshot-id ${snapshot.snapshotId}`;
report.restart = { outcome: relaunched.code, message: relaunched.message, recoveryCommand,
remainingStep: `If the pane (rig capture ${input.seat}) shows no running Claude, stop the seat first (rig seat stop ${input.seat} --reason "<why>"), `
+ `then run the recovery command; it resumes the conversation from snapshot ${snapshot.snapshotId} with the new reference. The way back works once the seat runs.` };
return { outcome: "unknown", reasons: [relaunched.code === "relaunch_threw" ? "relaunch_threw" : `relaunch_${relaunched.code.replace(/-/g, "_")}`], seat: report,
message: `${input.seat}'s relaunch in its pane from snapshot ${snapshot.snapshotId} didn't report a resumed conversation (${relaunched.code}: ${relaunched.message}). `
+ "Its process may have been ended. Not retried." };
}
const fallback = relaunched.warnings.filter(isClaudeSeatTokenFallback);
report.warnings = relaunched.warnings;
report.restart = { outcome: "resumed", message: `Resumed conversation ${snapshot.conversation ?? "(not recorded in the snapshot)"} in the seat's own pane.` };
if (checked.ref) {
report.tokenApplied = fallback.length === 0;
report.next = report.tokenApplied
? `After the seat completes a turn with a tool call, its /status shows the auth method it uses: the token (CLAUDE_CODE_OAUTH_TOKEN), with no account email. "${account}" is your label for it; Claude doesn't name the account.`
: `Fix the token file, then relaunch the seat (or switch it again); until then its /status shows the login it uses instead.`;
return { outcome: "rebind_in_progress", seat: report, message: report.tokenApplied
? `Relaunched ${input.seat} in its own pane on its own token ("${account}"), resuming its conversation. Work in flight when it stopped didn't carry over.`
: `Relaunched ${input.seat} in its own pane, resuming its conversation, and saved its token reference ("${account}"), but the token didn't take effect: ${fallback.join(" ")}` };
}
report.next = checked.instead
? `After the seat completes a turn with a tool call, its /status shows the token it uses (CLAUDE_CODE_OAUTH_TOKEN), not the stored login.`
: `After the seat completes a turn with a tool call, its /status shows the auth method it uses: its config dir's stored login, unless its pane shell exports a token.`;
return { outcome: "rebind_in_progress", seat: report, message: checked.instead
? `Relaunched ${input.seat} in its own pane without its own token, resuming its conversation. It uses ${checked.instead}, which Claude Code ranks above its config dir's stored login.`
: `Relaunched ${input.seat} in its own pane on its config dir's stored login, resuming its conversation. Work in flight when it stopped didn't carry over.` };
}
@@ -29,7 +29,10 @@ import type {
ProviderSwitchResult,
SeatRestart,
SeatSwitchReport,
ClaudeCredentialPrecedence,
ClaudeSeatRelaunch,
} from "./provider-service.js";
import { checkClaudeSeat, switchClaudeSeat } from "./claude-seat-switch.js";
import type { FourBlockReadModel, PrecheckResult, ProviderSignal } from "./provider-types.js";
import { authList, authSave, authSwitch, resolveCodexHome } from "../codex-auth.js";
import type { PeerReport } from "../seat-restart-in-place.js";
@@ -103,6 +106,8 @@ export interface ProviderServiceImplDeps {
usageWindows?: { enabled: () => boolean; windows: CodexUsageWindows; claude?: ClaudeUsageWindows; claudeTokens?: import("./claude-token-usage.js").ClaudeTokenUsage };
/** The logins saved in a Codex home; defaults to `rig auth`'s files. A seat switch acts on the home the seat runs on. */
codexLogins?: CodexHomeLogins;
/** What would outrank a Claude seat's own token; without it, Claude seats aren't moved. */
claudePrecedence?: ClaudeCredentialPrecedence;
}
export class ProviderServiceImpl implements ProviderService {
@@ -201,7 +206,9 @@ export class ProviderServiceImpl implements ProviderService {
return seats;
}
async precheck(input: ProviderPrecheckInput, seatRestart?: SeatRestart): Promise<PrecheckResult> {
async precheck(input: ProviderPrecheckInput, seatRestart?: SeatRestart, claudeSeat?: ClaudeSeatRelaunch): Promise<PrecheckResult> {
const claude = claudeSeat?.seat(input.seat);
if (claude) return (await checkClaudeSeat(input, claude, this.deps.claudePrecedence)).verdict;
if (seatRestart) return (await this.seatPrecheck(input, seatRestart)).verdict;
const model = await this.getReadModel();
const target = model.accounts.find((a) => a.accountId === input.toAccount);
@@ -216,7 +223,9 @@ export class ProviderServiceImpl implements ProviderService {
});
}
async switchAccount(input: ProviderSwitchInput, seatRestart?: SeatRestart): Promise<ProviderSwitchResult> {
async switchAccount(input: ProviderSwitchInput, seatRestart?: SeatRestart, claudeSeat?: ClaudeSeatRelaunch): Promise<ProviderSwitchResult> {
const claude = claudeSeat?.seat(input.seat);
if (claudeSeat && claude) return switchClaudeSeat(input, claudeSeat, claude, this.deps.claudePrecedence);
if (seatRestart) return this.switchSeat(input, seatRestart);
// Precheck-gate: never switch past an unsafe verdict unless explicitly forced.
const verdict = await this.precheck({ seat: input.seat, toAccount: input.toAccount });
@@ -7,16 +7,22 @@ import type { FourBlockReadModel, PrecheckResult } from "./provider-types.js";
import type { UsageRefreshResult } from "./usage-windows.js";
import type { RestartInPlaceResult } from "../seat-lifecycle-service.js";
import type { PeerReport } from "../seat-restart-in-place.js";
import type { ClaudeSeatTokenRef } from "../claude-seat-token.js";
import type { CredentialPrecedence } from "../claude-credential-precedence.js";
export interface ProviderPrecheckInput {
seat: string;
toAccount: string;
/** A Claude seat's own subscription token file (absolute); null moves it back to its config dir's stored login. */
tokenFile?: string | null;
}
export interface ProviderSwitchInput {
seat: string;
toAccount: string;
forceUnsafe: boolean;
/** As on the precheck. */
tokenFile?: string | null;
}
/**
@@ -39,18 +45,50 @@ export interface SeatRestart {
run(seat: string, reason: string): Promise<RestartInPlaceResult>;
}
/** What a seat switch did in the Codex home the seat runs on. */
/** A Claude seat's relaunch on the same conversation, in its own pane, by restore's own resume path, lent by the route for
* one call. */
export interface ClaudeSeatRelaunch {
/** The seat, when it's a managed Claude seat; null for any other runtime. `pane` is its bound tmux pane. */
seat(seat: string): { nodeId: string; rigId: string; rigName: string; logicalId: string; running: boolean; pane: string | null } | null;
tokenRef(nodeId: string): ClaudeSeatTokenRef | null;
setTokenRef(nodeId: string, ref: ClaudeSeatTokenRef | null): void;
/** A snapshot taken now, and whether restoring this seat from it resumes its conversation. */
snapshot(rigId: string, nodeId: string): { snapshotId: string; resumes: true; conversation: string | null } | { snapshotId: string; resumes: false; why: string };
/** Runs `fn` holding the seat's lifecycle lease (the one its stop, launch and restore take), so no other one runs on the
* seat in between. A wait for it ends with the seat as it is then. */
exclusive<T>(nodeId: string, fn: () => Promise<T>): Promise<T>;
/** Relaunch only this seat from that snapshot in its own pane: its process ends in place, the pane and its scrollback stay,
* and restore resumes the conversation there. `effect: "none"` when it was refused before anything stopped. */
relaunchInPane(rigId: string, logicalId: string, snapshotId: string): Promise<{ ok: true; status: "resumed"; warnings: string[] }
| { ok: false; effect: "none" | "unknown"; code: string; message: string }>;
}
/** What would outrank a Claude seat's own token at its next launch (`ClaudeManagedLaunch.credentialPrecedence`). */
export type ClaudeCredentialPrecedence = (nodeId: string, opts?: { storedLogin?: boolean }) => Promise<CredentialPrecedence>;
/** What a seat switch did: for a Codex seat, in the Codex home it runs on; for a Claude seat, to its token reference. */
export interface SeatSwitchReport {
seat: string;
home: string;
/** Codex: the home whose active login changed. */
home?: string;
/** Codex: the login's saved name. Claude: the token's label, or `stored-login`. */
account: string;
/** Whether the home's active login is now `account`. */
/** Claude: the token file the launch reads (a path; never its contents). */
tokenFile?: string;
/** Claude: the conversation the relaunch resumes. */
conversation?: string | null;
/** Whether the seat's login source is now `account`. */
loginChanged: boolean;
/** The outgoing login, saved in the same home first under a name of its own; null when the home had no active login. */
savedBefore: string | null;
/** Codex: the outgoing login, saved in the same home first under a name of its own; null when the home had no active login. */
savedBefore?: string | null;
/** The command that switches the home back. */
undo: string | null;
restart?: { outcome: string; message: string; recoveryCommand?: string; remainingStep?: string };
/** Claude: what the relaunch reported, such as a token it couldn't apply. */
warnings?: string[];
/** Claude: false when the relaunch reported starting without the seat's token. True means no such fallback was reported;
* the seat's /status after a tool turn is the evidence. */
tokenApplied?: boolean;
/** The other Codex seats. One on the same home shares its login: it keeps its current one only until its next token
* refresh, then stops until it's restarted, so `step` is that restart. */
peers: Array<PeerReport & { step?: string }>;
@@ -61,10 +99,10 @@ export interface ProviderService {
/** The ONE four-block read model; the filtered block routes are projections of this. */
getReadModel(): Promise<FourBlockReadModel>;
/** Resolve the target/seat state at call time (validate-at-use) and return the safety verdict. */
precheck(input: ProviderPrecheckInput, seatRestart?: SeatRestart): Promise<PrecheckResult>;
precheck(input: ProviderPrecheckInput, seatRestart?: SeatRestart, claudeSeat?: ClaudeSeatRelaunch): Promise<PrecheckResult>;
/** Precheck-gated switch; returns an explicit business outcome (never a transport error). With the seat's restart it
* moves that seat to a login saved in the Codex home it runs on. */
switchAccount(input: ProviderSwitchInput, seatRestart?: SeatRestart): Promise<ProviderSwitchResult>;
switchAccount(input: ProviderSwitchInput, seatRestart?: SeatRestart, claudeSeat?: ClaudeSeatRelaunch): Promise<ProviderSwitchResult>;
/** Slice 01: one bounded native read of the usage windows (coalesced); `disabled` while the switch is off. */
refreshUsage?(provider: "codex" | "claude", token?: import("./claude-token-usage.js").ClaudeTokenSelection): Promise<UsageRefreshResult | { disabled: true } | { unavailable: true }>;
}
@@ -129,7 +129,12 @@ export type PrecheckReason =
| "signal_unknown_or_stale"
| "rebind_unsupported_for_runtime"
| "seat_restart_refused" // the seat's in-place restart plan refused, so nothing would change
| "target_not_in_seat_home"; // no login by that name is saved in the Codex home the seat runs on
| "target_not_in_seat_home" // no login by that name is saved in the Codex home the seat runs on
| "token_file_required" // a Claude seat moves to a token file (--token-file), or back to its stored login (--stored-login)
| "invalid_token_reference" // the label or the token file's path can't be a seat's reference
| "token_file_unusable" // the token file wouldn't give Claude a token: missing, empty, not a file or unreadable
| "credential_outranks_token" // a cloud provider, API key, auth token or apiKeyHelper would win over the seat's token
| "seat_not_running"; // nothing to relaunch: launch the seat first
/** A seat on the same Codex home, as the in-place restart reports it (its `PeerReport`). Declared here rather than
* imported, so the provider types don't import the restart module and its tmux dependencies. */
@@ -147,9 +152,19 @@ export interface SeatSwitchView {
peers: SeatPeer[];
}
/** What a Claude seat's precheck saw: the reference it would get, and what the precedence check read and couldn't read. */
export interface ClaudeSwitchView {
label: string;
tokenFile: string | null;
checked: string[];
notChecked: string[];
/** A move back to the stored login: the token the seat would still use instead, and where it comes from. */
instead?: string;
}
export type PrecheckResult =
| { safe: true; seat?: SeatSwitchView }
| { safe: false; reasons: PrecheckReason[]; message?: string; seat?: SeatSwitchView };
| { safe: true; seat?: SeatSwitchView; claude?: ClaudeSwitchView }
| { safe: false; reasons: PrecheckReason[]; message?: string; seat?: SeatSwitchView; claude?: ClaudeSwitchView };
export type SwitchOutcome = "succeeded" | "rebind_in_progress" | "failed_safely" | "unknown";
@@ -142,7 +142,7 @@ function seatFacts(data: SnapshotData, nodeId: string, savedRuntime: string | nu
/** What `rig launch` does for this node from this snapshot without --fresh, read as the restore path reads it: the
* occupant, then the pre-launch stop-and-ask check, then the resume decision (restoreNodeWithCompensation and the
* resume step after it). An exited agent's message offers stop-then-launch as a resume only when this says so. */
function snapshotLaunchOutcome(data: SnapshotData, nodeId: string, runtime: string | null, savedRuntime: string | null):
export function snapshotLaunchOutcome(data: SnapshotData, nodeId: string, runtime: string | null, savedRuntime: string | null):
{ kind: "resume" } | { kind: "fresh" | "decision" | "unrecoverable"; why: string } {
const occupant = resolveActiveSnapshotSession(data, nodeId);
if (occupant.kind === "ambiguous") return { kind: "unrecoverable", why: "can't tell which saved session is this seat's" };
@@ -565,10 +565,13 @@ export class RestoreOrchestrator {
};
}
/** `keepPane`: the seat is running and is relaunched in its own pane (its process ended in place, the pane and scrollback
* kept) instead of a new session; nothing checks whether it is live first, since that is the point. */
async launchSingleNode(rigId: string, logicalId: string, opts?: {
adapters?: Record<string, import("./runtime-adapter.js").RuntimeAdapter>;
fsOps?: { exists(path: string): boolean };
snapshotId?: string;
keepPane?: boolean;
}): Promise<NarrowLaunchResult> {
return this.launchNodeTargets(rigId, [logicalId], { ...opts, nonTargetMode: "unchanged" });
}
@@ -578,6 +581,7 @@ export class RestoreOrchestrator {
fsOps?: { exists(path: string): boolean };
holdReason?: string;
snapshotId?: string;
keepPane?: boolean;
nonTargetMode: "unchanged" | "detach_and_hold";
}): Promise<NarrowLaunchResult> {
const rig = this.rigRepo.getRig(rigId);
@@ -614,6 +618,11 @@ export class RestoreOrchestrator {
const subsetWarnings: string[] = [];
for (const node of targetNodes) {
if (opts.keepPane) {
launched.push(await this.restoreNodeWithCompensation({ node }, rigId, snapshot.id, snapshot.data,
{ adapters: opts.adapters, fsOps: opts.fsOps, keepPane: true }, subsetWarnings));
continue;
}
// An external CLI's claim has no tmux session to probe; restoreNodeWithCompensation leaves it attached.
const external = this.sessionRegistry.getBindingForNode(node.id)?.attachmentType === "external_cli";
const sessions = external ? [] : this.sessionRegistry.getSessionsForRig(rigId)
@@ -853,8 +862,8 @@ export class RestoreOrchestrator {
return null;
}
private clearStaleState(nodeId: string, rigId: string): void {
this.sessionRegistry.clearBinding(nodeId);
private clearStaleState(nodeId: string, rigId: string, opts: { keepBinding?: boolean } = {}): void {
if (!opts.keepBinding) this.sessionRegistry.clearBinding(nodeId);
const sessions = this.sessionRegistry.getSessionsForRig(rigId);
for (const sess of sessions) {
if (sess.nodeId === nodeId && sess.status !== "superseded" && sess.status !== "exited") {
@@ -980,7 +989,7 @@ export class RestoreOrchestrator {
rigId: string,
snapshotId: string,
data: SnapshotData,
opts?: { adapters?: Record<string, import("./runtime-adapter.js").RuntimeAdapter>; fsOps?: { exists(path: string): boolean }; freshLogicalIds?: string[]; onSessionLaunched?: (nodeId: string, sessionId: string) => void; automaticReplacements?: AutomaticSeatReplacement[] },
opts?: { adapters?: Record<string, import("./runtime-adapter.js").RuntimeAdapter>; fsOps?: { exists(path: string): boolean }; freshLogicalIds?: string[]; onSessionLaunched?: (nodeId: string, sessionId: string) => void; automaticReplacements?: AutomaticSeatReplacement[]; keepPane?: boolean },
warnings?: string[],
preserveStoppedCurrent = false,
): Promise<RestoreNodeResult> {
@@ -1100,16 +1109,22 @@ export class RestoreOrchestrator {
}
// Capture prior state for compensation, including an exited replacement predecessor.
const priorState = this.captureNodeState(nodeId, rigId);
const keptPane = opts?.keepPane ? priorState.binding?.tmuxPane ?? null : null;
if (opts?.keepPane && (!keptPane || !priorState.binding?.tmuxSession)) {
return { nodeId, logicalId: node.logicalId, status: "failed", error: `${node.logicalId} has no bound tmux pane to keep, so nothing was stopped.` };
}
// Clear stale state so NodeLauncher doesn't see already_bound
this.clearStaleState(nodeId, rigId);
// Clear stale state so NodeLauncher doesn't see already_bound. A kept pane keeps its binding through the takeover: the
// delivery guard resolves the pane through it.
this.clearStaleState(nodeId, rigId, { keepBinding: keptPane !== null });
this.tmuxAdapter.deliveryGuard?.rebindLifecycle(nodeId);
// Derive canonical session name for pod-aware nodes
const rig = this.rigRepo.getRig(rigId);
let launchOpts: { sessionName?: string; cwd?: string } | undefined = node.cwd
let launchOpts: { sessionName?: string; cwd?: string; keepPane?: string } | undefined = node.cwd
? { cwd: node.cwd }
: undefined;
if (keptPane) launchOpts = { ...launchOpts, keepPane: keptPane };
let expectedSessionName: string | undefined;
// OPR.0.4.3.20 FR-5 — pin the resume target to the DURABLY-BOUND session.
@@ -2055,6 +2070,7 @@ export class RestoreOrchestrator {
if (notice) warnings?.push(`${sessionName}: ${notice}`);
const planNote = planModelAliasNotice("claude-code", { model, permissionMode }, sessionName);
if (planNote) warnings?.push(`${sessionName}: ${planNote}`);
for (const note of result.warnings ?? []) warnings?.push(`${sessionName}: ${note}`);
if (result.appliedLaunch && launchGeneration) this.appliedLaunchStore.recordGeneration(launchGeneration, result.appliedLaunch);
return { kind: "resumed", ...(result.launchedProcess ? { launchedProcess: result.launchedProcess } : {}) };
}
@@ -7,6 +7,7 @@ import { deriveComposeProjectName } from "./compose-project-name.js";
import type { ClaudeManagedBlockFile } from "./managed-blocks.js";
import { mergeSeatEnv, parseStoredSeatEnv, type SeatEnv } from "./seat-env.js";
import { withoutIgnoredRuntimeHomes } from "./seat-runtime-home.js";
import { parseClaudeSeatTokenRef, type ClaudeSeatTokenRef } from "./claude-seat-token.js";
import type {
Rig,
Node,
@@ -776,6 +777,13 @@ export class RigRepository {
return result.changes > 0;
}
/** A Claude seat's own subscription credential, by reference only; null clears it. */
setNodeClaudeTokenRef(nodeId: string, ref: ClaudeSeatTokenRef | null): boolean {
if (!this.hasNodeColumn("claude_token_ref")) return false;
return this.db.prepare("UPDATE nodes SET claude_token_ref = ? WHERE id = ?")
.run(ref ? JSON.stringify({ label: ref.label, tokenFile: ref.tokenFile }) : null, nodeId).changes > 0;
}
/** Persist the resolved Advisor; null removes a previous spec's selection. */
setNodeAdvisor(nodeId: string, advisor: string | null): boolean {
if (!this.hasNodeColumn("advisor")) return false;
@@ -1033,6 +1041,7 @@ export class RigRepository {
advisor: row.advisor ?? null,
effortChoice: row.effort_choice ?? null,
codexConfigProfile: row.codex_config_profile ?? null,
claudeTokenRef: parseClaudeSeatTokenRef(row.claude_token_ref),
permissionPolicy: row.permission_policy ?? null,
env: parseStoredSeatEnv(row.env_json),
cwd: row.cwd,
@@ -1125,6 +1134,7 @@ interface NodeRow {
advisor?: string | null;
effort_choice?: string | null;
codex_config_profile?: string | null;
claude_token_ref?: string | null;
permission_policy?: string | null;
env_json?: string | null;
cwd: string | null;
@@ -102,7 +102,10 @@ export class SeatLaunchEnvironment {
async command(session: string, command: string, target: { codexCwd?: string; nodeId?: string; generation?: string; runtime?: string;
/** The seat's own CODEX_HOME when it declares one; it wins over the daemon's. */
codexHome?: string } = {}): Promise<string> {
codexHome?: string;
/** A Claude seat's own token step (claude-seat-token.ts), run before Claude in the staged subshell. A POSIX pane only:
* a caller whose command comes back without it knows the token wasn't applied. */
claudeTokenStep?: string } = {}): Promise<string> {
const codexHome = target.codexHome ?? this.codexHome;
const searchPath = this.sessionEnv.PATH;
const codexEnv = target.codexCwd !== undefined
@@ -146,9 +149,11 @@ export class SeatLaunchEnvironment {
if (target.runtime === "claude-code") {
const assignments = Object.entries(env).map(([key, value]) => `${key}=${shellQuote(value)}`);
const launch = `${assignments.join(" ")} PATH=${shellQuote(binDir)}:"$PATH" ${command}`;
// The token step is POSIX shell; another shell (fish, tcsh) gets the command without it.
const token = target.claudeTokenStep && ["bash", "zsh", "sh", "dash", "ksh", "mksh"].includes(shell) ? [target.claudeTokenStep] : [];
// ksh calls a function without exporting a leading assignment that isn't already
// exported, so it keeps the form above, where every assignment reaches Claude.
if (!["bash", "zsh", "sh", "dash"].includes(shell)) return launch;
if (!["bash", "zsh", "sh", "dash"].includes(shell)) return [...token, launch].join("\n");
// A file command must replace the staging subshell so tmux observes
// Claude as the foreground process. Keep aliases/functions intact and
// pass the original leading assignments through a subshell-local function.
@@ -159,6 +164,7 @@ export class SeatLaunchEnvironment {
" */*) eval 'claude() { exec \"$_openrig_claude_executable\" \"$@\"; }' ;;",
' esac',
'fi',
...token,
launch,
].join("\n");
}
@@ -9,6 +9,7 @@ import { NativePermissionStore, type StoredNativePermissionSelection, type Resol
import { AppliedLaunchObservationStore, type StoredAppliedLaunchObservation } from "./applied-launch-observation-store.js";
import { planPermissionAccount, type PermissionAccount } from "./permission-account.js";
import { RUNTIME_HOME_VAR, seatRuntimeHome, type HomeRuntime } from "./seat-runtime-home.js";
import { claudeSeatTokenFallbackNotice, claudeSeatTokenFileProblem, readClaudeSeatTokenRef } from "./claude-seat-token.js";
const SEAT_LOOKUP_GUIDANCE = "List seats with: rig ps --nodes";
@@ -41,6 +42,9 @@ export interface SeatStatus {
source: "member" | "rig" | "daemon" | "native_default";
ignored?: { source: "member" | "rig"; value: string };
};
/** A Claude seat's own subscription token: its reference (never the value), and whether the file would give Claude the
* token at the next launch. When it wouldn't, Claude starts on the config dir's stored login, and `notice` says so. */
claude_token?: { label: string; token_file: string; file: "readable" | "unusable"; problem?: string; notice?: string };
terminalPolicy: ReturnType<TerminalPolicyStore["account"]> | { state: "unknown"; error: string };
permissions: {
selectionState: "explicit" | "inherit" | "unknown";
@@ -148,6 +152,7 @@ export class SeatStatusService {
handover_at: entry.handoverAt,
restore_outcome: entry.restoreOutcome,
...this.runtimeHomeStatus(entry.nodeId, entry.runtime),
...this.claudeTokenStatus(entry.nodeId, entry.runtime),
permissions: this.permissionStatus(entry.nodeId, entry.runtime, entry.canonicalSessionName ?? deriveCanonicalFromEntry(entry) ?? seatRef),
terminalPolicy: this.terminalPolicyStatus(entry.nodeId, entry.runtime),
};
@@ -175,6 +180,15 @@ export class SeatStatusService {
return { runtime_home: { variable, home: native, source: "native_default", ...ignored } };
}
private claudeTokenStatus(nodeId: string, runtime: string | null): Pick<SeatStatus, "claude_token"> {
if (runtime !== "claude-code") return {};
const ref = readClaudeSeatTokenRef(this.rigRepo.db, nodeId);
if (!ref) return {};
const problem = claudeSeatTokenFileProblem(ref);
return { claude_token: { label: ref.label, token_file: ref.tokenFile, file: problem ? "unusable" : "readable",
...(problem ? { problem, notice: claudeSeatTokenFallbackNotice(ref, problem) } : {}) } };
}
private terminalPolicyStatus(nodeId: string, runtime: string | null): SeatStatus["terminalPolicy"] {
try { return new TerminalPolicyStore(this.rigRepo.db).account(nodeId, runtime); }
catch (error) { return { state: "unknown", error: (error as Error).message }; }
+5
View File
@@ -39,6 +39,8 @@ export interface Node {
/** OPR.0.7.0.7: the seat's explicit effort choice (rig seat set-effort); null = none. */
effortChoice?: string | null;
codexConfigProfile?: string | null;
/** A Claude seat's own subscription credential, as a reference (operator label and token-file path); null = none. */
claudeTokenRef?: { label: string; tokenFile: string } | null;
/** OPR.0.4.8.3 Seam B: attached permission_policy REF (builtin:<name> or spec-relative custom
* path), or null when none is attached (= the floor). */
permissionPolicy?: string | null;
@@ -193,6 +195,9 @@ export type RigEvent =
// secret-boundary control — resume tokens are not treated as secret per the
// 2026-07-02 founder ruling).
| { type: "session.resume_token_captured"; rigId: string; nodeId: string; sessionName: string; sessionId: string; runtime: string; outcome: "captured" | "preserved" | "skipped"; resumeType?: string; provenance?: "adoption"; reason?: "missing_sidecar" | "parse_error" | "probe_timeout" | "process_unreadable" | "invalid_token" | "stale_sidecar" | "higher_rank_present"; redacted: true }
// A Claude launch that didn't give the seat its own subscription token: Claude started on its config dir's stored
// login. The reference (label and file path) only; the token value never enters OpenRig.
| { type: "seat.claude_token_not_applied"; rigId: string; nodeId: string; label: string; tokenFile: string; reason: string }
| { type: "seat.attention_cleared"; rigId: string; nodeId: string; sessionName: string; from: string; to: "ready"; clearedBy: "evidence" | "operator_attestation"; evidence?: { kind: string; state?: string; reason?: string }; reason?: string; previousError: string | null }
| { type: "seat.attention_clear_refused"; rigId: string; nodeId: string; sessionId: string; sessionName: string; attentionClass: "restore_outcome"; code: string; attemptId?: number; probe?: import("./native-resume-probe-evidence.js").NativeResumeProbeEvidence }
| { type: "rig.imported"; rigId: string; specName: string; specVersion: string }
+63 -3
View File
@@ -1,8 +1,16 @@
import fs from "node:fs";
import path from "node:path";
import { Hono, type Context } from "hono";
import { getSelfHostId, LOCAL_HOST_ID } from "../domain/hosts/fanout-contract.js";
import { loadHostRegistry } from "../domain/hosts/hosts-registry-reader.js";
import type { ProviderService, SeatRestart } from "../domain/provider/provider-service.js";
import type { ClaudeSeatRelaunch, ProviderService, SeatRestart } from "../domain/provider/provider-service.js";
import type { RigRepository } from "../domain/rig-repository.js";
import type { SnapshotCapture } from "../domain/snapshot-capture.js";
import type { RestoreOrchestrator } from "../domain/restore-orchestrator.js";
import type { TmuxAdapter } from "../adapters/tmux.js";
import { snapshotLaunchOutcome } from "../domain/restore-orchestrator.js";
import { resolveActiveSnapshotSession } from "../domain/active-occupant.js";
import { readClaudeSeatTokenRef } from "../domain/claude-seat-token.js";
import type { ProviderKind } from "../domain/provider/provider-types.js";
import { checkMoveReport, credentialRefusal, findCredential, MOVE_REPORT_TAG, readMoveReport, renderMoveReport, type MoveReport } from "../domain/provider/subscription-move-report.js";
import { QueueRepositoryError, type QueueRepository } from "../domain/queue-repository.js";
@@ -41,6 +49,51 @@ function seatRestartOf(c: Context): SeatRestart {
};
}
/** A Claude seat's relaunch for this request, by restore's own resume path in the seat's own pane: a snapshot, then only
* that seat launched from it, its process ended in place and the pane kept, all under the seat's lifecycle lease. */
function claudeSeatOf(c: Context): ClaudeSeatRelaunch {
const rigRepo = c.get("rigRepo" as never) as RigRepository;
return {
seat: (seat) => {
if (!rigRepo) return null;
const row = rigRepo.db.prepare(`SELECT n.id, n.rig_id, n.logical_id, n.runtime, r.name, s.status, b.tmux_pane FROM sessions s
JOIN nodes n ON n.id = s.node_id JOIN rigs r ON r.id = n.rig_id LEFT JOIN bindings b ON b.node_id = n.id
WHERE s.session_name = ? ORDER BY s.created_at DESC, s.id DESC LIMIT 1`)
.get(seat) as { id: string; rig_id: string; logical_id: string; runtime: string | null; name: string; status: string; tmux_pane: string | null } | undefined;
return row?.runtime === "claude-code"
? { nodeId: row.id, rigId: row.rig_id, rigName: row.name, logicalId: row.logical_id, running: row.status === "running", pane: row.tmux_pane ?? null } : null;
},
tokenRef: (nodeId) => readClaudeSeatTokenRef(rigRepo.db, nodeId),
setTokenRef: (nodeId, ref) => { rigRepo.setNodeClaudeTokenRef(nodeId, ref); },
snapshot: (rigId, nodeId) => {
const capture = c.get("snapshotCapture" as never) as SnapshotCapture | undefined;
if (!capture) return { snapshotId: "(none)", resumes: false, why: "can't be taken: this daemon has no snapshot capture" };
const snapshot = capture.captureSnapshot(rigId, "provider-switch");
const outcome = snapshotLaunchOutcome(snapshot.data, nodeId, "claude-code", "claude-code");
if (outcome.kind !== "resume") return { snapshotId: snapshot.id, resumes: false, why: outcome.why };
const occupant = resolveActiveSnapshotSession(snapshot.data, nodeId);
return { snapshotId: snapshot.id, resumes: true, conversation: occupant.kind === "resolved" ? occupant.session.resumeToken ?? null : null };
},
exclusive: (nodeId, fn) => {
const guard = (c.get("tmuxAdapter" as never) as TmuxAdapter | undefined)?.deliveryGuard;
return guard ? guard.lifecycle([nodeId], fn) : fn();
},
relaunchInPane: async (rigId, logicalId, snapshotId) => {
const restore = c.get("restoreOrchestrator" as never) as RestoreOrchestrator | undefined;
if (!restore) return { ok: false, effect: "none", code: "restore_unavailable", message: "This daemon has no restore orchestrator." };
const adapters = (c.get("runtimeAdapters" as never) as Record<string, import("../domain/runtime-adapter.js").RuntimeAdapter> | undefined) ?? {};
const result = await restore.launchSingleNode(rigId, logicalId, { snapshotId, adapters, fsOps: { exists: (p: string) => fs.existsSync(p) }, keepPane: true });
// A refusal of the whole launch (no usable snapshot, no such seat) comes before anything is touched.
if (!result.ok) return { ok: false, effect: "none", code: result.code ?? "launch_failed", message: result.message ?? "The seat wasn't launched from the snapshot." };
const node = result.launched?.find((n) => n.logicalId === logicalId);
if (node?.status === "resumed") return { ok: true, status: "resumed", warnings: result.warnings ?? [] };
// Restore stops for a decision, or leaves an attached CLI alone, before it touches the seat.
const untouched = node?.status === "awaiting-decision" || node?.status === "left_attached";
return { ok: false, effect: untouched ? "none" : "unknown", code: node?.status ?? "not_launched", message: node?.error ?? "The seat wasn't launched from the snapshot." };
},
};
}
export function providerRoutes(opts: ProviderRoutesOptions = {}): Hono {
const router = new Hono();
// Refresh legs across hosts live as long as these routes: overlapping requests join a running leg, and the last
@@ -258,8 +311,10 @@ export function providerRoutes(opts: ProviderRoutesOptions = {}): Hono {
if (!seat || !seat.trim() || !toAccount || !toAccount.trim()) {
return c.json({ error: "seat and toAccount are required" }, 400);
}
// A Claude seat's own token file, or `storedLogin=1` for its config dir's stored login.
const tokenFile = c.req.query("storedLogin") === "1" ? null : c.req.query("tokenFile");
// An unsafe verdict is a valid 200 response, not an error.
return c.json(await svc.precheck({ seat, toAccount }, seatRestartOf(c)), 200);
return c.json(await svc.precheck({ seat, toAccount, ...(tokenFile !== undefined ? { tokenFile } : {}) }, seatRestartOf(c), claudeSeatOf(c)), 200);
});
router.post("/switch", async (c) => {
@@ -285,8 +340,13 @@ export function providerRoutes(opts: ProviderRoutesOptions = {}): Hono {
if (typeof forceUnsafe !== "boolean") {
return c.json({ error: "forceUnsafe must be a boolean" }, 400);
}
const tokenFile = body["tokenFile"];
if (tokenFile !== undefined && tokenFile !== null && typeof tokenFile !== "string") {
return c.json({ error: "tokenFile must be a path, or null for the stored login" }, 400);
}
// Business outcome (incl. failed_safely / rebind_in_progress) is an explicit 200 payload.
return c.json(await svc.switchAccount({ seat, toAccount, forceUnsafe }, seatRestartOf(c)), 200);
return c.json(await svc.switchAccount({ seat, toAccount, forceUnsafe, ...(tokenFile !== undefined ? { tokenFile } : {}) },
seatRestartOf(c), claudeSeatOf(c)), 200);
});
// Slice 18 — subscription move reports: queue items tagged subscription-move, filed by the agent that moved a Codex
+3
View File
@@ -698,6 +698,7 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
const claudeManagedLaunch = new ClaudeManagedLaunch(db, { ...launchSessionEnv, CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR }, {
OPENRIG_CLAUDE_DISABLE_ALTERNATE_SCREEN: process.env.OPENRIG_CLAUDE_DISABLE_ALTERNATE_SCREEN,
}, (session, key) => tmuxAdapter.getSessionEnv(session, key));
claudeManagedLaunch.onTokenNotApplied = (event) => { eventBus.emit(event); };
// The compaction settings a managed restore reads, at each launch, so the launch grants those files too (#1940).
const restoreReadPolicy = () => {
try {
@@ -1414,6 +1415,8 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
legacyProviderUsageDirectory(OPENRIG_HOME),
),
agentActivityStore,
// A Claude seat switch checks what would outrank the seat's own token before it stops anything.
claudePrecedence: (nodeId: string, opts?: { storedLogin?: boolean }) => claudeManagedLaunch.credentialPrecedence(nodeId, opts),
// Slice 01: Codex and Claude usage windows, off unless provider.usage_windows is on (read per call). The native
// readers (Codex's app-server; Claude's auth status, for identity) run only on an explicit refresh, with the
// PATH, HOME, CODEX_HOME and CLAUDE_CONFIG_DIR a seat session gets. Claude's windows are the seats' status-line
@@ -0,0 +1,88 @@
import { afterEach, describe, expect, it } from "vitest";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import Database from "better-sqlite3";
import { ClaudeManagedLaunch } from "../src/domain/claude-managed-launch.js";
// Before a Claude seat moves to its own subscription token, OpenRig finds what Claude Code would rank above that token
// (a cloud provider, ANTHROPIC_AUTH_TOKEN, ANTHROPIC_API_KEY, an apiKeyHelper): in the seat's launch environment and in the
// settings files it loads. It names each one and its remedy, edits nothing, and says what it couldn't read.
const cleanup: Array<() => void> = [];
afterEach(() => { for (const f of cleanup.splice(0)) f(); });
function fixture(opts: { daemonEnv?: Record<string, string>; memberEnv?: Record<string, string>; worktree?: boolean } = {}) {
const root = mkdtempSync(path.join(tmpdir(), "claude-precedence-"));
cleanup.push(() => rmSync(root, { recursive: true, force: true }));
const home = path.join(root, "home"), main = path.join(root, "main"), cwd = opts.worktree ? path.join(root, "wt") : main;
for (const dir of [home, main, cwd, path.join(home, ".claude"), path.join(cwd, ".claude"), path.join(main, ".claude"), path.join(main, ".git", "worktrees", "wt")]) {
mkdirSync(dir, { recursive: true });
}
if (opts.worktree) writeFileSync(path.join(cwd, ".git"), `gitdir: ${path.join(main, ".git", "worktrees", "wt")}\n`);
const db = new Database(":memory:"); cleanup.push(() => { db.close(); });
db.exec(`CREATE TABLE rigs(id TEXT, env_json TEXT);
CREATE TABLE nodes(id TEXT, rig_id TEXT, runtime TEXT, cwd TEXT, env_json TEXT);
CREATE TABLE bindings(id TEXT, node_id TEXT, tmux_session TEXT, tmux_pane TEXT);
CREATE TABLE occupant_tenures(node_id TEXT, generation_uuid TEXT, generation_ordinal INTEGER);`);
db.prepare("INSERT INTO rigs VALUES ('rig', NULL)").run();
db.prepare("INSERT INTO nodes VALUES ('node', 'rig', 'claude-code', ?, ?)").run(cwd, opts.memberEnv ? JSON.stringify(opts.memberEnv) : null);
db.exec("INSERT INTO bindings VALUES ('b', 'node', 'seat@rig', '%1'); INSERT INTO occupant_tenures VALUES ('node', 'gen', 1)");
const managed = new ClaudeManagedLaunch(db, { PATH: "/usr/bin:/bin", HOME: home, ...opts.daemonEnv }, {}, async () => undefined);
return { managed, home, main, cwd };
}
describe("what would outrank a Claude seat's own subscription token", () => {
it("names each one in the launch environment and the settings files the seat loads, with its remedy", async () => {
const f = fixture({ daemonEnv: { ANTHROPIC_AUTH_TOKEN: "x" }, memberEnv: { ANTHROPIC_API_KEY: "y" }, worktree: true });
writeFileSync(path.join(f.home, ".claude", "settings.json"), JSON.stringify({ apiKeyHelper: "/usr/local/bin/key" }));
writeFileSync(path.join(f.cwd, ".claude", "settings.json"), JSON.stringify({ env: { CLAUDE_CODE_USE_BEDROCK: "1" } }));
// In a worktree, Claude Code reads the local settings at the main checkout's root.
writeFileSync(path.join(f.main, ".claude", "settings.local.json"), JSON.stringify({ env: { ANTHROPIC_API_KEY: "z" } }));
const found = await f.managed.credentialPrecedence("node");
expect(found.conflicts.map((c) => [c.name, c.source])).toEqual([
["ANTHROPIC_AUTH_TOKEN", "the daemon's environment, which every managed Claude launch receives"],
["ANTHROPIC_API_KEY", "this seat's env: (member or rig) or a one-off launch name"],
["apiKeyHelper", `user settings ${path.join(f.home, ".claude", "settings.json")}`],
["CLAUDE_CODE_USE_BEDROCK", `the env block of project settings ${path.join(f.cwd, ".claude", "settings.json")}`],
["ANTHROPIC_API_KEY", `the env block of project local settings ${path.join(f.main, ".claude", "settings.local.json")}`],
]);
for (const conflict of found.conflicts) expect(conflict.remedy.length).toBeGreaterThan(0);
// Values are never reported, only names and where they come from.
expect(JSON.stringify(found)).not.toMatch(/"(x|y|z)"/);
});
it("finds nothing on a seat with none, and says what it couldn't read", async () => {
const f = fixture();
writeFileSync(path.join(f.cwd, ".claude", "settings.json"), JSON.stringify({ env: { FOO: "1" }, model: "opus" }));
const found = await f.managed.credentialPrecedence("node");
expect(found.conflicts).toEqual([]);
// A pane shell's own exports can't be read, but the launch unsets those names for Claude when the token applies.
expect(found.checked).toContain("the launch environment (a pane shell's own exports are unset for Claude's process when the token applies)");
expect(found.notChecked).toEqual(["managed settings (a managed-settings.json file, MDM or the server)"]);
});
it("a move back to the stored login also finds a CLAUDE_CODE_OAUTH_TOKEN the seat would still receive, as what it uses instead", async () => {
const f = fixture({ daemonEnv: { CLAUDE_CODE_OAUTH_TOKEN: "x" }, memberEnv: { CLAUDE_CODE_OAUTH_TOKEN: "y" } });
writeFileSync(path.join(f.cwd, ".claude", "settings.json"), JSON.stringify({ env: { CLAUDE_CODE_OAUTH_TOKEN: "z" } }));
const back = await f.managed.credentialPrecedence("node", { storedLogin: true });
expect(back.conflicts).toEqual([]);
expect(back.instead?.map((c) => [c.name, c.source])).toEqual([
["CLAUDE_CODE_OAUTH_TOKEN", "the daemon's environment, which every managed Claude launch receives"],
["CLAUDE_CODE_OAUTH_TOKEN", "this seat's env: (member or rig) or a one-off launch name"],
["CLAUDE_CODE_OAUTH_TOKEN", `the env block of project settings ${path.join(f.cwd, ".claude", "settings.json")}`],
]);
// The pane shell's own exports, which a launch without a permission mode keeps, can't be read.
expect(back.notChecked).toContain("the pane shell's own exports (its startup files), which a launch without an explicit permission mode keeps");
expect(JSON.stringify(back)).not.toMatch(/"(x|y|z)"/);
// Moving to a token, the same token isn't something that outranks it.
expect((await f.managed.credentialPrecedence("node")).instead).toBeUndefined();
});
it("a settings file that isn't valid JSON is named as not checked, never as clear", async () => {
const f = fixture();
writeFileSync(path.join(f.home, ".claude", "settings.json"), "{ not json");
const found = await f.managed.credentialPrecedence("node");
expect(found.notChecked).toContain(`user settings ${path.join(f.home, ".claude", "settings.json")} (not valid JSON)`);
});
});
@@ -0,0 +1,145 @@
import { afterEach, describe, expect, it } from "vitest";
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import Database from "better-sqlite3";
import { ClaudeManagedLaunch } from "../src/domain/claude-managed-launch.js";
import { claudeSeatTokenStep } from "../src/domain/claude-seat-token.js";
import { shellQuote } from "../src/adapters/shell-quote.js";
import { SeatLaunchEnvironment } from "../src/domain/seat-launch-environment.js";
import type { TmuxAdapter } from "../src/adapters/tmux.js";
import { RigRepository } from "../src/domain/rig-repository.js";
import { createFullTestDb } from "./helpers/test-app.js";
// A Claude seat can be given its own subscription credential: an operator label and a token file. The managed launch
// reads the file when Claude starts, so the value never passes through OpenRig (its records, the launch command, argv or
// logs), and Claude doesn't start on another login when the file can't be read.
const exec = promisify(execFile);
const cleanup: Array<() => void> = [];
afterEach(() => { for (const f of cleanup.splice(0)) f(); });
const SYNTHETIC = "synthetic-fixture-value-not-a-credential";
function fixture(ref?: { label: string; tokenFile: string }, daemonEnv: Record<string, string> = {}) {
const root = mkdtempSync(path.join(tmpdir(), "claude-seat-token-"));
cleanup.push(() => rmSync(root, { recursive: true, force: true }));
const bin = path.join(root, "bin"), home = path.join(root, "home"), cwd = path.join(root, "work");
for (const dir of [bin, home, cwd]) mkdirSync(dir);
const claude = path.join(bin, "claude");
writeFileSync(claude, `#!/bin/sh
if [ "$1" = --help ]; then printf '%s\\n' '--permission-mode <mode> (choices: "auto", "default")'; exit 0; fi
printf '{"TOKEN":"%s"}' "\${CLAUDE_CODE_OAUTH_TOKEN-absent}"
`);
chmodSync(claude, 0o700);
const db = new Database(":memory:"); cleanup.push(() => { db.close(); });
db.exec(`CREATE TABLE rigs(id TEXT, env_json TEXT);
CREATE TABLE nodes(id TEXT, rig_id TEXT, runtime TEXT, cwd TEXT, env_json TEXT, claude_token_ref TEXT);
CREATE TABLE bindings(id TEXT, node_id TEXT, tmux_session TEXT, tmux_pane TEXT);
CREATE TABLE occupant_tenures(node_id TEXT, generation_uuid TEXT, generation_ordinal INTEGER);`);
db.prepare("INSERT INTO rigs VALUES ('rig', NULL)").run();
db.prepare("INSERT INTO nodes VALUES ('node', 'rig', 'claude-code', ?, NULL, ?)").run(cwd, ref ? JSON.stringify(ref) : null);
db.exec("INSERT INTO bindings VALUES ('b', 'node', 'seat@rig', '%1'); INSERT INTO occupant_tenures VALUES ('node', 'gen', 1)");
const managed = new ClaudeManagedLaunch(db, { PATH: `${bin}:/usr/bin:/bin`, HOME: home, ...daemonEnv }, {}, async () => undefined);
const events: unknown[] = [];
managed.onTokenNotApplied = (event) => { events.push(event); };
const prepare = () => managed.prepare({ nodeId: "node", session: "seat@rig", pane: "%1" }, "auto");
const command = async () => (await prepare()).command(["--permission-mode", "auto"]);
// `pane` is what the pane shell itself exports.
const run = (cmd: string, pane: Record<string, string> = {}) => exec("/bin/sh", ["-c", cmd], { env: { PATH: "/usr/bin:/bin", ...pane }, timeout: 5000 });
return { root, command, prepare, run, events };
}
describe.skipIf(process.platform === "win32")("a Claude seat's own subscription token file", () => {
it("reaches only that seat's Claude process; the value never enters the launch command", async () => {
const root = mkdtempSync(path.join(tmpdir(), "claude-seat-token-file-")); cleanup.push(() => rmSync(root, { recursive: true, force: true }));
const tokenFile = path.join(root, "claude-work.token");
writeFileSync(tokenFile, `${SYNTHETIC}\n`, { mode: 0o600 });
const f = fixture({ label: "work", tokenFile });
const command = await f.command();
expect(command).not.toContain(SYNTHETIC);
expect(command).toContain(tokenFile);
expect(JSON.parse((await f.run(command)).stdout)).toEqual({ TOKEN: SYNTHETIC });
expect(f.events).toEqual([]);
});
it("starts Claude on its stored login when the token file can't be read, and the pane says so", async () => {
const tokenFile = path.join(tmpdir(), "no-such-dir-claude-seat-token", "claude-work.token");
const f = fixture({ label: "work", tokenFile });
const { stdout, stderr } = await f.run(await f.command());
expect(JSON.parse(stdout)).toEqual({ TOKEN: "absent" });
expect(stderr).toContain("subscription token (work) wasn't applied");
// The launch result says so too, checked without reading the file.
expect((await f.prepare()).warnings).toEqual([expect.stringContaining("subscription token (work) wasn't applied")]);
// And each launch records it: the seat ran without its own token, and why.
expect(f.events).toEqual(Array(2).fill({ type: "seat.claude_token_not_applied", rigId: "rig", nodeId: "node", label: "work", tokenFile,
reason: `the token file ${tokenFile} doesn't exist` }));
});
it.each([["bash", true], ["zsh", true], ["fish", false]] as const)(
"a launch without an explicit permission mode runs the token step before Claude in a %s pane: %s",
async (shell, included) => {
const root = mkdtempSync(path.join(tmpdir(), "claude-seat-token-classic-")); cleanup.push(() => rmSync(root, { recursive: true, force: true }));
const cli = path.join(root, "rig");
writeFileSync(cli, "#!/bin/sh\n", { mode: 0o700 });
const identity: Record<string, string> = { OPENRIG_NODE_ID: "node", OPENRIG_SESSION_NAME: "seat@rig" };
const tmux = { getPaneCommand: async () => shell, getSessionEnv: async (_session: string, key: string) => identity[key] } as unknown as TmuxAdapter;
const launch = new SeatLaunchEnvironment(tmux, { PATH: "/usr/bin:/bin", HOME: root, OPENRIG_HOME: path.join(root, "instance") }, root, cli);
const step = "{ __openrig_token_step_fixture; }";
const command = await launch.command("seat@rig", "claude --resume abc", { runtime: "claude-code", nodeId: "node", claudeTokenStep: step });
expect(command.includes(step)).toBe(included);
if (included) expect(command.indexOf(step)).toBeLessThan(command.indexOf("claude --resume abc"));
});
it("wins over a CLAUDE_CODE_OAUTH_TOKEN the pane shell already exports, and over one the daemon forwards", async () => {
const root = mkdtempSync(path.join(tmpdir(), "claude-seat-token-file-")); cleanup.push(() => rmSync(root, { recursive: true, force: true }));
const tokenFile = path.join(root, "claude-work.token");
writeFileSync(tokenFile, SYNTHETIC, { mode: 0o600 });
const pane = fixture({ label: "work", tokenFile });
expect(JSON.parse((await pane.run(await pane.command(), { CLAUDE_CODE_OAUTH_TOKEN: "older-fixture-value" })).stdout)).toEqual({ TOKEN: SYNTHETIC });
const forwarded = fixture({ label: "work", tokenFile }, { CLAUDE_CODE_OAUTH_TOKEN: "older-fixture-value" });
expect(JSON.parse((await forwarded.run(await forwarded.command(), { CLAUDE_CODE_OAUTH_TOKEN: "older-fixture-value" })).stdout)).toEqual({ TOKEN: SYNTHETIC });
});
it("when the file can't be read, a token only the pane shell exports is still removed, and one the daemon forwards is kept", async () => {
const tokenFile = path.join(tmpdir(), "no-such-dir-claude-seat-token", "claude-work.token");
const pane = fixture({ label: "work", tokenFile });
expect(JSON.parse((await pane.run(await pane.command(), { CLAUDE_CODE_OAUTH_TOKEN: "older-fixture-value" })).stdout)).toEqual({ TOKEN: "absent" });
const forwarded = fixture({ label: "work", tokenFile }, { CLAUDE_CODE_OAUTH_TOKEN: "older-fixture-value" });
expect(JSON.parse((await forwarded.run(await forwarded.command(), { CLAUDE_CODE_OAUTH_TOKEN: "older-fixture-value" })).stdout)).toEqual({ TOKEN: "older-fixture-value" });
});
it("with its token, Claude's process doesn't get the names Claude Code ranks above it, even ones the pane shell exports", async () => {
const root = mkdtempSync(path.join(tmpdir(), "claude-seat-token-file-")); cleanup.push(() => rmSync(root, { recursive: true, force: true }));
const tokenFile = path.join(root, "claude-work.token");
writeFileSync(tokenFile, SYNTHETIC, { mode: 0o600 });
const report = `printf '%s|%s|%s|%s' "\${CLAUDE_CODE_OAUTH_TOKEN-absent}" "\${ANTHROPIC_API_KEY-absent}" "\${ANTHROPIC_AUTH_TOKEN-absent}" "\${CLAUDE_CODE_USE_BEDROCK-absent}"`;
const pane = { PATH: "/usr/bin:/bin", ANTHROPIC_API_KEY: "fixture-key", ANTHROPIC_AUTH_TOKEN: "fixture-auth", CLAUDE_CODE_USE_BEDROCK: "1" };
const withToken = await exec("/bin/sh", ["-c", `${claudeSeatTokenStep({ label: "work", tokenFile }, shellQuote)} && ${report}`], { env: pane, timeout: 5000 });
expect(withToken.stdout).toBe(`${SYNTHETIC}|absent|absent|absent`);
// Without it, nothing is unset: the launch is as it would have been.
const missing = path.join(root, "missing.token");
const without = await exec("/bin/sh", ["-c", `${claudeSeatTokenStep({ label: "work", tokenFile: missing }, shellQuote)} && ${report}`], { env: pane, timeout: 5000 });
expect(without.stdout).toBe("absent|fixture-key|fixture-auth|1");
});
it("a seat without one launches exactly as before", async () => {
const f = fixture();
const command = await f.command();
expect(command).not.toContain("CLAUDE_CODE_OAUTH_TOKEN");
expect(JSON.parse((await f.run(command)).stdout)).toEqual({ TOKEN: "absent" });
});
it("is kept on the seat as a reference only, and can be cleared", () => {
const db = createFullTestDb(); cleanup.push(() => db.close());
const repo = new RigRepository(db);
const rig = repo.createRig("token-rig");
const node = repo.addNode(rig.id, "owner", { runtime: "claude-code" });
const read = () => repo.getRig(rig.id)?.nodes.find((n) => n.id === node.id)?.claudeTokenRef ?? null;
expect(repo.setNodeClaudeTokenRef(node.id, { label: "work", tokenFile: "/secrets/claude-work.token" })).toBe(true);
expect(read()).toEqual({ label: "work", tokenFile: "/secrets/claude-work.token" });
expect(repo.setNodeClaudeTokenRef(node.id, null)).toBe(true);
expect(read()).toBeNull();
});
});
+2 -1
View File
@@ -3,6 +3,7 @@ import { terminalPolicySchema } from "../../src/db/migrations/128_terminal_polic
import { seatRuntimeSwitchesSchema } from "../../src/db/migrations/132_seat_runtime_switches.js";
import { seatIdentityPanePidSchema } from "../../src/db/migrations/119_seat_identity_pane_pid.js";
import { seatIdentityProcessEvidenceSchema } from "../../src/db/migrations/135_seat_identity_process_evidence.js";
import { nodeClaudeTokenRefSchema } from "../../src/db/migrations/138_node_claude_token_ref.js";
import { rigStartupLayersSchema } from "../../src/db/migrations/121_rig_startup_layers.js";
import { humanOperatingModesSchema } from "../../src/db/migrations/122_human_operating_modes.js";
import { queueDelegatedDecisionSchema } from "../../src/db/migrations/123_queue_delegated_decision.js";
@@ -146,7 +147,7 @@ import fs from "node:fs";
/** Seam B R6: the canonical full-fixture migration list, exported so file-backed
* DB-reopen tests migrate IDENTICALLY to createFullTestDb. */
export const migrationsForFullTestDb = [coreSchema, bindingsSessionsSchema, eventsSchema, snapshotsSchema, checkpointsSchema, resumeMetadataSchema, nodeSpecFieldsSchema, packagesSchema, installJournalSchema, journalSeqSchema, bootstrapSchema, discoverySchema, discoveryFkFix, agentspecRebootSchema, startupContextSchema, chatMessagesSchema, podNamespaceSchema, contextUsageSchema, externalCliAttachmentSchema, rigServicesSchema, seatHandoverObservabilitySchema, nodeCodexConfigProfileSchema, nodePermissionPolicySchema, rigPermissionPolicySchema, nodePolicyProvenanceSchema, rigPolicyProvenanceSchema, streamItemsSchema, queueItemsSchema, queueTransitionsSchema, rigPolicySchema, rigArchiveSchema, resumeProvenanceSchema, resumeVerificationSchema, seatIdentityVerdictsSchema, selfHostIdentitySchema, occupantTenuresSchema, daemonLifecycleSchema, watchdogJobsSchema, occupantGenerationStampsSchema, projectionManifestSchema, watchdogTargetGenerationSchema, appliedLaunchObservationsSchema, appliedLaunchObservationInvalidationsSchema, threadSeatMapSchema, queueTransitionWakesSchema, nodeSessionSourceSchema, scopedOperatingPostureSchema, humanNotificationIntentSchema, reviewReadIndexesSchema, inventoryEventIndexesSchema, rigClaudeManagedBlockFileSchema, nodePermissionSelectionsSchema, humanReplyToSchema, humanQuestionsSchema, nodeEffortSchema, rigNonInterruptiveSchema, rigInstallRootSchema, threadPartMapSchema, queueTransitionsQitemIdOrderSchema, queueItemsRecoveryTagsSchema, resumeRotationSchema, sessionStopIntentSchema, nodeEffortChoiceSchema, modePostureCoexistSchema, seatEnvSchema, appliedLaunchDaemonOptOutSchema, sessionCommunityHarnessSchema, threadSeatMapChannelKeySchema, queueItemWakeInputBoxSchema, providerAccountReadingsSchema, humanQuietWindowsSchema, seatIdentityPanePidSchema, seatIdentityProcessEvidenceSchema, rigStartupLayersSchema, humanOperatingModesSchema, queueDelegatedDecisionSchema, nodeAdvisorSchema, operationCorrelationSchema, terminalPolicySchema, seatRuntimeSwitchesSchema, claudeTokenReadingsSchema, nodeLaunchRecipeSchema];
export const migrationsForFullTestDb = [coreSchema, bindingsSessionsSchema, eventsSchema, snapshotsSchema, checkpointsSchema, resumeMetadataSchema, nodeSpecFieldsSchema, packagesSchema, installJournalSchema, journalSeqSchema, bootstrapSchema, discoverySchema, discoveryFkFix, agentspecRebootSchema, startupContextSchema, chatMessagesSchema, podNamespaceSchema, contextUsageSchema, externalCliAttachmentSchema, rigServicesSchema, seatHandoverObservabilitySchema, nodeCodexConfigProfileSchema, nodePermissionPolicySchema, rigPermissionPolicySchema, nodePolicyProvenanceSchema, rigPolicyProvenanceSchema, streamItemsSchema, queueItemsSchema, queueTransitionsSchema, rigPolicySchema, rigArchiveSchema, resumeProvenanceSchema, resumeVerificationSchema, seatIdentityVerdictsSchema, selfHostIdentitySchema, occupantTenuresSchema, daemonLifecycleSchema, watchdogJobsSchema, occupantGenerationStampsSchema, projectionManifestSchema, watchdogTargetGenerationSchema, appliedLaunchObservationsSchema, appliedLaunchObservationInvalidationsSchema, threadSeatMapSchema, queueTransitionWakesSchema, nodeSessionSourceSchema, scopedOperatingPostureSchema, humanNotificationIntentSchema, reviewReadIndexesSchema, inventoryEventIndexesSchema, rigClaudeManagedBlockFileSchema, nodePermissionSelectionsSchema, humanReplyToSchema, humanQuestionsSchema, nodeEffortSchema, rigNonInterruptiveSchema, rigInstallRootSchema, threadPartMapSchema, queueTransitionsQitemIdOrderSchema, queueItemsRecoveryTagsSchema, resumeRotationSchema, sessionStopIntentSchema, nodeEffortChoiceSchema, modePostureCoexistSchema, seatEnvSchema, appliedLaunchDaemonOptOutSchema, sessionCommunityHarnessSchema, threadSeatMapChannelKeySchema, queueItemWakeInputBoxSchema, providerAccountReadingsSchema, humanQuietWindowsSchema, seatIdentityPanePidSchema, seatIdentityProcessEvidenceSchema, rigStartupLayersSchema, humanOperatingModesSchema, queueDelegatedDecisionSchema, nodeAdvisorSchema, operationCorrelationSchema, terminalPolicySchema, seatRuntimeSwitchesSchema, claudeTokenReadingsSchema, nodeLaunchRecipeSchema, nodeClaudeTokenRefSchema];
/**
* P24 — the DECLARED exclusions for {@link migrationsForFullTestDb}. That list is deliberately a
@@ -16,6 +16,8 @@ import { RestoreOrchestrator } from "../src/domain/restore-orchestrator.js";
import { ClaudeResumeAdapter } from "../src/adapters/claude-resume.js";
import { CodexResumeAdapter } from "../src/adapters/codex-resume.js";
import { createFullTestDb } from "./helpers/test-app.js";
import { TmuxAdapter } from "../src/adapters/tmux.js";
import { SeatDeliveryGuard, resolveGuardTarget } from "../src/domain/seat-delivery-guard.js";
import type { NativeProcessRow } from "../src/domain/native-process-lineage.js";
function makeTmux(overrides?: Partial<Record<string, (...args: unknown[]) => unknown>>) {
@@ -672,4 +674,118 @@ describe("RestoreOrchestrator.launchNodeSubset", () => {
expect(events).toHaveLength(1);
expect(JSON.parse(events[0]!.payload).logicalId).toBe("dev.guard");
});
describe("keeping the seat's pane (a running seat relaunched in place)", () => {
function liveDriver(rigId: string, nodeId: string, pane = "%7") {
const live = sessionRegistry.registerSession(nodeId, "dev-driver@test-rig");
sessionRegistry.updateStatus(live.id, "running");
sessionRegistry.updateBinding(nodeId, { tmuxSession: "dev-driver@test-rig", tmuxPane: pane });
Object.assign(tmux, {
hasSession: vi.fn(async () => true),
listPanes: vi.fn(async () => [{ id: "%7", index: 0, cwd: "/", width: 80, height: 24, active: true }]),
setRemainOnExit: vi.fn(async () => ({ ok: true as const })),
signalPaneProcess: vi.fn(async () => ({ ok: true as const })),
isPaneDead: vi.fn(async () => true),
getDefaultShell: vi.fn(async () => "/bin/zsh"),
respawnPane: vi.fn(async () => ({ ok: true as const })),
});
return live;
}
it("ends the seat's process in its own pane and starts a shell there, with no new session, then launches it there", async () => {
const { rigId, nodeIds } = seedPodAwareRig();
seedSnapshot(rigId, nodeIds, { [nodeIds[0]!]: emptyStartupContext("claude-code") });
const live = liveDriver(rigId, nodeIds[0]!);
const adapter = readyAdapter("claude-code");
// Without it, a running seat is left alone.
expect((await orchestrator.launchSingleNode(rigId, "dev.driver", { adapters: { "claude-code": adapter } })).alreadyRunning)
.toEqual([expect.objectContaining({ logicalId: "dev.driver" })]);
expect(adapter.launchHarness).not.toHaveBeenCalled();
const result = await orchestrator.launchSingleNode(rigId, "dev.driver", { adapters: { "claude-code": adapter }, keepPane: true });
expect(result.launched).toEqual([expect.objectContaining({ logicalId: "dev.driver", status: "fresh-primed" })]);
expect(tmux.createSession).not.toHaveBeenCalled();
expect(tmux.killSession).not.toHaveBeenCalled();
expect((tmux as any).signalPaneProcess).toHaveBeenCalledWith("%7", "TERM");
expect((tmux as any).respawnPane).toHaveBeenCalledWith("%7", "/bin/zsh", expect.objectContaining({
env: expect.objectContaining({ OPENRIG_NODE_ID: nodeIds[0], OPENRIG_SESSION_NAME: "dev-driver@test-rig" }) }));
expect(adapter.launchHarness).toHaveBeenCalledOnce();
expect(sessionRegistry.getBindingForNode(nodeIds[0]!)).toMatchObject({ tmuxSession: "dev-driver@test-rig", tmuxPane: "%7" });
expect(db.prepare("SELECT status FROM sessions WHERE id = ?").get(live.id)).toEqual({ status: "superseded" });
});
it("a pane that isn't in the seat's session is left alone, and the seat's records are put back", async () => {
const { rigId, nodeIds } = seedPodAwareRig();
seedSnapshot(rigId, nodeIds, { [nodeIds[0]!]: emptyStartupContext("claude-code") });
const live = liveDriver(rigId, nodeIds[0]!, "%8");
const result = await orchestrator.launchSingleNode(rigId, "dev.driver", { adapters: { "claude-code": readyAdapter("claude-code") }, keepPane: true });
expect(result.launched).toEqual([expect.objectContaining({ logicalId: "dev.driver", status: "failed", error: expect.stringContaining("nothing was stopped") })]);
expect((tmux as any).signalPaneProcess).not.toHaveBeenCalled();
expect((tmux as any).respawnPane).not.toHaveBeenCalled();
expect(sessionRegistry.getBindingForNode(nodeIds[0]!)).toMatchObject({ tmuxSession: "dev-driver@test-rig", tmuxPane: "%8" });
expect(db.prepare("SELECT status FROM sessions WHERE id = ?").get(live.id)).toEqual({ status: "running" });
});
it("a process that won't end isn't replaced: no shell is started over it", async () => {
const { rigId, nodeIds } = seedPodAwareRig();
seedSnapshot(rigId, nodeIds, { [nodeIds[0]!]: emptyStartupContext("claude-code") });
liveDriver(rigId, nodeIds[0]!);
Object.assign(tmux, { isPaneDead: vi.fn(async () => false) });
const fastLauncher = new NodeLauncher({ db, rigRepo, sessionRegistry, eventBus, tmuxAdapter: tmux as any,
paneExit: { pollMs: 1, timeoutMs: 2, sleep: async () => {} } });
(orchestrator as any).nodeLauncher = fastLauncher;
const result = await orchestrator.launchSingleNode(rigId, "dev.driver", { adapters: { "claude-code": readyAdapter("claude-code") }, keepPane: true });
expect(result.launched).toEqual([expect.objectContaining({ status: "failed", error: expect.stringContaining("did not exit") })]);
expect((tmux as any).signalPaneProcess).toHaveBeenCalledWith("%7", "KILL");
expect((tmux as any).respawnPane).not.toHaveBeenCalled();
});
it("the takeover goes through the daemon's delivery guard: the seat's pane stays its target while it's ended and restarted", async () => {
const { rigId, nodeIds } = seedPodAwareRig();
seedSnapshot(rigId, nodeIds, { [nodeIds[0]!]: emptyStartupContext("claude-code") });
liveDriver(rigId, nodeIds[0]!);
// The real adapter's signal and respawn, behind the real guard, as a daemon wires them. Only the shell is a fake.
const guard = new SeatDeliveryGuard(db, (name) => resolveGuardTarget(db, name));
const commands: string[] = [];
const configs: string[] = [];
const real = new TmuxAdapter(async (command) => { commands.push(command); return ""; }, {
writeFile: async (_path, content) => { configs.push(content); }, unlink: async () => {},
tmpName: () => "/inert/respawn", bufferName: () => "fixture",
});
real.deliveryGuard = guard;
real.listPanes = tmux.listPanes as any;
real.getPanePid = async () => 4242;
Object.assign(tmux, {
deliveryGuard: guard,
signalPaneProcess: vi.fn((pane: string, signal: "TERM" | "KILL") => real.signalPaneProcess(pane, signal)),
respawnPane: vi.fn((pane: string, command?: string, opts?: { cwd?: string; env?: Record<string, string> }) => real.respawnPane(pane, command, opts)),
});
// The switch holds the seat's lifecycle lease around the relaunch.
const result = await guard.lifecycle([nodeIds[0]!], () =>
orchestrator.launchSingleNode(rigId, "dev.driver", { adapters: { "claude-code": readyAdapter("claude-code") }, keepPane: true }));
expect(await (tmux as any).signalPaneProcess.mock.results[0].value).toEqual({ ok: true });
expect(await (tmux as any).respawnPane.mock.results[0].value).toMatchObject({ ok: true });
expect(commands).toContain("kill -TERM 4242");
expect(configs).toEqual([expect.stringMatching(/^respawn-pane -t \S*%7/)]);
expect(result.launched).toEqual([expect.objectContaining({ logicalId: "dev.driver", status: "fresh-primed" })]);
expect(sessionRegistry.getBindingForNode(nodeIds[0]!)).toMatchObject({ tmuxSession: "dev-driver@test-rig", tmuxPane: "%7" });
});
it("a failed registration after the pane was restarted leaves the person's session alone", async () => {
const { rigId, nodeIds } = seedPodAwareRig();
seedSnapshot(rigId, nodeIds, { [nodeIds[0]!]: emptyStartupContext("claude-code") });
liveDriver(rigId, nodeIds[0]!);
const persist = eventBus.persistWithinTransaction.bind(eventBus);
let failed = false;
vi.spyOn(eventBus, "persistWithinTransaction").mockImplementation((event) => {
if (event.type === "node.launched" && !failed) { failed = true; throw new Error("synthetic DB commit failure"); }
return persist(event);
});
const result = await orchestrator.launchSingleNode(rigId, "dev.driver", { adapters: { "claude-code": readyAdapter("claude-code") }, keepPane: true });
expect((tmux as any).respawnPane).toHaveBeenCalledOnce();
expect(tmux.killSession).not.toHaveBeenCalled();
expect(result.launched).toEqual([expect.objectContaining({ logicalId: "dev.driver", status: "failed", error: expect.stringContaining("synthetic DB commit failure") })]);
});
});
});
+73 -2
View File
@@ -1,7 +1,10 @@
import { describe, it, expect } from "vitest";
import { describe, it, expect, vi } from "vitest";
import { Hono } from "hono";
import { providerRoutes } from "../src/routes/provider.js";
import type { ProviderService } from "../src/domain/provider/provider-service.js";
import type { ClaudeSeatRelaunch, ProviderService } from "../src/domain/provider/provider-service.js";
import { RigRepository } from "../src/domain/rig-repository.js";
import { SessionRegistry } from "../src/domain/session-registry.js";
import { createFullTestDb } from "./helpers/test-app.js";
import type { FourBlockReadModel } from "../src/domain/provider/provider-types.js";
// Slice-04 (OPR.0.5.0.4) seam B — the daemon provider routes (packet 3ffa3c22 §3). Thin handlers
@@ -115,6 +118,74 @@ describe("provider routes", () => {
for (const seatRestart of lent) expect(seatRestart).toMatchObject({ plan: expect.any(Function), run: expect.any(Function) });
});
it("precheck and switch also lend a Claude seat's relaunch, and pass its token file or the stored login through", async () => {
const seen: Array<{ input: unknown; claudeSeat: unknown }> = [];
const app = appWith({
...stubService,
precheck: async (input, _seatRestart, claudeSeat) => { seen.push({ input, claudeSeat }); return { safe: true }; },
switchAccount: async (input, _seatRestart, claudeSeat) => { seen.push({ input, claudeSeat }); return { outcome: "rebind_in_progress" }; },
});
expect((await app.request("/api/provider/precheck?seat=s1&toAccount=work&tokenFile=%2Ftokens%2Fwork")).status).toBe(200);
expect((await app.request("/api/provider/precheck?seat=s1&toAccount=stored-login&storedLogin=1")).status).toBe(200);
expect((await app.request("/api/provider/switch", {
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ seat: "s1", toAccount: "work", tokenFile: "/tokens/work" }),
})).status).toBe(200);
expect(seen.map((s) => s.input)).toEqual([
{ seat: "s1", toAccount: "work", tokenFile: "/tokens/work" },
{ seat: "s1", toAccount: "stored-login", tokenFile: null },
{ seat: "s1", toAccount: "work", forceUnsafe: false, tokenFile: "/tokens/work" },
]);
for (const { claudeSeat } of seen) {
expect(claudeSeat).toMatchObject({ seat: expect.any(Function), snapshot: expect.any(Function), exclusive: expect.any(Function), relaunchInPane: expect.any(Function) });
expect(claudeSeat).not.toHaveProperty("stop");
}
const bad = await app.request("/api/provider/switch", {
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ seat: "s1", toAccount: "work", tokenFile: 7 }),
});
expect(bad.status).toBe(400);
});
it("a Claude seat's relaunch keeps its pane: restore launches only that seat from the snapshot in it, under the seat's lease, and its warnings come back", async () => {
const db = createFullTestDb();
try {
const repo = new RigRepository(db), registry = new SessionRegistry(db);
const rig = repo.createRig("fixture-rig");
const node = repo.addNode(rig.id, "dev.a", { runtime: "claude-code" });
const session = registry.registerSession(node.id, "dev-a@fixture-rig");
registry.updateStatus(session.id, "running");
registry.updateBinding(node.id, { attachmentType: "tmux", tmuxSession: "dev-a@fixture-rig", tmuxPane: "%101" });
let launched: { ok: boolean; launched?: Array<{ logicalId: string; status: string; error?: string }>; warnings?: string[]; code?: string; message?: string }
= { ok: true, launched: [{ logicalId: "dev.a", status: "resumed" }], warnings: ["dev-a@fixture-rig: a launch warning"] };
const launchSingleNode = vi.fn(async () => launched);
const leased: string[][] = [];
const tmux = { killSession: vi.fn(), deliveryGuard: { lifecycle: async (ids: string[], fn: () => Promise<unknown>) => { leased.push(ids); return fn(); } } };
let lent!: ClaudeSeatRelaunch;
const app = new Hono();
app.use("*", async (c, next) => {
for (const [key, value] of Object.entries({ rigRepo: repo, tmuxAdapter: tmux, restoreOrchestrator: { launchSingleNode }, runtimeAdapters: {},
providerService: { ...stubService, precheck: async (_i: unknown, _r: unknown, claudeSeat: ClaudeSeatRelaunch) => { lent = claudeSeat; return { safe: true }; } } })) {
c.set(key as never, value as never);
}
await next();
});
app.route("/api/provider", providerRoutes());
await app.request("/api/provider/precheck?seat=dev-a@fixture-rig&toAccount=work&tokenFile=%2Ftokens%2Fwork");
expect(lent.seat("dev-a@fixture-rig")).toMatchObject({ nodeId: node.id, logicalId: "dev.a", running: true, pane: "%101" });
expect(await lent.exclusive(node.id, async () => "held")).toBe("held");
expect(leased).toEqual([[node.id]]);
expect(await lent.relaunchInPane(rig.id, "dev.a", "snap-1")).toEqual({ ok: true, status: "resumed", warnings: ["dev-a@fixture-rig: a launch warning"] });
expect(launchSingleNode).toHaveBeenCalledWith(rig.id, "dev.a", expect.objectContaining({ snapshotId: "snap-1", keepPane: true }));
expect(tmux.killSession).not.toHaveBeenCalled();
// Restore's stops before it touches the seat leave it unchanged; anything else after the launch began is unknown.
launched = { ok: true, launched: [{ logicalId: "dev.a", status: "awaiting-decision", error: "decide first" }] };
expect(await lent.relaunchInPane(rig.id, "dev.a", "snap-1")).toMatchObject({ ok: false, effect: "none", code: "awaiting-decision" });
launched = { ok: true, launched: [{ logicalId: "dev.a", status: "failed", error: "the pane couldn't be respawned" }] };
expect(await lent.relaunchInPane(rig.id, "dev.a", "snap-1")).toMatchObject({ ok: false, effect: "unknown", code: "failed" });
launched = { ok: false, code: "snapshot_not_found", message: "no such snapshot" };
expect(await lent.relaunchInPane(rig.id, "dev.a", "snap-1")).toMatchObject({ ok: false, effect: "none", code: "snapshot_not_found" });
} finally { db.close(); }
});
it("switch business outcomes are 200 payloads, not transport errors (failed_safely = 200)", async () => {
const res = await appWith(stubService).request("/api/provider/switch", {
method: "POST",
@@ -0,0 +1,221 @@
import { afterEach, describe, it, expect } from "vitest";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { execFileSync } from "node:child_process";
import { ProviderServiceImpl } from "../src/domain/provider/provider-service-impl.js";
import type { ClaudeSeatTokenRef } from "../src/domain/claude-seat-token.js";
import type { CredentialPrecedence } from "../src/domain/claude-credential-precedence.js";
import type { ClaudeSeatRelaunch } from "../src/domain/provider/provider-service.js";
// `rig provider switch --seat` for a Claude seat: the seat keeps its config dir and its pane, gets its own subscription token
// through a token-file reference, and resumes the same conversation by restore's own path: under the seat's lifecycle lease, a
// snapshot taken now that resumes it, then only that seat relaunched from that snapshot in its own pane. Nothing changes until
// every check has passed, and only a refusal before the seat was touched reads as unchanged.
const cleanup: Array<() => void> = [];
afterEach(() => { for (const f of cleanup.splice(0)) f(); });
const SEAT = "dev-a@rig";
const CLEAR: CredentialPrecedence = { conflicts: [], checked: ["the launch environment"], notChecked: ["managed settings (a managed-settings.json file, MDM or the server)"] };
type Relaunched = Awaited<ReturnType<ClaudeSeatRelaunch["relaunchInPane"]>>;
const RESUMED: Relaunched = { ok: true, status: "resumed", warnings: [] };
function tokenFile(contents = "fixture-file-contents", name = "work.token") {
const dir = mkdtempSync(path.join(tmpdir(), "claude-switch-"));
cleanup.push(() => rmSync(dir, { recursive: true, force: true }));
const file = path.join(dir, name);
writeFileSync(file, contents, { mode: 0o600 });
return file;
}
const deferred = () => { let resolve!: () => void; const promise = new Promise<void>((r) => { resolve = r; }); return { promise, resolve }; };
const settle = async () => { for (let i = 0; i < 5; i++) await new Promise((r) => setTimeout(r, 0)); };
function harness(opts: {
runtime?: string; running?: boolean; pane?: string | null; ref?: ClaudeSeatTokenRef | null; precedence?: CredentialPrecedence;
snapshot?: { resumes: true; conversation: string | null } | { resumes: false; why: string };
relaunch?: Relaunched | (() => Promise<Relaunched>);
exclusive?: (fn: () => Promise<unknown>) => Promise<unknown>;
} = {}) {
const calls: string[] = [];
let ref: ClaudeSeatTokenRef | null = opts.ref ?? null;
let queue: Promise<unknown> = Promise.resolve();
const claudeSeat: ClaudeSeatRelaunch = {
seat: () => (opts.runtime ?? "claude-code") === "claude-code"
? { nodeId: "node-a", rigId: "rig-1", rigName: "rig", logicalId: "dev.a", running: opts.running ?? true, pane: opts.pane === undefined ? "%7" : opts.pane } : null,
tokenRef: () => ref,
setTokenRef: (_nodeId, next) => { calls.push(`setTokenRef ${next ? next.label : "null"}`); ref = next; },
snapshot: (rigId) => { calls.push(`snapshot ${rigId}`); return { snapshotId: "snap-1", ...(opts.snapshot ?? { resumes: true as const, conversation: "conv-1" }) }; },
// The seat's lifecycle lease: one holder at a time, in arrival order.
exclusive: <T>(nodeId: string, fn: () => Promise<T>): Promise<T> => {
calls.push(`exclusive ${nodeId}`);
if (opts.exclusive) return opts.exclusive(fn) as Promise<T>;
const run = queue.then(fn, fn);
queue = run.catch(() => undefined);
return run;
},
relaunchInPane: async (rigId, logicalId, snapshotId) => {
calls.push(`relaunch ${rigId} ${logicalId} ${snapshotId} with ${ref ? ref.label : "no reference"}`);
return typeof opts.relaunch === "function" ? opts.relaunch() : opts.relaunch ?? RESUMED;
},
};
const claudePrecedence = async (nodeId: string, o?: { storedLogin?: boolean }) => {
calls.push(`precedence ${nodeId}${o?.storedLogin ? " (stored login)" : ""}`); return opts.precedence ?? CLEAR;
};
const svc = new ProviderServiceImpl({ db: {} as never, listRigs: () => [], claudePrecedence });
const seatRestart = { plan: async () => { throw new Error("the Codex restart isn't used for a Claude seat"); }, run: async () => { throw new Error("unused"); } };
return { svc, seatRestart, claudeSeat, calls, ref: () => ref };
}
const to = (account: string, file: string | null) => ({ seat: SEAT, toAccount: account, tokenFile: file, forceUnsafe: false });
describe("rig provider switch --seat moves a Claude seat to its own subscription token on the same conversation, in its own pane", () => {
it("under the seat's lease, checks what outranks the token, takes a snapshot that resumes the seat, sets the reference and relaunches the seat in its pane from that snapshot", async () => {
const file = tokenFile();
const h = harness();
const r = await h.svc.switchAccount(to("work", file), h.seatRestart, h.claudeSeat);
expect(h.calls).toEqual([
"exclusive node-a",
"precedence node-a",
"snapshot rig-1",
"setTokenRef work",
"relaunch rig-1 dev.a snap-1 with work",
]);
expect(r).toMatchObject({ outcome: "rebind_in_progress", message: expect.stringContaining("in its own pane on its own token"), seat: {
seat: SEAT, account: "work", tokenFile: file, conversation: "conv-1", loginChanged: true, tokenApplied: true,
undo: `rig provider switch --seat ${SEAT} --stored-login`,
next: expect.stringContaining("/status"),
} });
// The reference is metadata: the file's contents appear nowhere.
expect(JSON.stringify(r)).not.toContain("fixture-file-contents");
expect(h.ref()).toEqual({ label: "work", tokenFile: file });
});
it("a credential that outranks the token stops it before anything changes, naming each and its remedy", async () => {
const h = harness({ precedence: { ...CLEAR, conflicts: [{ name: "ANTHROPIC_API_KEY", source: "the daemon's environment", remedy: "remove it from the daemon's allowlist" }] } });
const r = await h.svc.switchAccount({ ...to("work", tokenFile()), forceUnsafe: true }, h.seatRestart, h.claudeSeat);
expect(r).toMatchObject({ outcome: "failed_safely", reasons: ["credential_outranks_token"],
message: expect.stringMatching(/ANTHROPIC_API_KEY.*the daemon's environment.*remove it from the daemon's allowlist/) });
expect(h.calls).toEqual(["exclusive node-a", "precedence node-a"]);
});
it("a token file that wouldn't apply stops it before anything changes", async () => {
const h = harness();
const r = await h.svc.switchAccount(to("work", path.join(tmpdir(), "no-such-dir-claude-switch", "work.token")), h.seatRestart, h.claudeSeat);
expect(r).toMatchObject({ outcome: "failed_safely", reasons: ["token_file_unusable"], message: expect.stringContaining("doesn't exist") });
expect(h.calls).toEqual(["exclusive node-a"]);
});
it("a snapshot that can't resume the seat's conversation stops it before the reference or the seat changes", async () => {
const h = harness({ snapshot: { resumes: false, why: "has no resume token for this seat" } });
const r = await h.svc.switchAccount(to("work", tokenFile()), h.seatRestart, h.claudeSeat);
expect(r).toMatchObject({ outcome: "failed_safely", reasons: ["conversation_not_resumable"], message: expect.stringContaining("has no resume token") });
expect(h.calls).toEqual(["exclusive node-a", "precedence node-a", "snapshot rig-1"]);
expect(h.ref()).toBeNull();
});
it("a seat without a bound pane isn't relaunched, and nothing changes", async () => {
const h = harness({ pane: null });
const r = await h.svc.switchAccount(to("work", tokenFile()), h.seatRestart, h.claudeSeat);
expect(r).toMatchObject({ outcome: "failed_safely", reasons: ["no_bound_pane"] });
expect(h.ref()).toBeNull();
expect(h.calls.some((call) => call.startsWith("relaunch"))).toBe(false);
});
it("a relaunch refused before the seat was touched puts the reference back, and the seat keeps running as it was", async () => {
const before = { label: "personal", tokenFile: tokenFile() };
const h = harness({ ref: before, relaunch: { ok: false, effect: "none", code: "awaiting-decision", message: "Restore stopped for a decision." } });
const r = await h.svc.switchAccount(to("work", tokenFile()), h.seatRestart, h.claudeSeat);
expect(r).toMatchObject({ outcome: "failed_safely", reasons: ["relaunch_awaiting_decision"], message: expect.stringContaining("keeps running as it was") });
expect(h.ref()).toEqual(before);
});
it("a relaunch that ended the seat's process but didn't resume reads unknown, with the snapshot's recovery and the way back, and isn't retried", async () => {
const h = harness({ relaunch: { ok: false, effect: "unknown", code: "failed", message: "The pane couldn't be respawned." } });
const r = await h.svc.switchAccount(to("work", tokenFile()), h.seatRestart, h.claudeSeat);
expect(r).toMatchObject({ outcome: "unknown", reasons: ["relaunch_failed"], message: expect.stringContaining("may have been ended"), seat: {
loginChanged: true, undo: `rig provider switch --seat ${SEAT} --stored-login`,
restart: { recoveryCommand: "rig launch rig dev.a --snapshot-id snap-1", remainingStep: expect.stringContaining(`rig seat stop ${SEAT}`) } } });
expect(r.message).not.toContain("keeps running");
expect(h.calls.filter((call) => call.startsWith("relaunch"))).toHaveLength(1);
});
it("a relaunch that throws reads unknown, with the snapshot's recovery, instead of rejecting the switch", async () => {
const h = harness({ relaunch: async () => { throw new Error("transport failure after the launch request"); } });
const r = await h.svc.switchAccount(to("work", tokenFile()), h.seatRestart, h.claudeSeat);
expect(r).toMatchObject({ outcome: "unknown", reasons: ["relaunch_threw"], seat: { restart: { recoveryCommand: "rig launch rig dev.a --snapshot-id snap-1" } } });
});
it("a token the relaunch couldn't apply is reported: the reference is saved, the token didn't take effect, and the warning is carried", async () => {
const warning = "dev-a@rig: This seat's Claude subscription token (work) wasn't applied: the seat's pane shell can't run the token step. Claude started without it.";
const h = harness({ relaunch: { ok: true, status: "resumed", warnings: [warning] } });
const r = await h.svc.switchAccount(to("work", tokenFile()), h.seatRestart, h.claudeSeat);
expect(r).toMatchObject({ outcome: "rebind_in_progress", message: expect.stringContaining("the token didn't take effect"),
seat: { tokenApplied: false, warnings: [warning] } });
expect(r.message).not.toContain("on its own token");
});
it("--stored-login moves the seat back, and the way back names the reference it had, its path quoted as one argument", async () => {
const before = { label: "work", tokenFile: tokenFile("fixture-file-contents", "prior token's file.txt") };
const h = harness({ ref: before });
const r = await h.svc.switchAccount(to("stored-login", null), h.seatRestart, h.claudeSeat);
expect(h.calls).toContain("precedence node-a (stored login)");
expect(r).toMatchObject({ outcome: "rebind_in_progress", message: expect.stringContaining("on its config dir's stored login"), seat: { account: "stored-login",
undo: `rig provider switch --seat ${SEAT} --to-account work --token-file '${before.tokenFile.replace(/'/g, `'"'"'`)}'` } });
expect(h.ref()).toBeNull();
const args = execFileSync("/bin/sh", ["-c", `rig() { printf '%s\\n' "$@"; }\n${r.seat!.undo}`], { env: { PATH: "/usr/bin:/bin" }, encoding: "utf8" }).trimEnd().split("\n");
expect(args.slice(args.indexOf("--token-file") + 1)).toEqual([before.tokenFile]);
});
it("--stored-login with an inherited CLAUDE_CODE_OAUTH_TOKEN goes ahead and says that token is what the seat uses", async () => {
const inherited = { name: "CLAUDE_CODE_OAUTH_TOKEN", source: "the daemon's environment, which every managed Claude launch receives", remedy: "unset it where the daemon starts" };
const h = harness({ ref: { label: "work", tokenFile: tokenFile() }, precedence: { ...CLEAR, instead: [inherited] } });
const r = await h.svc.switchAccount(to("stored-login", null), h.seatRestart, h.claudeSeat);
expect(r).toMatchObject({ outcome: "rebind_in_progress",
message: expect.stringContaining("It uses the CLAUDE_CODE_OAUTH_TOKEN from the daemon's environment"),
seat: { next: expect.stringContaining("not the stored login") } });
expect(r.message).not.toContain("on its config dir's stored login");
});
it("a stopped seat isn't relaunched: it says to launch it first, and nothing changes", async () => {
const h = harness({ running: false });
const r = await h.svc.switchAccount(to("work", tokenFile()), h.seatRestart, h.claudeSeat);
expect(r).toMatchObject({ outcome: "failed_safely", reasons: ["seat_not_running"] });
expect(h.calls).toEqual(["exclusive node-a"]);
});
it("a second switch on the same seat waits for the first, then checks the seat again and relaunches with its own reference", async () => {
const firstRelaunch = deferred();
let relaunches = 0;
const h = harness({ relaunch: async () => { if (++relaunches === 1) await firstRelaunch.promise; return RESUMED; } });
const first = h.svc.switchAccount(to("alpha", tokenFile()), h.seatRestart, h.claudeSeat);
const second = h.svc.switchAccount(to("beta", tokenFile()), h.seatRestart, h.claudeSeat);
await settle();
expect(h.calls.filter((call) => call.startsWith("relaunch"))).toEqual(["relaunch rig-1 dev.a snap-1 with alpha"]);
expect(h.calls.filter((call) => call.startsWith("precedence"))).toHaveLength(1);
firstRelaunch.resolve();
const [a, b] = await Promise.all([first, second]);
expect(h.calls.filter((call) => call.startsWith("relaunch"))).toEqual(["relaunch rig-1 dev.a snap-1 with alpha", "relaunch rig-1 dev.a snap-1 with beta"]);
expect(h.calls.filter((call) => call.startsWith("precedence"))).toHaveLength(2);
expect(a).toMatchObject({ outcome: "rebind_in_progress", seat: { account: "alpha" } });
expect(b).toMatchObject({ outcome: "rebind_in_progress", seat: { account: "beta" } });
expect(h.ref()?.label).toBe("beta");
});
it("a lease the seat can't give (its typing guard holds it) refuses before anything changes", async () => {
const h = harness({ exclusive: async () => { throw Object.assign(new Error("Automatic input is paused for this seat."), { code: "typing_guard_enabled" }); } });
const r = await h.svc.switchAccount(to("work", tokenFile()), h.seatRestart, h.claudeSeat);
expect(r).toMatchObject({ outcome: "failed_safely", reasons: ["typing_guard_enabled"], message: expect.stringContaining("Nothing changed") });
expect(h.ref()).toBeNull();
});
it("precheck names the reference, whether its file would apply and what would outrank it, and changes nothing", async () => {
const h = harness();
const file = tokenFile();
expect(await h.svc.precheck({ seat: SEAT, toAccount: "work", tokenFile: file }, h.seatRestart, h.claudeSeat)).toMatchObject({
safe: true, claude: { label: "work", tokenFile: file, notChecked: CLEAR.notChecked } });
expect(h.calls).toEqual(["precedence node-a"]);
expect(h.ref()).toBeNull();
});
});
@@ -1,4 +1,7 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import type Database from "better-sqlite3";
import { createFullTestDb, createTestApp } from "./helpers/test-app.js";
import { RigRepository } from "../src/domain/rig-repository.js";
@@ -83,6 +86,36 @@ describe("SeatStatusService", () => {
expect(status("dev-term@home-rig")).not.toHaveProperty("runtime_home");
});
it("names a Claude seat's own subscription token by its label and file, and says when that file wouldn't apply at the next launch", () => {
const dir = mkdtempSync(path.join(tmpdir(), "seat-token-status-"));
try {
const ready = path.join(dir, "ready");
writeFileSync(ready, "fixture-file-contents", { mode: 0o600 });
const rig = rigRepo.createRig("token-rig");
const nodes = {
ok: rigRepo.addNode(rig.id, "dev.ok", { runtime: "claude-code" }),
missing: rigRepo.addNode(rig.id, "dev.missing", { runtime: "claude-code" }),
none: rigRepo.addNode(rig.id, "dev.none", { runtime: "claude-code" }),
};
for (const [name, node] of Object.entries(nodes)) sessionRegistry.registerSession(node.id, `dev-${name}@token-rig`);
rigRepo.setNodeClaudeTokenRef(nodes.ok.id, { label: "work", tokenFile: ready });
rigRepo.setNodeClaudeTokenRef(nodes.missing.id, { label: "personal", tokenFile: path.join(dir, "gone") });
const status = (ref: string) => {
const result = service.getStatus(ref);
if (!result.ok) throw new Error(result.code);
return result.status;
};
expect(status("dev-ok@token-rig").claude_token).toEqual({ label: "work", token_file: ready, file: "readable" });
expect(status("dev-missing@token-rig").claude_token).toMatchObject({ label: "personal", token_file: path.join(dir, "gone"),
file: "unusable", problem: expect.stringContaining("doesn't exist"), notice: expect.stringContaining("wasn't applied") });
expect(status("dev-none@token-rig")).not.toHaveProperty("claude_token");
// The file is checked, never read: its contents appear nowhere.
expect(JSON.stringify(status("dev-ok@token-rig"))).not.toContain("fixture-file-contents");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("returns honest no-handover defaults for an existing active node", () => {
const rig = rigRepo.createRig("seat-rig");
const node = rigRepo.addNode(rig.id, "dev.impl", { runtime: "codex", cwd: "/project" });
+1 -1
View File
@@ -84,7 +84,7 @@
"openrig-operating-model/templates/SOP.md": "1eaf0daf4f9bbf0715e6a5aaf0f29f030af5fe8b8a10232488ee97c507afa6dc",
"openrig-operating-model/templates/SPEC.md": "f126536daa59cea3f1cfb0898d83c7cdf99f6392f6f155ea64fe55187dd0f5f1",
"openrig-skills/SKILL.md": "bdd6594ae1108455103239d39694c865cc40d3f7d9f24cd3566a18397df270da",
"openrig-user/SKILL.md": "64a1901c9097c41b3ef133c2998dcbc966ee25f7e1a201bd7e8142ca78a864ae",
"openrig-user/SKILL.md": "01cb169050a3d4f51ad7b263772c6ed58f62683df820efeac1a88faadf9cd086",
"orienting-to-an-inherited-seat/CHANGELOG.md": "4d0970e75e5c15b00a48d6a7150376d646257777264fd1b9a191165a8d5a6e84",
"orienting-to-an-inherited-seat/SKILL.md": "252fed402f19027056bbba241df3305be88de3175b67dc8fe051143bb0d4412b",
"queue-handoff/SKILL.md": "fcfa420a49a09be4fd51ccc6ca89c00847a59484a7bcab205c8eec83837cdc0d",