feat(provider): rig provider switch --seat moves one Codex seat to a login saved in its home (#1972)

An agent can move one Codex seat to another subscription login with one command, and the seat keeps its conversation: `rig provider precheck --seat <seat> --to-account <name>` shows the Codex home the running seat uses, what the seat is doing and every peer on that home, and `rig provider switch --seat <seat> --to-account <name>` performs the move. Before this, `rig provider switch` always answered `failed_safely` with `switch_execution_not_yet_wired`.

How it works: the switch composes `rig auth`'s file operations, now in the daemon (`@openrig/daemon/codex-auth`, with the CLI path a re-export), and the in-place `rig seat restart --keep-conversation`. It plans the restart first, which stops nothing, and checks that the login is saved in that home. It then saves the outgoing login as a unique `before-switch-<time>` copy, never replacing an earlier one; a save that fails for any reason other than "no active login" stops the switch before anything changes. It activates the chosen login and restarts the seat in place. The report reads `rebind_in_progress` with the verify step, `failed_safely` with the way back, or `unknown` with the restart's recovery command, never retried. Switches on one Codex home run one at a time inside the daemon, and a switch that waited behind another rechecks its seat before it changes the login. Every same-home peer is listed with what will happen to it, from Codex 0.164.0's own source: it keeps its old login in memory until its next token refresh, then stops with "signed in to another account". Each peer gets a pasteable `rig seat restart <peer> --keep-conversation --reason ...` step, and the person is told not to sign in again in that home. After a CLI timeout, the message says the switch may still be running or queued behind another.

Limits: Codex seats only (a Claude seat is refused with `rebind_unsupported_for_runtime`), Linux only (the restart reads `/proc`), and the login must already be saved in the seat's own home. A restart that never settles holds its home's queue; switches there wait, and their CLIs report unknown after 120 s. Auth contents never enter returned metadata, logs or argv: files are copied by descriptor through a scrubbed buffer.

Validation: the new tests fail on main and pass now, including a failed outgoing save, two switches in one second, overlapping switches on one home, and a switch whose seat became ineligible or moved home while it waited. The provider-switch and route suites and the CLI tests pass, and the independent security review's real-file controls pass. Native proof with two logins in one home and a control seat is Quality's.
This commit is contained in:
Mike Schwarz authored and GitHub committed 2026-10-25 01:01:10 -07:00
1 parent 2323f9bc42
commit 1de6984464
16 files changed
+1224 -602

No files matched your search

+76 -16
View File
@@ -1,6 +1,6 @@
import fs from "node:fs";
import { Command } from "commander";
import { DaemonClient } from "../client.js";
import { DaemonClient, DaemonTimeoutError } from "../client.js";
import { getDaemonStatus, getDaemonUrl , daemonStatusGuard} from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
@@ -20,9 +20,27 @@ export const USAGE_ACROSS_HOSTS_READ_TIMEOUT_MS = 20_000;
// deadline: 50 s + 15 s + a 10 s margin = 75 s.
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 };
// 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;
function printSeat(seat: SeatView): void {
if (seat.home) console.log(`home: ${seat.home}`);
if (seat.activity) console.log(`seat activity: ${seat.activity}`);
for (const peer of seat.peers) {
const where = peer.home === "same" ? "peer on this home" : peer.home === "different" ? "seat on another home" : "seat, home unknown";
console.log(`${where}: ${peer.sessionName ?? peer.logicalId}: ${peer.effect}`);
if (peer.step) console.log(` then: ${peer.step}`);
}
}
export function providerCommand(depsOverride?: StatusDeps): Command {
const cmd = new Command("provider").description(
"Provider accounts and usage signals, plus a switch precheck (switching itself isn't carried out yet)",
"Provider accounts and usage signals, and moving one Codex seat to another login saved in its Codex home",
);
const getDeps = (): StatusDeps =>
depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
@@ -471,9 +489,9 @@ export function providerCommand(depsOverride?: StatusDeps): Command {
cmd
.command("precheck")
.description("Whether switching a seat to an account is safe (never offers an unsafe switch)")
.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 target account ref")
.requiredOption("--to-account <a>", "The login's saved name in the seat's Codex home (rig auth list --codex-home <home>)")
.option("--json", "Output as parseable JSON")
.action(async (opts: { seat: string; toAccount: string; json?: boolean }) => {
const client = await getClient(getDeps());
@@ -482,35 +500,77 @@ export function providerCommand(depsOverride?: StatusDeps): Command {
return;
}
const qs = new URLSearchParams({ seat: opts.seat, toAccount: opts.toAccount });
const res = await client.get<{ safe: boolean; reasons?: string[] }>(`/api/provider/precheck?${qs.toString()}`);
const res = await client.get<{ safe: boolean; reasons?: string[]; message?: string; seat?: SeatView }>(`/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(", ")}`);
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.safe) process.exitCode = 1; // let scripts gate on an unsafe verdict
});
cmd
.command("switch")
.description("Request a seat's switch to an account. Not carried out yet: the daemon runs the precheck and answers failed_safely (reasons in --json), so no account changes")
.description("Move one Codex seat to another login saved in the Codex home it runs on, keeping its conversation (Codex only, Linux only)")
.requiredOption("--seat <s>", "The seat session")
.requiredOption("--to-account <a>", "The target account ref")
.option("--force-unsafe", "Skip the precheck's refusal; the switch still isn't carried out (switch_execution_not_yet_wired)")
.requiredOption("--to-account <a>", "The login's saved name in the seat's Codex home (rig auth list --codex-home <home>)")
.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", `
The daemon plans the seat's in-place restart first (rig seat restart --plan), so a refusal changes nothing. It then saves the
home's current login as before-switch-<time>, activates the chosen one there (rig auth switch), and restarts the seat in place
with the same conversation, pane and launch settings. If the current login can't be saved first, nothing changes. Every seat
on that home shares its login. Peers aren't restarted: each keeps its current login only until its next token refresh, when
Codex stops it with "signed in to another account" until it's restarted, so the report prints the rig seat restart <peer>
--keep-conversation --reason "..." to paste for each. Signing in again there would replace the login you just switched to.
One switch runs at a time per home; a switch that waited checks the seat again before it changes anything. The account
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).
- 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 }) => {
const client = await getClient(getDeps());
if (!client) {
process.exitCode = 1;
return;
}
const res = await client.post<{ outcome?: string; reasons?: string[] }>("/api/provider/switch", {
seat: opts.seat,
toAccount: opts.toAccount,
forceUnsafe: opts.forceUnsafe ?? false,
});
type SwitchResponse = { status: number; data: { outcome?: string; reasons?: string[]; message?: string; seat?: SwitchReport } };
let res: SwitchResponse;
const startedAt = new Date().toISOString();
try {
res = await client.post<SwitchResponse["data"]>("/api/provider/switch", {
seat: opts.seat,
toAccount: opts.toAccount,
forceUnsafe: opts.forceUnsafe ?? false,
}, { timeoutMs: SWITCH_TIMEOUT_MS });
} catch (err) {
if (!(err instanceof DaemonTimeoutError)) throw err;
// 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.` } };
}
if (handleHttpError(res, "switch")) return;
if (opts.json) console.log(JSON.stringify(res.data));
else console.log(res.data.outcome ?? "unknown");
if (res.data.outcome === "failed_safely") process.exitCode = 1;
else {
const { outcome, reasons, message, seat } = res.data;
console.log(reasons?.length ? `${outcome ?? "unknown"}: ${reasons.join(", ")}` : outcome ?? "unknown");
if (message) console.log(message);
if (seat) {
console.log(`home: ${seat.home}${seat.loginChanged ? ` (now "${seat.account}")` : ""}`);
if (seat.undo) console.log(`to switch the home back: ${seat.undo}`);
if (seat.restart?.recoveryCommand) console.log(`recovery: ${seat.restart.recoveryCommand}`);
printSeat({ peers: seat.peers });
if (seat.next) console.log(`next: ${seat.next}`);
}
}
if (res.data.outcome !== "rebind_in_progress" && res.data.outcome !== "succeeded") process.exitCode = 1;
});
// Slice 18 — subscription move reports: what the agent that moved a Codex home reports, kept as queue items tagged
+2 -568
View File
@@ -1,568 +1,2 @@
// OPR.0.4.1.29 — rig auth secret-safe core (CLI-LOCAL / daemon-free).
//
// This module operates directly on the operator's Codex auth files under CODEX_HOME and is invoked
// only from the `rig auth` CLI command — it NEVER touches the daemon, so a token can never enter the
// daemon queue / SSE stream / SQLite / event log (never-queued/never-streamed is true BY CONSTRUCTION).
//
// SECRET INVARIANTS (non-negotiable): no auth/refresh/access token value is ever returned in, or used
// to build, any human-facing string. Functions return STRUCTURED, non-secret result objects
// (presence / mode / parseability / login-state / names / counts); the auth FILE is snapshotted as a
// file (mode-guarded byte copy), never read into a printed value. CODEX_HOME defaults to $HOME/.codex
// and is env-overridable (tests point it at a fixture); no personal/operator path is baked in.
import path from "node:path";
import { userInfo } from "node:os";
/** The account's home from the OS account database. Unlike os.homedir() this does not echo
* an empty $HOME back, so a HOME="" process still lands under the real home. Empty string
* when the OS reports no home at all: the caller keeps the historical cwd-relative
* ".codex" rather than erroring. */
function accountHome(): string {
try { return userInfo().homedir ?? ""; } catch { return ""; }
}
import fs from "node:fs";
import { spawnSync } from "node:child_process";
/** Strict profile-name whitelist: alnum-led, then [A-Za-z0-9._-], 1..64 chars. Excludes /, \, ~,
* leading dot, whitespace, control, and shell metacharacters. Fail closed. */
const PROFILE_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
export function validateProfileName(name: unknown): name is string {
return typeof name === "string" && name.length > 0 && name.length <= 64 && PROFILE_NAME_RE.test(name);
}
export interface CodexAuthPaths {
/** Codex state dir — CODEX_HOME or $HOME/.codex. */
codexHome: string;
/** Saved-profile dir (0700). */
profileDir: string;
/** Active auth file the Codex CLI reads (0600). */
activeAuth: string;
/** Product-native seat->profile metadata registry (0600). */
registryPath: string;
}
/** Resolve the Codex paths from an env map (default process.env). CODEX_HOME wins; else $HOME/.codex. */
export function resolveCodexHome(env: NodeJS.ProcessEnv = process.env): CodexAuthPaths {
const home = typeof env.HOME === "string" && env.HOME.length > 0 ? env.HOME : accountHome();
const codexHome =
typeof env.CODEX_HOME === "string" && env.CODEX_HOME.length > 0
? env.CODEX_HOME
// HOME unset/empty (cron, systemd, some CI/container shells) must not yield a
// cwd-relative ".codex" — auth files stay under the account's home.
: path.join(home, ".codex");
return {
codexHome,
profileDir: path.join(codexHome, "auth-profiles"),
activeAuth: path.join(codexHome, "auth.json"),
registryPath: path.join(codexHome, "auth-seat-registry.tsv"),
};
}
// --- secret-safe fs helpers (no contents ever read into a returned/printed value) ---
function lstatSafe(p: string): fs.Stats | null {
try {
return fs.lstatSync(p);
} catch {
return null;
}
}
function isDir(p: string): boolean {
const st = lstatSafe(p);
return st !== null && st.isDirectory();
}
function isFile(p: string): boolean {
const st = lstatSafe(p);
return st !== null && st.isFile();
}
/** Octal permission string (e.g. "600") or null when the path is absent/unstatable. */
function fileModeOctal(p: string): string | null {
const st = lstatSafe(p);
if (st === null) return null;
return (st.mode & 0o777).toString(8).padStart(3, "0");
}
/** A profile file is safe iff it exists, is a regular file (not a symlink — which could redirect us
* out of the profile dir), has link count 1 (a hardlink shares an inode that may live outside the
* profile dir), and lives directly in profileDir. */
function isSafeProfileFile(p: string, profileDir: string): boolean {
const st = lstatSafe(p);
if (st === null || st.isSymbolicLink() || !st.isFile() || st.nlink !== 1) return false;
return path.dirname(p) === profileDir;
}
/** True iff `dir` really resolves INSIDE realpath(codexHome). realpath follows every symlink in the
* chain, so a symlinked parent (e.g. an auth-profiles dir pointing outside) resolves out and is
* rejected — a lexical startsWith would be fooled. Returns false if either path can't be resolved. */
function realDirContained(dir: string, codexHome: string): boolean {
try {
const root = fs.realpathSync(codexHome);
const resolved = fs.realpathSync(dir);
return resolved === root || resolved.startsWith(root + path.sep);
} catch {
return false;
}
}
/** A destination we are about to write SECRET bytes onto is safe to replace iff it is absent, or a
* regular file (not a symlink) with link count 1. A symlink redirects the write; a hardlink (nlink>1)
* shares an inode that may live outside CODEX_HOME — writing through either leaks secret bytes out. */
function destReplaceable(p: string): boolean {
const st = lstatSafe(p);
if (st === null) return true;
return !st.isSymbolicLink() && st.isFile() && st.nlink === 1;
}
/** fd-first copy of `source` onto `dest` (OPR.0.4.3.23 secret-boundary B1 hardening). Closes the
* check-then-use gap that a path-based copy leaves open: an earlier lstat guard, then a copy that
* re-resolves the SOURCE PATH, can be redirected by an inode swap in the window (a crash, or a
* concurrent legitimate rig on the same auth home) — reading the wrong inode, a torn file, or briefly
* leaving a wider-than-0600 temp (CERT FIO45-C). Path-based pre-checks cannot close that race, so the
* authoritative check moves onto the OPENED fd:
* (1) SOURCE: openSync with O_NOFOLLOW (a final-component symlink swap fails closed), then fstat ON
* THE FD and validate the opened inode (regular file, nlink === 1 — a hardlink shares an inode
* that may live outside the boundary), and — when the caller passes its earlier lstat — confirm
* dev/ino still match (the inode was not swapped in the window). Read bytes FROM the fd; the
* source path is never re-resolved after the open.
* (2) DEST: openSync the temp with O_CREAT|O_EXCL (fresh inode; never a pre-existing hardlink/symlink)
* at 0600 AT creation (removes the create-then-chmod window), fchmod on the fd to pin 0600
* regardless of umask, write the source bytes, fsync, then renameSync as the SOLE atomic publish
* (a crash BEFORE the rename leaves the prior dest byte-intact — a strength we preserve).
* The temp stays co-located in dest's dir so the rename never crosses devices. The transient byte
* buffer is the only time secret bytes touch JS memory; it is never printed and is scrubbed in
* `finally`. Caller must still have verified dest's parent containment and that dest is replaceable
* (the lstat pre-checks stay as cheap fail-fast; this fd validation is the authoritative layer).
* Exported for the fd-first / crash-safety leak-hunt tests. */
export function copyOntoFresh(
source: string,
dest: string,
expectSrc?: { dev: number; ino: number },
): boolean {
const tmp = `${dest}.tmp-${process.pid}`;
// O_NOFOLLOW fails the open closed if a final-component symlink was swapped in. (O_CLOEXEC is omitted:
// it is undefined on macOS Node and absent from @types/node, and no child process is spawned during
// this fd's synchronous lifetime, so close-on-exec would be a no-op here.)
const NOFOLLOW = fs.constants.O_NOFOLLOW ?? 0;
let srcFd = -1;
let destFd = -1;
let data: Buffer | null = null;
try {
try {
fs.rmSync(tmp, { force: true }); // clear any stale temp so the O_EXCL create below succeeds
} catch {
/* ignore */
}
// (1) fd-first source read: open, then validate ON THE FD — never re-resolve the source path.
srcFd = fs.openSync(source, fs.constants.O_RDONLY | NOFOLLOW);
const st = fs.fstatSync(srcFd);
if (!st.isFile() || st.nlink !== 1) return false; // authoritative: regular file, single link
if (expectSrc && (st.dev !== expectSrc.dev || st.ino !== expectSrc.ino)) return false; // swapped in the window
const size = st.size;
data = Buffer.allocUnsafe(size);
let read = 0;
while (read < size) {
const n = fs.readSync(srcFd, data, read, size - read, read);
if (n === 0) break;
read += n;
}
if (read !== size) return false; // torn/truncated read (the source changed under us) → fail safe
// (2) fd-first dest temp: fresh inode (O_EXCL), 0600 AT creation, byte-copy, fsync, atomic rename.
destFd = fs.openSync(tmp, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY | NOFOLLOW, 0o600);
fs.fchmodSync(destFd, 0o600); // pin 0600 on the fd regardless of umask (no path chmod window)
let written = 0;
while (written < size) {
written += fs.writeSync(destFd, data, written, size - written);
}
fs.fsyncSync(destFd); // durability before the sole atomic publish
fs.closeSync(destFd);
destFd = -1;
fs.renameSync(tmp, dest); // atomic dir-entry swap; any old dest inode is unlinked, never written
return true;
} catch {
return false;
} finally {
if (srcFd >= 0) {
try {
fs.closeSync(srcFd);
} catch {
/* ignore */
}
}
if (destFd >= 0) {
try {
fs.closeSync(destFd);
} catch {
/* ignore */
}
}
if (data) data.fill(0); // scrub the secret bytes out of the transient JS buffer
try {
fs.rmSync(tmp, { force: true }); // remove any orphan temp (no-op after a successful rename)
} catch {
/* ignore */
}
}
}
function countProfiles(profileDir: string): number {
try {
return fs.readdirSync(profileDir).filter((n) => n.endsWith(".json") && isFile(path.join(profileDir, n))).length;
} catch {
return 0;
}
}
/** True iff the string contains a control character (<=0x1f or 0x7f), incl. tab/newline/CR which would
* corrupt a TSV row. Printable chars (spaces, hyphens, '@', '/') are allowed. Regex-free on purpose. */
function hasControlChar(v: string): boolean {
for (let i = 0; i < v.length; i++) {
const c = v.charCodeAt(i);
if (c <= 0x1f || c === 0x7f) return true;
}
return false;
}
export interface CodexAuthDeps {
/** Resolve the Codex login state using EXIT CODE ONLY — never the external command's stdout/stderr
* (any future codex version could print a token there). Default: spawn `codex login status` with
* stdio ignored and map the exit code. Injectable for hermetic tests. */
loginStatus?: (codexHome: string) => "logged_in" | "not_logged_in" | "unavailable";
}
/** Default login-state probe: exit code only, output never captured (stdio: ignore = no leak path). */
function defaultLoginStatus(codexHome: string): "logged_in" | "not_logged_in" | "unavailable" {
const r = spawnSync("codex", ["login", "status"], {
stdio: "ignore",
env: { ...process.env, CODEX_HOME: codexHome },
});
if (r.error) return "unavailable";
return r.status === 0 ? "logged_in" : "not_logged_in";
}
export interface AuthStatusResult {
codexHome: string;
codexHomePresent: boolean;
profileDirPresent: boolean;
activeAuthPresent: boolean;
activeAuthMode: string | null;
activeAuthModeSafe: boolean | "unknown";
profileCount: number;
loginStatus: "logged_in" | "not_logged_in" | "unavailable";
}
/** Report auth-file presence/mode/safety + profile count + login-state. NEVER reads token contents. */
export function authStatus(paths: CodexAuthPaths, deps: CodexAuthDeps = {}): AuthStatusResult {
const activeAuthPresent = isFile(paths.activeAuth);
const activeAuthMode = activeAuthPresent ? fileModeOctal(paths.activeAuth) : null;
return {
codexHome: paths.codexHome,
codexHomePresent: isDir(paths.codexHome),
profileDirPresent: isDir(paths.profileDir),
activeAuthPresent,
activeAuthMode,
activeAuthModeSafe: activeAuthMode === null ? "unknown" : activeAuthMode === "600",
profileCount: countProfiles(paths.profileDir),
loginStatus: (deps.loginStatus ?? defaultLoginStatus)(paths.codexHome),
};
}
export type AuthValidateResult =
| { ok: true; name: string; path: string; mode: string }
| {
ok: false;
reason: "invalid_profile" | "missing_profile" | "unsafe_path" | "unsafe_permissions" | "malformed_json" | "parse_check_unavailable";
};
/** Validate a saved profile: name whitelist -> safe regular file -> 0600 -> JSON parseable. The
* parse is in-memory and the result is discarded; on failure we return a FIXED reason and never the
* JSON.parse error (its message can include a snippet of the file = a secret). */
export function authValidate(paths: CodexAuthPaths, name: string): AuthValidateResult {
if (!validateProfileName(name)) return { ok: false, reason: "invalid_profile" };
const target = path.join(paths.profileDir, `${name}.json`);
if (lstatSafe(target) === null) return { ok: false, reason: "missing_profile" };
// Safe regular file (no symlink/hardlink) AND a profile dir that really resolves inside CODEX_HOME.
if (!isSafeProfileFile(target, paths.profileDir) || !realDirContained(paths.profileDir, paths.codexHome)) {
return { ok: false, reason: "unsafe_path" };
}
if (fileModeOctal(target) !== "600") return { ok: false, reason: "unsafe_permissions" };
let content: string;
try {
content = fs.readFileSync(target, "utf8");
} catch {
return { ok: false, reason: "parse_check_unavailable" };
}
try {
JSON.parse(content);
} catch {
// Intentionally ignore the error object — its message can echo file content (a secret).
return { ok: false, reason: "malformed_json" };
}
return { ok: true, name, path: target, mode: "600" };
}
export type AuthSaveResult =
| { ok: true; name: string; path: string; mode: string }
| { ok: false; reason: "invalid_profile" | "not_configured" | "unsafe_path" | "io_error" };
/** Snapshot the active auth file into a named profile (0600 file in the 0700 profile dir). Byte-copy
* via copyFileSync — contents never enter JS memory; result reports name/path/mode only. */
export function authSave(paths: CodexAuthPaths, name: string): AuthSaveResult {
if (!validateProfileName(name)) return { ok: false, reason: "invalid_profile" };
const srcStat = lstatSafe(paths.activeAuth);
if (srcStat === null || !srcStat.isFile()) return { ok: false, reason: "not_configured" };
const target = path.join(paths.profileDir, `${name}.json`);
// Refuse a symlinked profile-dir BEFORE mkdir/chmod so we never follow it out of CODEX_HOME or
// mutate the outside target's mode.
const pdStat = lstatSafe(paths.profileDir);
if (pdStat !== null && pdStat.isSymbolicLink()) return { ok: false, reason: "unsafe_path" };
try {
fs.mkdirSync(paths.profileDir, { recursive: true });
fs.chmodSync(paths.profileDir, 0o700);
} catch {
return { ok: false, reason: "io_error" };
}
// Parent must REALLY resolve inside CODEX_HOME (defeats a symlinked auth-profiles parent), and the
// existing target must not be a symlink/hardlink/non-regular file (defeats writing secret bytes
// through an inode that lives outside CODEX_HOME).
if (!realDirContained(paths.profileDir, paths.codexHome)) return { ok: false, reason: "unsafe_path" };
if (!destReplaceable(target)) return { ok: false, reason: "unsafe_path" };
// Thread the earlier lstat so copyOntoFresh can confirm the opened fd is still the same inode
// (dev/ino) it was checked as — a swap of the active auth in the check-then-use window fails safe.
if (!copyOntoFresh(paths.activeAuth, target, { dev: srcStat.dev, ino: srcStat.ino })) return { ok: false, reason: "io_error" };
return { ok: true, name, path: target, mode: "600" };
}
export type AuthSwitchResult =
| { ok: true; name: string; activePath: string; mode: string; note: string }
| { ok: false; reason: "invalid_profile" | "missing_profile" | "unsafe_path" | "unsafe_permissions" | "io_error" };
/** Activate a saved profile (copy it onto the active auth at 0600). Refuses to widen perms over an
* existing unsafe active file. Byte-copy; no content echoed. */
export function authSwitch(paths: CodexAuthPaths, name: string): AuthSwitchResult {
if (!validateProfileName(name)) return { ok: false, reason: "invalid_profile" };
const source = path.join(paths.profileDir, `${name}.json`);
const srcStat = lstatSafe(source);
if (srcStat === null) return { ok: false, reason: "missing_profile" };
if (!isSafeProfileFile(source, paths.profileDir)) return { ok: false, reason: "unsafe_path" };
// Destination guard: a symlink/non-regular OR hardlinked (nlink>1) active auth would let the copy
// write the selected profile's SECRET bytes THROUGH an inode living outside CODEX_HOME. Refuse it
// (isFile() is false for a symlink, so the old isFile-gated check silently missed both cases).
if (!destReplaceable(paths.activeAuth)) return { ok: false, reason: "unsafe_path" };
if (isFile(paths.activeAuth)) {
const m = fileModeOctal(paths.activeAuth);
if (m !== null && m !== "600") return { ok: false, reason: "unsafe_permissions" };
}
try {
fs.mkdirSync(paths.codexHome, { recursive: true });
} catch {
return { ok: false, reason: "io_error" };
}
if (!realDirContained(paths.codexHome, paths.codexHome)) return { ok: false, reason: "unsafe_path" };
// dev/ino continuity: the opened profile fd must still be the inode we checked (swap in the window fails safe).
if (!copyOntoFresh(source, paths.activeAuth, { dev: srcStat.dev, ino: srcStat.ino })) return { ok: false, reason: "io_error" };
return {
ok: true,
name,
activePath: paths.activeAuth,
mode: "600",
note: "live Codex sessions do not switch accounts in place; restart the affected seats to pick up the new profile.",
};
}
/** List saved profile names (safe regular *.json files in the profile dir), sorted. No contents read.
* Refuses to list through a symlinked / out-of-tree profile dir (returns []), so a redirected parent
* never exposes file names from outside CODEX_HOME. */
export function authList(paths: CodexAuthPaths): string[] {
if (!realDirContained(paths.profileDir, paths.codexHome)) return [];
try {
return fs
.readdirSync(paths.profileDir)
.filter((n) => n.endsWith(".json") && isSafeProfileFile(path.join(paths.profileDir, n), paths.profileDir))
.map((n) => n.slice(0, -".json".length))
.sort();
} catch {
return [];
}
}
// --- Auth-B: seat -> profile METADATA registry (product-native; NO resume_token per orch D2) ---
/** Stated in command output + docs: a seat label is metadata, never proof of a live account. */
export const SEAT_REGISTRY_DISCLAIMER =
"Seat labels are metadata only; they do NOT prove a running session is actually using that account/profile.";
// 6 columns; NO resume_token (a print surface is the worst place to hold a secret-class token).
const SEAT_COLUMNS = ["seat", "rig", "runtime", "cwd", "auth_profile", "updated_ts"] as const;
const SEAT_HEADER = SEAT_COLUMNS.join("\t");
const SEAT_TAB_COUNT = SEAT_COLUMNS.length - 1;
export interface SeatRow {
seat: string;
rig: string;
runtime: string;
cwd: string;
authProfile: string;
updatedTs: string;
}
export interface SeatSetFields {
seat: string;
rig: string;
runtime: string;
cwd?: string;
authProfile?: string;
}
function isSafeRegistryFile(p: string): boolean {
const st = lstatSafe(p);
return st !== null && !st.isSymbolicLink() && st.isFile();
}
/** Reject empty + any control character (covers tab/newline/CR, which would corrupt the TSV). */
function validRegistryField(v: string): boolean {
return v.length > 0 && !hasControlChar(v);
}
function rawRegistryLines(registryPath: string): string[] {
let text: string;
try {
text = fs.readFileSync(registryPath, "utf8");
} catch {
return [];
}
const out: string[] = [];
let first = true;
for (const line of text.split("\n")) {
if (line === "" || line.startsWith("#")) continue;
if (first) {
first = false; // skip header
continue;
}
out.push(line);
}
return out;
}
function rowMalformed(line: string): boolean {
const tabs = (line.match(/\t/g) ?? []).length;
if (tabs !== SEAT_TAB_COUNT) return true;
return line.split("\t")[0] === "";
}
function parseRow(line: string): SeatRow {
// Callers pre-filter via rowMalformed (guaranteed 6 fields); ?? "" satisfies strict indexing.
const f = line.split("\t");
return {
seat: f[0] ?? "",
rig: f[1] ?? "",
runtime: f[2] ?? "",
cwd: f[3] ?? "",
authProfile: f[4] ?? "",
updatedTs: f[5] ?? "",
};
}
export type SeatSetResult =
| { ok: true; seat: string; registryPath: string; mode: string; disclaimer: string }
| { ok: false; reason: "invalid_seat" | "invalid_rig" | "invalid_runtime" | "invalid_cwd" | "invalid_profile" | "unsafe_path" | "io_error" };
/** Atomic upsert of a seat metadata row (tmp + rename). Drops pre-existing malformed rows rather than
* re-emitting fabricated metadata. `now` is injectable for deterministic tests. */
export function authSeatSet(
paths: CodexAuthPaths,
fields: SeatSetFields,
now: () => string = () => new Date().toISOString(),
): SeatSetResult {
if (!validRegistryField(fields.seat)) return { ok: false, reason: "invalid_seat" };
if (!validRegistryField(fields.rig)) return { ok: false, reason: "invalid_rig" };
if (fields.runtime !== "codex") return { ok: false, reason: "invalid_runtime" }; // v0 whitelist
const cwd = fields.cwd && fields.cwd.length > 0 ? fields.cwd : "unknown";
if (!validRegistryField(cwd)) return { ok: false, reason: "invalid_cwd" };
const profile = fields.authProfile && fields.authProfile.length > 0 ? fields.authProfile : "unknown";
if (profile !== "unknown" && !validateProfileName(profile)) return { ok: false, reason: "invalid_profile" };
if (lstatSafe(paths.registryPath) !== null && !isSafeRegistryFile(paths.registryPath)) {
return { ok: false, reason: "unsafe_path" };
}
const kept = rawRegistryLines(paths.registryPath)
.filter((l) => !rowMalformed(l))
.filter((l) => l.split("\t")[0] !== fields.seat);
const newRow = [fields.seat, fields.rig, "codex", cwd, profile, now()].join("\t");
const body = [SEAT_HEADER, ...kept, newRow].join("\n") + "\n";
const tmp = `${paths.registryPath}.${process.pid}.tmp`;
try {
fs.mkdirSync(path.dirname(paths.registryPath), { recursive: true });
fs.writeFileSync(tmp, body, { mode: 0o600 });
fs.chmodSync(tmp, 0o600);
fs.renameSync(tmp, paths.registryPath);
fs.chmodSync(paths.registryPath, 0o600);
} catch {
try {
fs.rmSync(tmp, { force: true });
} catch {
/* ignore */
}
return { ok: false, reason: "io_error" };
}
return { ok: true, seat: fields.seat, registryPath: paths.registryPath, mode: "600", disclaimer: SEAT_REGISTRY_DISCLAIMER };
}
/** List well-formed seat rows (malformed rows skipped). */
export function authSeatsList(paths: CodexAuthPaths): SeatRow[] {
return rawRegistryLines(paths.registryPath)
.filter((l) => !rowMalformed(l))
.map(parseRow);
}
export type SeatShowResult = { ok: true; row: SeatRow } | { ok: false; reason: "invalid_seat" | "missing_seat" | "unsafe_path" };
export function authSeatShow(paths: CodexAuthPaths, seat: string): SeatShowResult {
if (!validRegistryField(seat)) return { ok: false, reason: "invalid_seat" };
if (lstatSafe(paths.registryPath) !== null && !isSafeRegistryFile(paths.registryPath)) {
return { ok: false, reason: "unsafe_path" };
}
const row = authSeatsList(paths).find((r) => r.seat === seat);
return row ? { ok: true, row } : { ok: false, reason: "missing_seat" };
}
export interface SeatsReport {
registryPresent: boolean;
registryMode: string | null;
registryModeSafe: boolean | "unknown";
total: number;
known: number;
unknown: number;
malformed: number;
}
export function authSeatsReport(paths: CodexAuthPaths): SeatsReport {
const present = isFile(paths.registryPath);
const mode = present ? fileModeOctal(paths.registryPath) : null;
let total = 0;
let known = 0;
let unknown = 0;
let malformed = 0;
for (const line of rawRegistryLines(paths.registryPath)) {
if (rowMalformed(line)) {
malformed += 1;
continue;
}
total += 1;
const profile = line.split("\t")[4];
if (!profile || profile === "unknown") unknown += 1;
else known += 1;
}
return {
registryPresent: present,
registryMode: mode,
registryModeSafe: mode === null ? "unknown" : mode === "600",
total,
known,
unknown,
malformed,
};
}
// The Codex login file operations live in the daemon, which uses them too, for `rig provider switch --seat`.
export * from "@openrig/daemon/codex-auth";
+37 -1
View File
@@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeAll, afterAll } from "vitest";
import http from "node:http";
import { providerCommand } from "../src/commands/provider.js";
import { DaemonClient } from "../src/client.js";
import { DaemonClient, DaemonTimeoutError } from "../src/client.js";
import { STATE_FILE, type LifecycleDeps, type DaemonState } from "../src/daemon-lifecycle.js";
import type { StatusDeps } from "../src/commands/status.js";
@@ -217,3 +217,39 @@ describe("rig provider CLI grammar (daemon-backed)", () => {
expect(logs.join(" ")).toMatch(/not running/i);
});
});
describe("rig provider switch --seat tells the person what follows", () => {
function switchDeps(post: (path: string, body: unknown, opts?: { timeoutMs?: number }) => Promise<unknown>): StatusDeps {
return { ...runningDeps(1), clientFactory: () => ({ post }) as never };
}
it("waits as long as a seat restart does, and prints each peer's home and the restart a same-home peer needs", async () => {
let timeoutMs: number | undefined;
const deps = switchDeps(async (_path, _body, opts) => {
timeoutMs = opts?.timeoutMs;
return { status: 200, data: { outcome: "rebind_in_progress", seat: { home: "/h", account: "work", loginChanged: true, undo: null, peers: [
{ logicalId: "dev.qa", sessionName: "dev-qa@rig", home: "same", effect: "Not restarted.", step: "rig seat restart dev-qa@rig --keep-conversation" },
{ logicalId: "dev.guard", sessionName: "dev-guard@rig", home: "different", effect: "Not restarted; uses a different Codex home." },
{ logicalId: "dev.pi", sessionName: null, home: "unknown", effect: "Not restarted; its Codex home could not be observed." },
] } } };
});
const { logs } = await run(["switch", "--seat", "s1", "--to-account", "work"], deps);
expect(timeoutMs).toBe(120_000);
expect(logs).toContain("peer on this home: dev-qa@rig: Not restarted.");
expect(logs).toContain(" then: rig seat restart dev-qa@rig --keep-conversation");
expect(logs).toContain("seat on another home: dev-guard@rig: Not restarted; uses a different Codex home.");
expect(logs).toContain("seat, home unknown: dev.pi: Not restarted; its Codex home could not be observed.");
});
it("a CLI timeout reports the switch as unknown, with how to check it before running it again", async () => {
const deps = switchDeps(async () => { throw new DaemonTimeoutError("timed out"); });
const { logs, exitCode } = await run(["switch", "--seat", "s1", "--to-account", "work"], deps);
expect(exitCode).toBe(1);
expect(logs[0]).toBe("unknown: switch_outcome_unknown");
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")).toContain("don't run the switch again");
});
});
+1
View File
@@ -8,6 +8,7 @@
"baseUrl": ".",
"paths": {
"@openrig/daemon/local-reading": ["../daemon/dist/local-reading-surface.d.ts"],
"@openrig/daemon/codex-auth": ["../daemon/dist/codex-auth-surface.d.ts"],
"@openrig/daemon/crash-cart": ["../daemon/dist/crash-cart-surface.d.ts"],
"@openrig/daemon/gateway-human-registry": ["../daemon/dist/gateway-human-registry-surface.d.ts"],
"@openrig/daemon/spec-conformance": [
@@ -1016,6 +1016,26 @@ in `CODEX_HOME` (default `~/.codex`). A seat that names its own home in `env:` r
Seats may share a home. OpenRig writes its own settings (Codex hooks, managed `config.toml` and plugin; Claude's
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.
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.
2. **Check:** `rig provider precheck --seat <seat> --to-account <name>` shows the home, what the seat is doing, and every
peer on that home. A refusal says what's missing, and nothing changes.
3. **Move:** `rig provider switch --seat <seat> --to-account <name>`. It saves the home's current login as
`before-switch-<time>`, activates the chosen one, and restarts the seat in place on the same conversation. If the
current login can't be saved first, nothing changes. Peers on the home aren't restarted. Each keeps its current login
only until its next token refresh, when Codex stops it with "signed in to another account". Run the printed
`rig seat restart <peer> --keep-conversation --reason "..."` for each to move it too. Don't sign in again in that home: that replaces
the login you just switched to.
4. **Confirm:** after the seat completes a real tool-using turn, run the printed
`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.
### 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
@@ -1041,7 +1061,8 @@ and there's no threshold to obey.
- **The Observatory is optional:** it can show readings, but you don't need it to rotate.
**The unit is a Codex home, with every seat on it.** Moving some seats on a shared home to a different account, and
moving Claude seats, aren't supported moves yet: say so instead of improvising.
moving Claude seats, aren't supported moves yet: say so instead of improvising. A Codex seat with a home of its own
moves alone, with `rig provider switch --seat` (above).
0. **Load the person's account policy.**
- It's the context pack named `account-policy`: check `rig context list` for it, then read it with
+4
View File
@@ -82,6 +82,10 @@
"./local-reading": {
"types": "./dist/local-reading-surface.d.ts",
"import": "./dist/local-reading-surface.js"
},
"./codex-auth": {
"types": "./dist/codex-auth-surface.d.ts",
"import": "./dist/codex-auth-surface.js"
}
},
"scripts": {
@@ -0,0 +1 @@
export * from "./domain/codex-auth.js";
+568
View File
@@ -0,0 +1,568 @@
// OPR.0.4.1.29 — rig auth secret-safe core.
//
// This module operates directly on the operator's Codex auth files in a Codex home. The `rig auth` CLI command uses
// it, and the daemon uses its list, save and switch for `rig provider switch --seat`, on the home the seat runs on.
// A token never reaches a returned value (below), so none can enter the daemon queue / SSE stream / SQLite / event log.
//
// SECRET INVARIANTS (non-negotiable): no auth/refresh/access token value is ever returned in, or used
// to build, any human-facing string. Functions return STRUCTURED, non-secret result objects
// (presence / mode / parseability / login-state / names / counts); the auth FILE is snapshotted as a
// file (mode-guarded byte copy), never read into a printed value. CODEX_HOME defaults to $HOME/.codex
// and is env-overridable (tests point it at a fixture); no personal/operator path is baked in.
import path from "node:path";
import { userInfo } from "node:os";
/** The account's home from the OS account database. Unlike os.homedir() this does not echo
* an empty $HOME back, so a HOME="" process still lands under the real home. Empty string
* when the OS reports no home at all: the caller keeps the historical cwd-relative
* ".codex" rather than erroring. */
function accountHome(): string {
try { return userInfo().homedir ?? ""; } catch { return ""; }
}
import fs from "node:fs";
import { spawnSync } from "node:child_process";
/** Strict profile-name whitelist: alnum-led, then [A-Za-z0-9._-], 1..64 chars. Excludes /, \, ~,
* leading dot, whitespace, control, and shell metacharacters. Fail closed. */
const PROFILE_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
export function validateProfileName(name: unknown): name is string {
return typeof name === "string" && name.length > 0 && name.length <= 64 && PROFILE_NAME_RE.test(name);
}
export interface CodexAuthPaths {
/** Codex state dir — CODEX_HOME or $HOME/.codex. */
codexHome: string;
/** Saved-profile dir (0700). */
profileDir: string;
/** Active auth file the Codex CLI reads (0600). */
activeAuth: string;
/** Product-native seat->profile metadata registry (0600). */
registryPath: string;
}
/** Resolve the Codex paths from an env map (default process.env). CODEX_HOME wins; else $HOME/.codex. */
export function resolveCodexHome(env: NodeJS.ProcessEnv = process.env): CodexAuthPaths {
const home = typeof env.HOME === "string" && env.HOME.length > 0 ? env.HOME : accountHome();
const codexHome =
typeof env.CODEX_HOME === "string" && env.CODEX_HOME.length > 0
? env.CODEX_HOME
// HOME unset/empty (cron, systemd, some CI/container shells) must not yield a
// cwd-relative ".codex" — auth files stay under the account's home.
: path.join(home, ".codex");
return {
codexHome,
profileDir: path.join(codexHome, "auth-profiles"),
activeAuth: path.join(codexHome, "auth.json"),
registryPath: path.join(codexHome, "auth-seat-registry.tsv"),
};
}
// --- secret-safe fs helpers (no contents ever read into a returned/printed value) ---
function lstatSafe(p: string): fs.Stats | null {
try {
return fs.lstatSync(p);
} catch {
return null;
}
}
function isDir(p: string): boolean {
const st = lstatSafe(p);
return st !== null && st.isDirectory();
}
function isFile(p: string): boolean {
const st = lstatSafe(p);
return st !== null && st.isFile();
}
/** Octal permission string (e.g. "600") or null when the path is absent/unstatable. */
function fileModeOctal(p: string): string | null {
const st = lstatSafe(p);
if (st === null) return null;
return (st.mode & 0o777).toString(8).padStart(3, "0");
}
/** A profile file is safe iff it exists, is a regular file (not a symlink — which could redirect us
* out of the profile dir), has link count 1 (a hardlink shares an inode that may live outside the
* profile dir), and lives directly in profileDir. */
function isSafeProfileFile(p: string, profileDir: string): boolean {
const st = lstatSafe(p);
if (st === null || st.isSymbolicLink() || !st.isFile() || st.nlink !== 1) return false;
return path.dirname(p) === profileDir;
}
/** True iff `dir` really resolves INSIDE realpath(codexHome). realpath follows every symlink in the
* chain, so a symlinked parent (e.g. an auth-profiles dir pointing outside) resolves out and is
* rejected — a lexical startsWith would be fooled. Returns false if either path can't be resolved. */
function realDirContained(dir: string, codexHome: string): boolean {
try {
const root = fs.realpathSync(codexHome);
const resolved = fs.realpathSync(dir);
return resolved === root || resolved.startsWith(root + path.sep);
} catch {
return false;
}
}
/** A destination we are about to write SECRET bytes onto is safe to replace iff it is absent, or a
* regular file (not a symlink) with link count 1. A symlink redirects the write; a hardlink (nlink>1)
* shares an inode that may live outside CODEX_HOME — writing through either leaks secret bytes out. */
function destReplaceable(p: string): boolean {
const st = lstatSafe(p);
if (st === null) return true;
return !st.isSymbolicLink() && st.isFile() && st.nlink === 1;
}
/** fd-first copy of `source` onto `dest` (OPR.0.4.3.23 secret-boundary B1 hardening). Closes the
* check-then-use gap that a path-based copy leaves open: an earlier lstat guard, then a copy that
* re-resolves the SOURCE PATH, can be redirected by an inode swap in the window (a crash, or a
* concurrent legitimate rig on the same auth home) — reading the wrong inode, a torn file, or briefly
* leaving a wider-than-0600 temp (CERT FIO45-C). Path-based pre-checks cannot close that race, so the
* authoritative check moves onto the OPENED fd:
* (1) SOURCE: openSync with O_NOFOLLOW (a final-component symlink swap fails closed), then fstat ON
* THE FD and validate the opened inode (regular file, nlink === 1 — a hardlink shares an inode
* that may live outside the boundary), and — when the caller passes its earlier lstat — confirm
* dev/ino still match (the inode was not swapped in the window). Read bytes FROM the fd; the
* source path is never re-resolved after the open.
* (2) DEST: openSync the temp with O_CREAT|O_EXCL (fresh inode; never a pre-existing hardlink/symlink)
* at 0600 AT creation (removes the create-then-chmod window), fchmod on the fd to pin 0600
* regardless of umask, write the source bytes, fsync, then renameSync as the SOLE atomic publish
* (a crash BEFORE the rename leaves the prior dest byte-intact — a strength we preserve).
* The temp stays co-located in dest's dir so the rename never crosses devices. The transient byte
* buffer is the only time secret bytes touch JS memory; it is never printed and is scrubbed in
* `finally`. Caller must still have verified dest's parent containment and that dest is replaceable
* (the lstat pre-checks stay as cheap fail-fast; this fd validation is the authoritative layer).
* Exported for the fd-first / crash-safety leak-hunt tests. */
export function copyOntoFresh(
source: string,
dest: string,
expectSrc?: { dev: number; ino: number },
): boolean {
const tmp = `${dest}.tmp-${process.pid}`;
// O_NOFOLLOW fails the open closed if a final-component symlink was swapped in. (O_CLOEXEC is omitted:
// it is undefined on macOS Node and absent from @types/node, and no child process is spawned during
// this fd's synchronous lifetime, so close-on-exec would be a no-op here.)
const NOFOLLOW = fs.constants.O_NOFOLLOW ?? 0;
let srcFd = -1;
let destFd = -1;
let data: Buffer | null = null;
try {
try {
fs.rmSync(tmp, { force: true }); // clear any stale temp so the O_EXCL create below succeeds
} catch {
/* ignore */
}
// (1) fd-first source read: open, then validate ON THE FD — never re-resolve the source path.
srcFd = fs.openSync(source, fs.constants.O_RDONLY | NOFOLLOW);
const st = fs.fstatSync(srcFd);
if (!st.isFile() || st.nlink !== 1) return false; // authoritative: regular file, single link
if (expectSrc && (st.dev !== expectSrc.dev || st.ino !== expectSrc.ino)) return false; // swapped in the window
const size = st.size;
data = Buffer.allocUnsafe(size);
let read = 0;
while (read < size) {
const n = fs.readSync(srcFd, data, read, size - read, read);
if (n === 0) break;
read += n;
}
if (read !== size) return false; // torn/truncated read (the source changed under us) → fail safe
// (2) fd-first dest temp: fresh inode (O_EXCL), 0600 AT creation, byte-copy, fsync, atomic rename.
destFd = fs.openSync(tmp, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY | NOFOLLOW, 0o600);
fs.fchmodSync(destFd, 0o600); // pin 0600 on the fd regardless of umask (no path chmod window)
let written = 0;
while (written < size) {
written += fs.writeSync(destFd, data, written, size - written);
}
fs.fsyncSync(destFd); // durability before the sole atomic publish
fs.closeSync(destFd);
destFd = -1;
fs.renameSync(tmp, dest); // atomic dir-entry swap; any old dest inode is unlinked, never written
return true;
} catch {
return false;
} finally {
if (srcFd >= 0) {
try {
fs.closeSync(srcFd);
} catch {
/* ignore */
}
}
if (destFd >= 0) {
try {
fs.closeSync(destFd);
} catch {
/* ignore */
}
}
if (data) data.fill(0); // scrub the secret bytes out of the transient JS buffer
try {
fs.rmSync(tmp, { force: true }); // remove any orphan temp (no-op after a successful rename)
} catch {
/* ignore */
}
}
}
function countProfiles(profileDir: string): number {
try {
return fs.readdirSync(profileDir).filter((n) => n.endsWith(".json") && isFile(path.join(profileDir, n))).length;
} catch {
return 0;
}
}
/** True iff the string contains a control character (<=0x1f or 0x7f), incl. tab/newline/CR which would
* corrupt a TSV row. Printable chars (spaces, hyphens, '@', '/') are allowed. Regex-free on purpose. */
function hasControlChar(v: string): boolean {
for (let i = 0; i < v.length; i++) {
const c = v.charCodeAt(i);
if (c <= 0x1f || c === 0x7f) return true;
}
return false;
}
export interface CodexAuthDeps {
/** Resolve the Codex login state using EXIT CODE ONLY — never the external command's stdout/stderr
* (any future codex version could print a token there). Default: spawn `codex login status` with
* stdio ignored and map the exit code. Injectable for hermetic tests. */
loginStatus?: (codexHome: string) => "logged_in" | "not_logged_in" | "unavailable";
}
/** Default login-state probe: exit code only, output never captured (stdio: ignore = no leak path). */
function defaultLoginStatus(codexHome: string): "logged_in" | "not_logged_in" | "unavailable" {
const r = spawnSync("codex", ["login", "status"], {
stdio: "ignore",
env: { ...process.env, CODEX_HOME: codexHome },
});
if (r.error) return "unavailable";
return r.status === 0 ? "logged_in" : "not_logged_in";
}
export interface AuthStatusResult {
codexHome: string;
codexHomePresent: boolean;
profileDirPresent: boolean;
activeAuthPresent: boolean;
activeAuthMode: string | null;
activeAuthModeSafe: boolean | "unknown";
profileCount: number;
loginStatus: "logged_in" | "not_logged_in" | "unavailable";
}
/** Report auth-file presence/mode/safety + profile count + login-state. NEVER reads token contents. */
export function authStatus(paths: CodexAuthPaths, deps: CodexAuthDeps = {}): AuthStatusResult {
const activeAuthPresent = isFile(paths.activeAuth);
const activeAuthMode = activeAuthPresent ? fileModeOctal(paths.activeAuth) : null;
return {
codexHome: paths.codexHome,
codexHomePresent: isDir(paths.codexHome),
profileDirPresent: isDir(paths.profileDir),
activeAuthPresent,
activeAuthMode,
activeAuthModeSafe: activeAuthMode === null ? "unknown" : activeAuthMode === "600",
profileCount: countProfiles(paths.profileDir),
loginStatus: (deps.loginStatus ?? defaultLoginStatus)(paths.codexHome),
};
}
export type AuthValidateResult =
| { ok: true; name: string; path: string; mode: string }
| {
ok: false;
reason: "invalid_profile" | "missing_profile" | "unsafe_path" | "unsafe_permissions" | "malformed_json" | "parse_check_unavailable";
};
/** Validate a saved profile: name whitelist -> safe regular file -> 0600 -> JSON parseable. The
* parse is in-memory and the result is discarded; on failure we return a FIXED reason and never the
* JSON.parse error (its message can include a snippet of the file = a secret). */
export function authValidate(paths: CodexAuthPaths, name: string): AuthValidateResult {
if (!validateProfileName(name)) return { ok: false, reason: "invalid_profile" };
const target = path.join(paths.profileDir, `${name}.json`);
if (lstatSafe(target) === null) return { ok: false, reason: "missing_profile" };
// Safe regular file (no symlink/hardlink) AND a profile dir that really resolves inside CODEX_HOME.
if (!isSafeProfileFile(target, paths.profileDir) || !realDirContained(paths.profileDir, paths.codexHome)) {
return { ok: false, reason: "unsafe_path" };
}
if (fileModeOctal(target) !== "600") return { ok: false, reason: "unsafe_permissions" };
let content: string;
try {
content = fs.readFileSync(target, "utf8");
} catch {
return { ok: false, reason: "parse_check_unavailable" };
}
try {
JSON.parse(content);
} catch {
// Intentionally ignore the error object — its message can echo file content (a secret).
return { ok: false, reason: "malformed_json" };
}
return { ok: true, name, path: target, mode: "600" };
}
export type AuthSaveResult =
| { ok: true; name: string; path: string; mode: string }
| { ok: false; reason: "invalid_profile" | "not_configured" | "unsafe_path" | "io_error" };
/** Snapshot the active auth file into a named profile (0600 file in the 0700 profile dir). Byte-copy
* via copyFileSync — contents never enter JS memory; result reports name/path/mode only. */
export function authSave(paths: CodexAuthPaths, name: string): AuthSaveResult {
if (!validateProfileName(name)) return { ok: false, reason: "invalid_profile" };
const srcStat = lstatSafe(paths.activeAuth);
if (srcStat === null || !srcStat.isFile()) return { ok: false, reason: "not_configured" };
const target = path.join(paths.profileDir, `${name}.json`);
// Refuse a symlinked profile-dir BEFORE mkdir/chmod so we never follow it out of CODEX_HOME or
// mutate the outside target's mode.
const pdStat = lstatSafe(paths.profileDir);
if (pdStat !== null && pdStat.isSymbolicLink()) return { ok: false, reason: "unsafe_path" };
try {
fs.mkdirSync(paths.profileDir, { recursive: true });
fs.chmodSync(paths.profileDir, 0o700);
} catch {
return { ok: false, reason: "io_error" };
}
// Parent must REALLY resolve inside CODEX_HOME (defeats a symlinked auth-profiles parent), and the
// existing target must not be a symlink/hardlink/non-regular file (defeats writing secret bytes
// through an inode that lives outside CODEX_HOME).
if (!realDirContained(paths.profileDir, paths.codexHome)) return { ok: false, reason: "unsafe_path" };
if (!destReplaceable(target)) return { ok: false, reason: "unsafe_path" };
// Thread the earlier lstat so copyOntoFresh can confirm the opened fd is still the same inode
// (dev/ino) it was checked as — a swap of the active auth in the check-then-use window fails safe.
if (!copyOntoFresh(paths.activeAuth, target, { dev: srcStat.dev, ino: srcStat.ino })) return { ok: false, reason: "io_error" };
return { ok: true, name, path: target, mode: "600" };
}
export type AuthSwitchResult =
| { ok: true; name: string; activePath: string; mode: string; note: string }
| { ok: false; reason: "invalid_profile" | "missing_profile" | "unsafe_path" | "unsafe_permissions" | "io_error" };
/** Activate a saved profile (copy it onto the active auth at 0600). Refuses to widen perms over an
* existing unsafe active file. Byte-copy; no content echoed. */
export function authSwitch(paths: CodexAuthPaths, name: string): AuthSwitchResult {
if (!validateProfileName(name)) return { ok: false, reason: "invalid_profile" };
const source = path.join(paths.profileDir, `${name}.json`);
const srcStat = lstatSafe(source);
if (srcStat === null) return { ok: false, reason: "missing_profile" };
if (!isSafeProfileFile(source, paths.profileDir)) return { ok: false, reason: "unsafe_path" };
// Destination guard: a symlink/non-regular OR hardlinked (nlink>1) active auth would let the copy
// write the selected profile's SECRET bytes THROUGH an inode living outside CODEX_HOME. Refuse it
// (isFile() is false for a symlink, so the old isFile-gated check silently missed both cases).
if (!destReplaceable(paths.activeAuth)) return { ok: false, reason: "unsafe_path" };
if (isFile(paths.activeAuth)) {
const m = fileModeOctal(paths.activeAuth);
if (m !== null && m !== "600") return { ok: false, reason: "unsafe_permissions" };
}
try {
fs.mkdirSync(paths.codexHome, { recursive: true });
} catch {
return { ok: false, reason: "io_error" };
}
if (!realDirContained(paths.codexHome, paths.codexHome)) return { ok: false, reason: "unsafe_path" };
// dev/ino continuity: the opened profile fd must still be the inode we checked (swap in the window fails safe).
if (!copyOntoFresh(source, paths.activeAuth, { dev: srcStat.dev, ino: srcStat.ino })) return { ok: false, reason: "io_error" };
return {
ok: true,
name,
activePath: paths.activeAuth,
mode: "600",
note: "live Codex sessions do not switch accounts in place; restart the affected seats to pick up the new profile.",
};
}
/** List saved profile names (safe regular *.json files in the profile dir), sorted. No contents read.
* Refuses to list through a symlinked / out-of-tree profile dir (returns []), so a redirected parent
* never exposes file names from outside CODEX_HOME. */
export function authList(paths: CodexAuthPaths): string[] {
if (!realDirContained(paths.profileDir, paths.codexHome)) return [];
try {
return fs
.readdirSync(paths.profileDir)
.filter((n) => n.endsWith(".json") && isSafeProfileFile(path.join(paths.profileDir, n), paths.profileDir))
.map((n) => n.slice(0, -".json".length))
.sort();
} catch {
return [];
}
}
// --- Auth-B: seat -> profile METADATA registry (product-native; NO resume_token per orch D2) ---
/** Stated in command output + docs: a seat label is metadata, never proof of a live account. */
export const SEAT_REGISTRY_DISCLAIMER =
"Seat labels are metadata only; they do NOT prove a running session is actually using that account/profile.";
// 6 columns; NO resume_token (a print surface is the worst place to hold a secret-class token).
const SEAT_COLUMNS = ["seat", "rig", "runtime", "cwd", "auth_profile", "updated_ts"] as const;
const SEAT_HEADER = SEAT_COLUMNS.join("\t");
const SEAT_TAB_COUNT = SEAT_COLUMNS.length - 1;
export interface SeatRow {
seat: string;
rig: string;
runtime: string;
cwd: string;
authProfile: string;
updatedTs: string;
}
export interface SeatSetFields {
seat: string;
rig: string;
runtime: string;
cwd?: string;
authProfile?: string;
}
function isSafeRegistryFile(p: string): boolean {
const st = lstatSafe(p);
return st !== null && !st.isSymbolicLink() && st.isFile();
}
/** Reject empty + any control character (covers tab/newline/CR, which would corrupt the TSV). */
function validRegistryField(v: string): boolean {
return v.length > 0 && !hasControlChar(v);
}
function rawRegistryLines(registryPath: string): string[] {
let text: string;
try {
text = fs.readFileSync(registryPath, "utf8");
} catch {
return [];
}
const out: string[] = [];
let first = true;
for (const line of text.split("\n")) {
if (line === "" || line.startsWith("#")) continue;
if (first) {
first = false; // skip header
continue;
}
out.push(line);
}
return out;
}
function rowMalformed(line: string): boolean {
const tabs = (line.match(/\t/g) ?? []).length;
if (tabs !== SEAT_TAB_COUNT) return true;
return line.split("\t")[0] === "";
}
function parseRow(line: string): SeatRow {
// Callers pre-filter via rowMalformed (guaranteed 6 fields); ?? "" satisfies strict indexing.
const f = line.split("\t");
return {
seat: f[0] ?? "",
rig: f[1] ?? "",
runtime: f[2] ?? "",
cwd: f[3] ?? "",
authProfile: f[4] ?? "",
updatedTs: f[5] ?? "",
};
}
export type SeatSetResult =
| { ok: true; seat: string; registryPath: string; mode: string; disclaimer: string }
| { ok: false; reason: "invalid_seat" | "invalid_rig" | "invalid_runtime" | "invalid_cwd" | "invalid_profile" | "unsafe_path" | "io_error" };
/** Atomic upsert of a seat metadata row (tmp + rename). Drops pre-existing malformed rows rather than
* re-emitting fabricated metadata. `now` is injectable for deterministic tests. */
export function authSeatSet(
paths: CodexAuthPaths,
fields: SeatSetFields,
now: () => string = () => new Date().toISOString(),
): SeatSetResult {
if (!validRegistryField(fields.seat)) return { ok: false, reason: "invalid_seat" };
if (!validRegistryField(fields.rig)) return { ok: false, reason: "invalid_rig" };
if (fields.runtime !== "codex") return { ok: false, reason: "invalid_runtime" }; // v0 whitelist
const cwd = fields.cwd && fields.cwd.length > 0 ? fields.cwd : "unknown";
if (!validRegistryField(cwd)) return { ok: false, reason: "invalid_cwd" };
const profile = fields.authProfile && fields.authProfile.length > 0 ? fields.authProfile : "unknown";
if (profile !== "unknown" && !validateProfileName(profile)) return { ok: false, reason: "invalid_profile" };
if (lstatSafe(paths.registryPath) !== null && !isSafeRegistryFile(paths.registryPath)) {
return { ok: false, reason: "unsafe_path" };
}
const kept = rawRegistryLines(paths.registryPath)
.filter((l) => !rowMalformed(l))
.filter((l) => l.split("\t")[0] !== fields.seat);
const newRow = [fields.seat, fields.rig, "codex", cwd, profile, now()].join("\t");
const body = [SEAT_HEADER, ...kept, newRow].join("\n") + "\n";
const tmp = `${paths.registryPath}.${process.pid}.tmp`;
try {
fs.mkdirSync(path.dirname(paths.registryPath), { recursive: true });
fs.writeFileSync(tmp, body, { mode: 0o600 });
fs.chmodSync(tmp, 0o600);
fs.renameSync(tmp, paths.registryPath);
fs.chmodSync(paths.registryPath, 0o600);
} catch {
try {
fs.rmSync(tmp, { force: true });
} catch {
/* ignore */
}
return { ok: false, reason: "io_error" };
}
return { ok: true, seat: fields.seat, registryPath: paths.registryPath, mode: "600", disclaimer: SEAT_REGISTRY_DISCLAIMER };
}
/** List well-formed seat rows (malformed rows skipped). */
export function authSeatsList(paths: CodexAuthPaths): SeatRow[] {
return rawRegistryLines(paths.registryPath)
.filter((l) => !rowMalformed(l))
.map(parseRow);
}
export type SeatShowResult = { ok: true; row: SeatRow } | { ok: false; reason: "invalid_seat" | "missing_seat" | "unsafe_path" };
export function authSeatShow(paths: CodexAuthPaths, seat: string): SeatShowResult {
if (!validRegistryField(seat)) return { ok: false, reason: "invalid_seat" };
if (lstatSafe(paths.registryPath) !== null && !isSafeRegistryFile(paths.registryPath)) {
return { ok: false, reason: "unsafe_path" };
}
const row = authSeatsList(paths).find((r) => r.seat === seat);
return row ? { ok: true, row } : { ok: false, reason: "missing_seat" };
}
export interface SeatsReport {
registryPresent: boolean;
registryMode: string | null;
registryModeSafe: boolean | "unknown";
total: number;
known: number;
unknown: number;
malformed: number;
}
export function authSeatsReport(paths: CodexAuthPaths): SeatsReport {
const present = isFile(paths.registryPath);
const mode = present ? fileModeOctal(paths.registryPath) : null;
let total = 0;
let known = 0;
let unknown = 0;
let malformed = 0;
for (const line of rawRegistryLines(paths.registryPath)) {
if (rowMalformed(line)) {
malformed += 1;
continue;
}
total += 1;
const profile = line.split("\t")[4];
if (!profile || profile === "unknown") unknown += 1;
else known += 1;
}
return {
registryPresent: present,
registryMode: mode,
registryModeSafe: mode === null ? "unknown" : mode === "600",
total,
known,
unknown,
malformed,
};
}
@@ -4,6 +4,7 @@
// is precheck-gated and NEVER fabricates success — the switch-execution + BR-1 action-record path
// is the D seam; until it lands, switch returns an honest failed_safely with an operational reason.
import fs from "node:fs";
import nodePath from "node:path";
import os from "node:os";
import type Database from "better-sqlite3";
@@ -26,8 +27,65 @@ import type {
ProviderPrecheckInput,
ProviderSwitchInput,
ProviderSwitchResult,
SeatRestart,
SeatSwitchReport,
} from "./provider-service.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";
/** The Codex logins saved in a home (`rig auth`'s file operations, which never return a token). */
export interface CodexHomeLogins {
list(home: string): string[];
/** Saves the home's active login under a new name; never replaces a saved login. */
save(home: string, name: string): { ok: true; name: string } | { ok: false; reason: string };
activate(home: string, name: string): { ok: true } | { ok: false; reason: string };
}
type SeatCheck =
| { ok: false; verdict: Extract<PrecheckResult, { safe: false }> }
| { ok: true; verdict: Extract<PrecheckResult, { safe: true }>; home: string; peers: PeerReport[] };
const codexHomeLogins: CodexHomeLogins = {
list: (home) => authList(resolveCodexHome({ CODEX_HOME: home })),
save: (home, name) => {
const paths = resolveCodexHome({ CODEX_HOME: home });
// An earlier switch's recovery copy stays its own, so its receipt's undo restores the bytes it saved.
try {
if (fs.lstatSync(nodePath.join(paths.profileDir, `${name}.json`), { throwIfNoEntry: false })) return { ok: false, reason: "name_taken" };
} catch {
return { ok: false, reason: "io_error" };
}
return authSave(paths, name);
},
activate: (home, name) => authSwitch(resolveCodexHome({ CODEX_HOME: home }), name),
};
/** One seat switch at a time per Codex home in this daemon: a switch's save, activation and restart finish before the
* next switch on that home starts, so each restart loads the login its receipt names. */
const switchesByHome = new Map<string, Promise<unknown>>();
function oneSwitchPerHome<T>(home: string, run: (waited: boolean) => Promise<T>): Promise<T> {
let key = home;
try { key = fs.realpathSync(home); } catch { /* an unresolvable home is keyed by its path */ }
const ahead = switchesByHome.get(key);
const next = (ahead ?? Promise.resolve()).then(() => run(ahead !== undefined));
const settled = next.catch(() => undefined);
switchesByHome.set(key, settled);
void settled.then(() => { if (switchesByHome.get(key) === settled) switchesByHome.delete(key); });
return next;
}
/** What a seat restarted after a switch records as its reason; a login name has no quotes or spaces. */
function movedReason(account: string): string {
return `--reason "its Codex home moved to ${account}"`;
}
/** A peer on the same home keeps its current login only until its next token refresh, then stops until it's restarted. */
function withRestartSteps(peers: PeerReport[], account: string): SeatSwitchReport["peers"] {
return peers.map((peer) => peer.home === "same" && peer.sessionName
? { ...peer, step: `rig seat restart ${peer.sessionName} --keep-conversation ${movedReason(account)}` } : peer);
}
export interface ProviderServiceImplDeps {
db: Database.Database;
@@ -43,6 +101,8 @@ export interface ProviderServiceImplDeps {
now?: () => string;
/** Slice 01: Codex usage windows, served only while `enabled()` (provider.usage_windows) is true. */
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;
}
export class ProviderServiceImpl implements ProviderService {
@@ -141,7 +201,8 @@ export class ProviderServiceImpl implements ProviderService {
return seats;
}
async precheck(input: ProviderPrecheckInput): Promise<PrecheckResult> {
async precheck(input: ProviderPrecheckInput, seatRestart?: SeatRestart): Promise<PrecheckResult> {
if (seatRestart) return (await this.seatPrecheck(input, seatRestart)).verdict;
const model = await this.getReadModel();
const target = model.accounts.find((a) => a.accountId === input.toAccount);
// Validate-at-use: an unknown target OR unknown auth fails closed (BR-3 — disk presence never
@@ -155,7 +216,8 @@ export class ProviderServiceImpl implements ProviderService {
});
}
async switchAccount(input: ProviderSwitchInput): Promise<ProviderSwitchResult> {
async switchAccount(input: ProviderSwitchInput, seatRestart?: SeatRestart): Promise<ProviderSwitchResult> {
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 });
if (verdict.safe === false && !input.forceUnsafe) {
@@ -166,4 +228,86 @@ export class ProviderServiceImpl implements ProviderService {
// Honest operational failure — never fabricate a succeeded/rebind_in_progress outcome.
return { outcome: "failed_safely", reasons: ["switch_execution_not_yet_wired"] };
}
/** A seat's precheck reads the seat: its in-place restart plan (every gate, nothing stopped) names the Codex home the
* running seat uses, and the login must be saved there. Every saved login's sign-in state reads unknown, so an
* authorized move isn't refused for that; the restart's uptake evidence settles it. */
private async seatPrecheck(input: ProviderPrecheckInput, seatRestart: SeatRestart): Promise<SeatCheck> {
const plan = await seatRestart.plan(input.seat);
if (!plan.ok) {
return { ok: false, verdict: { safe: false, reasons: [plan.code === "not_codex" ? "rebind_unsupported_for_runtime" : "seat_restart_refused"],
message: `${plan.code}: ${plan.message}` } };
}
const seat = { home: plan.codexHome, activity: plan.activity.state, peers: plan.peers };
if (!this.codexLogins().list(plan.codexHome).includes(input.toAccount)) {
return { ok: false, verdict: { safe: false, reasons: ["target_not_in_seat_home"], seat,
message: `No login named "${input.toAccount}" is saved in ${plan.codexHome}, the Codex home this seat runs on. List them with: rig auth list --codex-home ${plan.codexHome}` } };
}
return { ok: true, verdict: { safe: true, seat }, home: plan.codexHome, peers: plan.peers };
}
/** Move one seat: keep the home's outgoing login, activate the chosen one, restart the seat in place (same conversation,
* pane and launch settings), and report. A seat refusal can't be forced: there'd be nothing safe to restart, or no
* such login. Nothing changes unless the outgoing login was saved first (or the home had none). Never retried: a
* restart whose result isn't confirmed is reported with its recovery command. */
private async switchSeat(input: ProviderSwitchInput, seatRestart: SeatRestart): Promise<ProviderSwitchResult> {
const checked = await this.seatPrecheck(input, seatRestart);
if (!checked.ok) {
const { reasons, message } = checked.verdict;
return { outcome: "failed_safely", reasons: reasons as [string, ...string[]], ...(message ? { message } : {}) };
}
return oneSwitchPerHome(checked.home, async (waited): Promise<ProviderSwitchResult> => {
if (!waited) return this.switchOnHome(input, seatRestart, checked.home, checked.peers);
// It waited behind another switch on this home, so the seat may have changed meanwhile: check it again first.
const now = await this.seatPrecheck(input, seatRestart);
const nowHome = now.ok ? now.home : now.verdict.seat?.home;
if (nowHome !== undefined && nowHome !== checked.home) {
return { outcome: "failed_safely", reasons: ["seat_home_changed"],
message: `${input.seat} now runs on ${nowHome}, not ${checked.home}, the home this switch waited on. Nothing changed; run the switch again for its current home.` };
}
if (!now.ok) {
const { reasons, message } = now.verdict;
return { outcome: "failed_safely", reasons: reasons as [string, ...string[]], ...(message ? { message } : {}) };
}
return this.switchOnHome(input, seatRestart, now.home, now.peers);
});
}
private async switchOnHome(input: ProviderSwitchInput, seatRestart: SeatRestart, home: string, peers: PeerReport[]): Promise<ProviderSwitchResult> {
const logins = this.codexLogins();
const stamp = (this.deps.now ?? (() => new Date().toISOString()))().replace(/[-:]/g, "");
const saved = logins.save(home, `before-switch-${stamp}`);
if (!saved.ok && saved.reason !== "not_configured") {
return { outcome: "failed_safely", reasons: ["outgoing_login_not_saved"],
message: `The login active in ${home} couldn't be saved first (${saved.reason}), so nothing changed: no login was activated and the seat wasn't restarted.` };
}
const savedBefore = saved.ok ? saved.name : null;
const report: SeatSwitchReport = { seat: input.seat, home, account: input.toAccount, loginChanged: false, savedBefore,
undo: savedBefore ? `rig auth switch --codex-home ${home} ${savedBefore}` : null, peers: withRestartSteps(peers, input.toAccount) };
const activated = logins.activate(home, input.toAccount);
if (!activated.ok) {
return { outcome: "failed_safely", reasons: ["login_not_activated"], seat: report,
message: `The login "${input.toAccount}" couldn't be activated in ${home} (${activated.reason}); nothing was restarted.` };
}
report.loginChanged = true;
const restart = await seatRestart.run(input.seat, `rig provider switch --to-account ${input.toAccount}`);
if (!restart.ok) {
return { outcome: "failed_safely", reasons: ["login_changed_seat_not_restarted"], seat: report,
message: `${home} now uses "${input.toAccount}", but ${input.seat} wasn't restarted (${restart.code}: ${restart.message}). It and every other seat on this home keep their current login only until their next token refresh, when Codex stops them with "signed in to another account" until they're restarted: rig seat restart ${input.seat} --keep-conversation ${movedReason(input.toAccount)}.${report.undo ? ` To switch the home back: ${report.undo}` : ""}` };
}
const outcome = restart.outcome ?? "native_unknown";
report.restart = { outcome, message: restart.message,
...(restart.recoveryCommand ? { recoveryCommand: restart.recoveryCommand } : {}), ...(restart.remainingStep ? { remainingStep: restart.remainingStep } : {}) };
report.peers = withRestartSteps(restart.peers, input.toAccount);
if (outcome === "restarted_account_unverified") {
report.next = `After the seat completes a turn with a tool call: rig seat restart ${input.seat} --keep-conversation --verify --expect-account <text its /status shows for "${input.toAccount}">`;
return { outcome: "rebind_in_progress", seat: report,
message: `Restarted ${input.seat} in place on "${input.toAccount}" in ${home}; the account isn't verified until a tool turn and its account line are seen.` };
}
return { outcome: "unknown", reasons: [`restart_${outcome}`], seat: report, message: `${restart.message} Not retried.` };
}
private codexLogins(): CodexHomeLogins {
return this.deps.codexLogins ?? codexHomeLogins;
}
}
@@ -5,6 +5,8 @@
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";
export interface ProviderPrecheckInput {
seat: string;
@@ -24,17 +26,45 @@ export interface ProviderSwitchInput {
* (auth-switch failure, action-record failure), not only PrecheckReason refusals.
*/
export type ProviderSwitchResult =
| { outcome: "succeeded" }
| { outcome: "rebind_in_progress" }
| { outcome: "failed_safely"; reasons: [string, ...string[]] };
| { outcome: "succeeded"; message?: string; seat?: SeatSwitchReport }
| { outcome: "rebind_in_progress"; message?: string; seat?: SeatSwitchReport }
| { outcome: "failed_safely"; reasons: [string, ...string[]]; message?: string; seat?: SeatSwitchReport }
// The restart ran but its result wasn't confirmed: reported with its recovery command, never retried.
| { outcome: "unknown"; reasons: [string, ...string[]]; message: string; seat: SeatSwitchReport };
/** The seat's in-place restart (`rig seat restart --keep-conversation`), lent by the route for one call. */
export interface SeatRestart {
/** Every gate, read-only: nothing is stopped. */
plan(seat: string): Promise<RestartInPlaceResult>;
run(seat: string, reason: string): Promise<RestartInPlaceResult>;
}
/** What a seat switch did in the Codex home the seat runs on. */
export interface SeatSwitchReport {
seat: string;
home: string;
account: string;
/** Whether the home's active login 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;
/** The command that switches the home back. */
undo: string | null;
restart?: { outcome: string; message: string; recoveryCommand?: string; remainingStep?: string };
/** 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 }>;
next?: string;
}
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): Promise<PrecheckResult>;
/** Precheck-gated switch; returns an explicit business outcome (never a transport error). */
switchAccount(input: ProviderSwitchInput): Promise<ProviderSwitchResult>;
precheck(input: ProviderPrecheckInput, seatRestart?: SeatRestart): 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>;
/** 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 }>;
}
@@ -127,11 +127,31 @@ export type PrecheckReason =
| "target_needs_reauth" // target authState === "needs_reauth"
| "target_auth_unknown" // target authState === "unknown" — fails closed, NOT relabeled as re-auth
| "signal_unknown_or_stale"
| "rebind_unsupported_for_runtime";
| "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
export type PrecheckResult = { safe: true } | { safe: false; reasons: PrecheckReason[] };
/** 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. */
export interface SeatPeer {
logicalId: string;
sessionName: string | null;
home: "same" | "different" | "unknown";
effect: string;
}
export type SwitchOutcome = "succeeded" | "rebind_in_progress" | "failed_safely";
/** What a seat-aware precheck saw: the Codex home the seat runs on, what the seat is doing, and its peers on that home. */
export interface SeatSwitchView {
home: string;
activity: string;
peers: SeatPeer[];
}
export type PrecheckResult =
| { safe: true; seat?: SeatSwitchView }
| { safe: false; reasons: PrecheckReason[]; message?: string; seat?: SeatSwitchView };
export type SwitchOutcome = "succeeded" | "rebind_in_progress" | "failed_safely" | "unknown";
/** The whole four-block read model, emitted by `rig provider status --json`. */
export interface FourBlockReadModel {
@@ -755,7 +755,7 @@ export class CodexInPlaceRestart {
}
reports.push({ logicalId: row.logical_id, sessionName: row.tmux_session, home,
effect: home === "same"
? `Not restarted; account unverified. Shares home ${codexHome}, so it may take the new account at its next start or token refresh.`
? `Not restarted; account unverified. Shares home ${codexHome}: if the home's login changed, it keeps its current login only until its next token refresh, when Codex stops it with "signed in to another account" until it's restarted. Signing in again there would replace the home's login.`
: home === "different" ? "Not restarted; uses a different Codex home." : "Not restarted; account unverified; its Codex home could not be observed." });
}
return reports;
+13 -3
View File
@@ -2,11 +2,12 @@ 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 } from "../domain/provider/provider-service.js";
import type { ProviderService, SeatRestart } from "../domain/provider/provider-service.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";
import { requireSenderIdentity, resolveRecordedProvenance } from "./require-sender-identity.js";
import { seatLifecycleService } from "./seat.js";
import {
readUsageAcrossHosts,
selectUsageHosts,
@@ -31,6 +32,15 @@ export interface ProviderRoutesOptions {
};
}
/** The seat's in-place restart for this request, built only when used: precheck and switch act on the Codex home the
* running seat uses, and the restart keeps its conversation and launch settings. */
function seatRestartOf(c: Context): SeatRestart {
return {
plan: (seat) => seatLifecycleService(c).restartInPlace({ seatRef: seat, plan: true }),
run: (seat, reason) => seatLifecycleService(c).restartInPlace({ seatRef: seat, reason }),
};
}
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
@@ -249,7 +259,7 @@ export function providerRoutes(opts: ProviderRoutesOptions = {}): Hono {
return c.json({ error: "seat and toAccount are required" }, 400);
}
// An unsafe verdict is a valid 200 response, not an error.
return c.json(await svc.precheck({ seat, toAccount }), 200);
return c.json(await svc.precheck({ seat, toAccount }, seatRestartOf(c)), 200);
});
router.post("/switch", async (c) => {
@@ -276,7 +286,7 @@ export function providerRoutes(opts: ProviderRoutesOptions = {}): Hono {
return c.json({ error: "forceUnsafe must be a boolean" }, 400);
}
// Business outcome (incl. failed_safely / rebind_in_progress) is an explicit 200 payload.
return c.json(await svc.switchAccount({ seat, toAccount, forceUnsafe }), 200);
return c.json(await svc.switchAccount({ seat, toAccount, forceUnsafe }, seatRestartOf(c)), 200);
});
// Slice 18 — subscription move reports: queue items tagged subscription-move, filed by the agent that moved a Codex
@@ -100,6 +100,21 @@ describe("provider routes", () => {
expect(res.status).toBe(400);
});
it("precheck and switch lend the service the seat's in-place restart, so both act on the home the seat runs on", async () => {
const lent: unknown[] = [];
const app = appWith({
...stubService,
precheck: async (_input, seatRestart) => { lent.push(seatRestart); return { safe: true }; },
switchAccount: async (_input, seatRestart) => { lent.push(seatRestart); return { outcome: "rebind_in_progress" }; },
});
expect((await app.request("/api/provider/precheck?seat=s1&toAccount=work")).status).toBe(200);
expect((await app.request("/api/provider/switch", {
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ seat: "s1", toAccount: "work" }),
})).status).toBe(200);
expect(lent).toHaveLength(2);
for (const seatRestart of lent) expect(seatRestart).toMatchObject({ plan: expect.any(Function), run: expect.any(Function) });
});
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,278 @@
import { afterEach, describe, it, expect } from "vitest";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { authSwitch, resolveCodexHome } from "../src/domain/codex-auth.js";
import { ProviderServiceImpl } from "../src/domain/provider/provider-service-impl.js";
// `rig provider switch --seat`: one seat moves to a login saved in the Codex home it runs on (its own home with #1937,
// or a shared one, whose peers are named). The seat's in-place restart keeps its conversation and launch settings;
// the restart plan runs first, so a refusal changes nothing. Uptake is left to `rig seat restart --verify`.
const HOME = "/homes/seat-a";
const NOW = "2026-10-25T04:10:00.000Z";
const BEFORE = "before-switch-20261025T041000.000Z";
const PEER = { logicalId: "dev.qa", sessionName: "dev-qa@rig", home: "same" as const, effect: "not restarted; account unverified" };
const OTHER = { logicalId: "dev.guard", sessionName: "dev-guard@rig", home: "different" as const, effect: "Not restarted; uses a different Codex home." };
function report(over: Record<string, unknown> = {}) {
return { ok: true, planOnly: true, message: "planned", codexHome: HOME, peers: [], activity: { state: "idle", needsInput: null, detail: "" }, ...over };
}
function harness(opts: { plan?: Record<string, unknown>; run?: Record<string, unknown>; saved?: string[]; loggedIn?: boolean; saveFails?: string } = {}) {
const calls: string[] = [];
const homes = new Map([[HOME, { active: opts.loggedIn === false ? null as string | null : "personal", saved: new Set(opts.saved ?? ["personal", "work"]) }]]);
const codexLogins = {
list: (home: string) => [...(homes.get(home)?.saved ?? [])].sort(),
save: (home: string, name: string) => {
calls.push(`save ${home} ${name}`);
const h = homes.get(home);
if (!h?.active) return { ok: false as const, reason: "not_configured" };
if (opts.saveFails) return { ok: false as const, reason: opts.saveFails };
if (h.saved.has(name)) return { ok: false as const, reason: "name_taken" };
h.saved.add(name);
return { ok: true as const, name };
},
activate: (home: string, name: string) => {
calls.push(`activate ${home} ${name}`);
const h = homes.get(home);
if (!h?.saved.has(name)) return { ok: false as const, reason: "missing_profile" };
h.active = name;
return { ok: true as const };
},
};
const seatRestart = {
plan: async (seat: string) => { calls.push(`plan ${seat}`); return (opts.plan ?? report()) as never; },
run: async (seat: string, reason: string) => {
calls.push(`run ${seat} ${reason}`);
return (opts.run ?? report({ planOnly: false, outcome: "restarted_account_unverified", message: "Restarted in place." })) as never;
},
};
const svc = new ProviderServiceImpl({ db: {} as never, listRigs: () => [], codexLogins, now: () => NOW });
return { svc, seatRestart, calls, homes };
}
describe("rig provider switch --seat moves one seat to a login saved in the Codex home it runs on", () => {
it("plans the restart, keeps the outgoing login, activates the chosen one there, restarts in place, and leaves uptake to verify", async () => {
const h = harness();
const r = await h.svc.switchAccount({ seat: "dev-a@rig", toAccount: "work", forceUnsafe: false }, h.seatRestart);
expect(h.calls).toEqual([
"plan dev-a@rig",
`save ${HOME} ${BEFORE}`,
`activate ${HOME} work`,
"run dev-a@rig rig provider switch --to-account work",
]);
expect(r).toMatchObject({
outcome: "rebind_in_progress",
seat: { seat: "dev-a@rig", home: HOME, account: "work", loginChanged: true, savedBefore: BEFORE,
undo: `rig auth switch --codex-home ${HOME} ${BEFORE}`, restart: { outcome: "restarted_account_unverified" },
next: expect.stringContaining("rig seat restart dev-a@rig --keep-conversation --verify --expect-account") },
});
expect(h.homes.get(HOME)!.active).toBe("work");
});
it("names every peer on a shared home, and gives each one on the same home its restart, since it stops at its next token refresh", async () => {
const h = harness({ plan: report({ peers: [PEER, OTHER] }), run: report({ planOnly: false, outcome: "restarted_account_unverified", peers: [PEER, OTHER] }) });
expect(await h.svc.precheck({ seat: "dev-a@rig", toAccount: "work" }, h.seatRestart))
.toMatchObject({ safe: true, seat: { home: HOME, activity: "idle", peers: [PEER, OTHER] } });
const r = await h.svc.switchAccount({ seat: "dev-a@rig", toAccount: "work", forceUnsafe: false }, h.seatRestart);
expect(r).toMatchObject({ outcome: "rebind_in_progress",
seat: { peers: [{ ...PEER, step: 'rig seat restart dev-qa@rig --keep-conversation --reason "its Codex home moved to work"' }, OTHER] } });
expect(r.seat!.peers[1]).not.toHaveProperty("step");
});
it("a home whose current login can't be saved first changes nothing: the switch stops before activation", async () => {
const h = harness({ saveFails: "io_error" });
const r = await h.svc.switchAccount({ seat: "dev-a@rig", toAccount: "work", forceUnsafe: false }, h.seatRestart);
expect(r).toMatchObject({ outcome: "failed_safely", reasons: ["outgoing_login_not_saved"], message: expect.stringContaining("io_error") });
expect(h.calls).toEqual(["plan dev-a@rig", `save ${HOME} ${BEFORE}`]);
expect(h.homes.get(HOME)!.active).toBe("personal");
});
it("an unknown sign-in state doesn't stop an authorized move", async () => {
const h = harness();
const verdict = await h.svc.precheck({ seat: "dev-a@rig", toAccount: "work" }, h.seatRestart);
expect(verdict).toMatchObject({ safe: true });
expect(verdict).not.toHaveProperty("reasons");
});
it("a refused restart plan changes nothing: the home's login stays, and the refusal is named", async () => {
const h = harness({ plan: { ok: false, code: "settings_unverified", message: "Couldn't read the running Codex's environment." } });
const r = await h.svc.switchAccount({ seat: "dev-a@rig", toAccount: "work", forceUnsafe: true }, h.seatRestart);
expect(r).toMatchObject({ outcome: "failed_safely", reasons: ["seat_restart_refused"], message: expect.stringContaining("settings_unverified") });
expect(h.calls).toEqual(["plan dev-a@rig"]);
expect(h.homes.get(HOME)!.active).toBe("personal");
});
it("a Claude seat isn't switched: its runtime has no account switch yet", async () => {
const h = harness({ plan: { ok: false, code: "not_codex", message: "Seat \"dev-a@rig\" runs claude-code; in-place restart supports Codex seats only." } });
expect(await h.svc.precheck({ seat: "dev-a@rig", toAccount: "work" }, h.seatRestart))
.toMatchObject({ safe: false, reasons: ["rebind_unsupported_for_runtime"] });
expect(await h.svc.switchAccount({ seat: "dev-a@rig", toAccount: "work", forceUnsafe: false }, h.seatRestart))
.toMatchObject({ outcome: "failed_safely", reasons: ["rebind_unsupported_for_runtime"] });
expect(h.calls.some((call) => call.startsWith("activate"))).toBe(false);
});
it("a login that isn't saved in the seat's home is named, with how to list the ones that are", async () => {
const h = harness({ saved: ["personal"] });
expect(await h.svc.precheck({ seat: "dev-a@rig", toAccount: "work" }, h.seatRestart)).toMatchObject({
safe: false, reasons: ["target_not_in_seat_home"], message: expect.stringContaining(`rig auth list --codex-home ${HOME}`),
});
expect(h.calls.some((call) => call.startsWith("activate") || call.startsWith("run"))).toBe(false);
});
it("a restart that can't confirm is reported as unknown, with its recovery command, and never retried", async () => {
const h = harness({ run: report({ planOnly: false, outcome: "native_unknown", recoveryCommand: "codex resume abc", message: "The replacement Codex wasn't observed." }) });
const r = await h.svc.switchAccount({ seat: "dev-a@rig", toAccount: "work", forceUnsafe: false }, h.seatRestart);
expect(r).toMatchObject({ outcome: "unknown", reasons: ["restart_native_unknown"],
seat: { loginChanged: true, restart: { outcome: "native_unknown", recoveryCommand: "codex resume abc" } } });
expect(h.calls.filter((call) => call.startsWith("run"))).toHaveLength(1);
});
it("a restart refused after the login changed says so, with the way back", async () => {
const h = harness({ run: { ok: false, code: "restart_in_progress", message: "Another restart of this seat is running." } });
const r = await h.svc.switchAccount({ seat: "dev-a@rig", toAccount: "work", forceUnsafe: false }, h.seatRestart);
expect(r).toMatchObject({ outcome: "failed_safely", reasons: ["login_changed_seat_not_restarted"],
message: expect.stringContaining("Another restart of this seat is running."),
seat: { loginChanged: true, undo: `rig auth switch --codex-home ${HOME} ${BEFORE}` } });
// The seat wasn't restarted, so it keeps its old login only until its next token refresh.
expect(r.message).toContain("next token refresh");
expect(r.message).toContain('rig seat restart dev-a@rig --keep-conversation --reason "its Codex home moved to work"');
});
it("a switch that waited behind another on its home checks the seat again before it changes the login", async () => {
const h = harness();
let release!: () => void;
const held = new Promise<void>((resolve) => { release = resolve; });
let bPlans = 0;
const seatRestart = {
plan: async (seat: string) => {
h.calls.push(`plan ${seat}`);
// While it waits, seat B loses what its restart needs.
if (seat === "dev-b@rig" && bPlans++ > 0) return { ok: false, code: "settings_unverified", message: "Couldn't read the running Codex's environment." } as never;
return report() as never;
},
run: async (seat: string) => {
h.calls.push(`run ${seat}`);
if (seat === "dev-a@rig") await held;
return report({ planOnly: false, outcome: "restarted_account_unverified", message: "Restarted in place." }) as never;
},
};
const a = h.svc.switchAccount({ seat: "dev-a@rig", toAccount: "work", forceUnsafe: false }, seatRestart);
await new Promise((resolve) => setTimeout(resolve, 0));
const b = h.svc.switchAccount({ seat: "dev-b@rig", toAccount: "personal", forceUnsafe: false }, seatRestart);
await new Promise((resolve) => setTimeout(resolve, 0));
release();
expect(await a).toMatchObject({ outcome: "rebind_in_progress" });
expect(await b).toMatchObject({ outcome: "failed_safely", reasons: ["seat_restart_refused"], message: expect.stringContaining("settings_unverified") });
// B planned twice (before and after its wait), and changed nothing: the home keeps A's login.
expect(h.calls.filter((call) => call.endsWith("dev-b@rig"))).toEqual(["plan dev-b@rig", "plan dev-b@rig"]);
expect(h.homes.get(HOME)!.active).toBe("work");
});
it("a switch whose seat moved to another home while it waited touches neither home", async () => {
const h = harness();
let release!: () => void;
const held = new Promise<void>((resolve) => { release = resolve; });
let bPlans = 0;
const seatRestart = {
plan: async (seat: string) => {
h.calls.push(`plan ${seat}`);
if (seat === "dev-b@rig" && bPlans++ > 0) return report({ codexHome: "/homes/seat-b" }) as never;
return report() as never;
},
run: async (seat: string) => {
h.calls.push(`run ${seat}`);
if (seat === "dev-a@rig") await held;
return report({ planOnly: false, outcome: "restarted_account_unverified", message: "Restarted in place." }) as never;
},
};
const a = h.svc.switchAccount({ seat: "dev-a@rig", toAccount: "work", forceUnsafe: false }, seatRestart);
await new Promise((resolve) => setTimeout(resolve, 0));
const b = h.svc.switchAccount({ seat: "dev-b@rig", toAccount: "personal", forceUnsafe: false }, seatRestart);
await new Promise((resolve) => setTimeout(resolve, 0));
release();
await a;
expect(await b).toMatchObject({ outcome: "failed_safely", reasons: ["seat_home_changed"], message: expect.stringContaining("/homes/seat-b") });
expect(h.calls.filter((call) => call.endsWith("dev-b@rig") || call.includes("seat-b"))).toEqual(["plan dev-b@rig", "plan dev-b@rig"]);
expect(h.homes.get(HOME)!.active).toBe("work");
});
it("a home with no active login moves without a before copy, and says there's nothing to switch back to", async () => {
const h = harness({ loggedIn: false });
const r = await h.svc.switchAccount({ seat: "dev-a@rig", toAccount: "work", forceUnsafe: false }, h.seatRestart);
expect(r).toMatchObject({ outcome: "rebind_in_progress", seat: { savedBefore: null, undo: null, loginChanged: true } });
expect(h.homes.get(HOME)!.active).toBe("work");
});
});
// The same switch on real files in a temporary Codex home (`rig auth`'s save and switch); only the restart is stubbed.
describe("switches on one Codex home keep each receipt's way back and the login its restart loads", () => {
const cleanup: Array<() => void> = [];
afterEach(() => { for (const f of cleanup.splice(0)) f(); });
const login = (name: string) => JSON.stringify({ fixture: name });
function codexHome() {
const home = mkdtempSync(path.join(tmpdir(), "provider-switch-home-"));
cleanup.push(() => rmSync(home, { recursive: true, force: true }));
mkdirSync(path.join(home, "auth-profiles"), { mode: 0o700 });
for (const name of ["alpha", "beta", "gamma"]) writeFileSync(path.join(home, "auth-profiles", `${name}.json`), login(name), { mode: 0o600 });
writeFileSync(path.join(home, "auth.json"), login("alpha"), { mode: 0o600 });
const times = ["2026-10-25T04:10:00.100Z", "2026-10-25T04:10:00.200Z"];
const svc = new ProviderServiceImpl({ db: {} as never, listRigs: () => [], now: () => times.length > 1 ? times.shift()! : times[0] });
const restarted = () => report({ codexHome: home, planOnly: false, outcome: "restarted_account_unverified", message: "Restarted in place." });
const restart = (run: () => Promise<unknown> = async () => restarted()) => ({ plan: async () => report({ codexHome: home }) as never, run: run as never });
const active = () => readFileSync(path.join(home, "auth.json"), "utf8");
const undo = (savedBefore: string) => authSwitch(resolveCodexHome({ CODEX_HOME: home }), savedBefore);
return { home, svc, times, restart, restarted, active, undo };
}
it("each switch keeps its own recovery copy, so an earlier receipt's undo still restores its own login", async () => {
const f = codexHome();
const first = await f.svc.switchAccount({ seat: "dev-a@rig", toAccount: "beta", forceUnsafe: false }, f.restart());
const second = await f.svc.switchAccount({ seat: "dev-b@rig", toAccount: "gamma", forceUnsafe: false }, f.restart());
expect([first.outcome, second.outcome]).toEqual(["rebind_in_progress", "rebind_in_progress"]);
expect(first.seat!.savedBefore).not.toBe(second.seat!.savedBefore);
expect(f.undo(first.seat!.savedBefore!).ok).toBe(true);
expect(f.active()).toBe(login("alpha"));
expect(f.undo(second.seat!.savedBefore!).ok).toBe(true);
expect(f.active()).toBe(login("beta"));
});
it("a recovery copy is never overwritten: a second switch in the same millisecond stops before changing anything", async () => {
const f = codexHome();
f.times.splice(1);
const first = await f.svc.switchAccount({ seat: "dev-a@rig", toAccount: "beta", forceUnsafe: false }, f.restart());
const second = await f.svc.switchAccount({ seat: "dev-b@rig", toAccount: "gamma", forceUnsafe: false }, f.restart());
expect(second).toMatchObject({ outcome: "failed_safely", reasons: ["outgoing_login_not_saved"] });
expect(f.active()).toBe(login("beta"));
expect(f.undo(first.seat!.savedBefore!).ok).toBe(true);
expect(f.active()).toBe(login("alpha"));
});
it("two switches on one home run one at a time, so each restart loads the login its receipt names", async () => {
const f = codexHome();
let release!: () => void;
const held = new Promise<void>((resolve) => { release = resolve; });
let entered!: () => void;
const inFirstRestart = new Promise<void>((resolve) => { entered = resolve; });
let firstRestartLoaded = "";
const first = f.svc.switchAccount({ seat: "dev-a@rig", toAccount: "beta", forceUnsafe: false }, f.restart(async () => {
entered();
await held;
firstRestartLoaded = f.active();
return f.restarted();
}));
await inFirstRestart;
const second = f.svc.switchAccount({ seat: "dev-b@rig", toAccount: "gamma", forceUnsafe: false }, f.restart());
await new Promise((resolve) => setTimeout(resolve, 20));
// While the first seat restarts, the second switch hasn't touched the home.
expect(f.active()).toBe(login("beta"));
release();
expect(await first).toMatchObject({ outcome: "rebind_in_progress", seat: { account: "beta" } });
expect(firstRestartLoaded).toBe(login("beta"));
expect(await second).toMatchObject({ outcome: "rebind_in_progress", seat: { account: "gamma" } });
expect(f.active()).toBe(login("gamma"));
});
});
+1 -1
View File
@@ -84,7 +84,7 @@
"openrig-operating-model/templates/SOP.md": "1eaf0daf4f9bbf0715e6a5aaf0f29f030af5fe8b8a10232488ee97c507afa6dc",
"openrig-operating-model/templates/SPEC.md": "f126536daa59cea3f1cfb0898d83c7cdf99f6392f6f155ea64fe55187dd0f5f1",
"openrig-skills/SKILL.md": "bdd6594ae1108455103239d39694c865cc40d3f7d9f24cd3566a18397df270da",
"openrig-user/SKILL.md": "6c00df21fd2712d1cfa5db0e6e935fedf3aae9bf8626bd0dc4598c876ccb2c98",
"openrig-user/SKILL.md": "64a1901c9097c41b3ef133c2998dcbc966ee25f7e1a201bd7e8142ca78a864ae",
"orienting-to-an-inherited-seat/CHANGELOG.md": "4d0970e75e5c15b00a48d6a7150376d646257777264fd1b9a191165a8d5a6e84",
"orienting-to-an-inherited-seat/SKILL.md": "252fed402f19027056bbba241df3305be88de3175b67dc8fe051143bb0d4412b",
"queue-handoff/SKILL.md": "fcfa420a49a09be4fd51ccc6ca89c00847a59484a7bcab205c8eec83837cdc0d",