mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-11 17:32:22 -10:00
Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d21a50acba | ||
|
|
0965959f14 | ||
|
|
337bd64926 | ||
|
|
8cc6468dd3 | ||
|
|
da78915c5e | ||
|
|
b71bd5245c | ||
|
|
44a39774d9 | ||
|
|
76d18b8311 | ||
|
|
5ecb4241ba | ||
|
|
96a9ff69ba | ||
|
|
3a9c5008ab | ||
|
|
5927a36e69 | ||
|
|
86bd3c36f4 | ||
|
|
f2a2eada63 | ||
|
|
1b276a986f | ||
|
|
0a7babd88f | ||
|
|
b74dd594b6 | ||
|
|
faec860c6f | ||
|
|
31334e5f81 | ||
|
|
157cf502e1 | ||
|
|
3e6c3ea667 | ||
|
|
2123324b67 | ||
|
|
e9d37c1acf | ||
|
|
14cde2080a | ||
|
|
c7e5c2ff3b | ||
|
|
661a66e126 | ||
|
|
8854a217b9 | ||
|
|
d584b49003 | ||
|
|
9d9e3aa558 | ||
|
|
8f1693b796 | ||
|
|
60a669fd6b |
No files matched your search
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: ");
|
||||
}
|
||||
@@ -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 }; }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,11 @@ 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 { createDb } from "../src/db/connection.js";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
import { ALL_MIGRATIONS } from "../src/db/all-migrations.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>>) {
|
||||
@@ -61,8 +66,10 @@ describe("RestoreOrchestrator.launchNodeSubset", () => {
|
||||
let processes: NativeProcessRow[];
|
||||
let listProcesses: ReturnType<typeof vi.fn<() => Promise<NativeProcessRow[]>>>;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createFullTestDb();
|
||||
beforeEach(() => { setup(createFullTestDb()); });
|
||||
|
||||
function setup(database: Database.Database) {
|
||||
db = database;
|
||||
rigRepo = new RigRepository(db);
|
||||
sessionRegistry = new SessionRegistry(db);
|
||||
eventBus = new EventBus(db);
|
||||
@@ -80,7 +87,7 @@ describe("RestoreOrchestrator.launchNodeSubset", () => {
|
||||
codexResume: new CodexResumeAdapter(tmux as any),
|
||||
listProcesses,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => { db.close(); });
|
||||
|
||||
@@ -672,4 +679,123 @@ 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 () => {
|
||||
// The guard's own tables, as a daemon's database has them.
|
||||
db.close();
|
||||
const full = createDb();
|
||||
migrate(full, ALL_MIGRATIONS);
|
||||
setup(full);
|
||||
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") })]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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" });
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user