Compare commits

...
Author SHA1 Message Date
dev-driver 3e6c3ea667 test: a provider switch keeps each way back, runs one at a time per home, and tells peers they stop at their next refresh
- A home whose current login can't be saved first (io_error, unsafe_path) changes nothing: the switch stops before
  activation with outgoing_login_not_saved.
- Each switch keeps its own recovery copy (milliseconds in the name, never overwritten), so an earlier receipt's undo
  restores its own login. Real files in a temporary Codex home; only the restart is stubbed.
- Two switches on one home run one at a time, so the first restart loads the login its receipt names.
- Each same-home peer gets its restart step; the failed-restart message names the next-token-refresh stop.
- The CLI waits as long as rig seat restart does, labels each peer by its home, and reports a CLI timeout as unknown.
2026-10-25 06:11:44 +00:00
dev-driver 2123324b67 Merge remote-tracking branch 'origin/main' into feat/provider-switch-seat
# Conflicts:
#	scripts/skill-edge-digests.generated.json
2026-10-25 06:09:38 +00:00
dev-driver 9d9e3aa558 fix(provider): keep the provider types free of the restart module's imports
The reactive tap's import closure must not reach pane capture; a type import of PeerReport pulled in
seat-restart-in-place.ts. The peer shape is declared in the provider types instead.
2026-10-25 04:47:05 +00:00
dev-driver 8f1693b796 feat(provider): rig provider switch --seat moves one Codex seat to a login saved in its home
The switch plans the seat's in-place restart first, so a refusal changes nothing; then it saves the
home's outgoing login, activates the chosen one (rig auth's file operations, now in the daemon so
both can use them) and restarts the seat in place with the same conversation and launch settings.
Peers on the home are listed, uptake is left to rig seat restart --verify, and an uncertain restart
is reported, never retried. Codex only and Linux only, like the restart it uses.
2026-10-25 04:10:02 +00:00
dev-driver 60a669fd6b test: rig provider switch --seat moves one seat to a login saved in the Codex home it runs on 2026-10-25 04:08:02 +00:00
15 changed files with 1070 additions and 596 deletions

No files matched your search

+48 -11
View File
@@ -20,9 +20,20 @@ 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; effect: 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 };
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) console.log(`peer on this home: ${peer.sessionName ?? peer.logicalId}: ${peer.effect}`);
}
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 +482,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 +493,61 @@ 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. Every seat on that home shares its login, so peers are listed: they're
not restarted, and pick up the login at their next start. 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", {
const res = await client.post<{ outcome?: string; reasons?: string[]; message?: string; seat?: SwitchReport }>("/api/provider/switch", {
seat: opts.seat,
toAccount: opts.toAccount,
forceUnsafe: opts.forceUnsafe ?? false,
});
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";
+33 -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,35 @@ 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");
});
});
+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,23 @@ 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. Peers on
the home aren't restarted: they share its login and pick it up at their next start.
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 +1058,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,
};
}
@@ -26,8 +26,29 @@ 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[];
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) => authSave(resolveCodexHome({ CODEX_HOME: home }), name),
activate: (home, name) => authSwitch(resolveCodexHome({ CODEX_HOME: home }), name),
};
export interface ProviderServiceImplDeps {
db: Database.Database;
@@ -43,6 +64,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 +164,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 +179,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 +191,64 @@ 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. 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 } : {}) };
}
const home = checked.home;
const logins = this.codexLogins();
const stamp = (this.deps.now ?? (() => new Date().toISOString()))().replace(/[-:]/g, "").replace(/\.\d+/, "");
const saved = logins.save(home, `before-switch-${stamp}`);
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: checked.peers };
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 the seat wasn't restarted (${restart.code}: ${restart.message}). Seats on this home pick it up at their next start.${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 = restart.peers;
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,44 @@ 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; 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 };
/** Seats on the same home: they share its login, so they pick up the change at their next start. */
peers: PeerReport[];
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 {
+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,220 @@
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" }, 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");
});
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": "ce31d6e335e786c1bf8b17d900f8c0ed45760bc8bd9a2aa80ef2e1cee471e09f",
"orienting-to-an-inherited-seat/CHANGELOG.md": "4d0970e75e5c15b00a48d6a7150376d646257777264fd1b9a191165a8d5a6e84",
"orienting-to-an-inherited-seat/SKILL.md": "252fed402f19027056bbba241df3305be88de3175b67dc8fe051143bb0d4412b",
"queue-handoff/SKILL.md": "fcfa420a49a09be4fd51ccc6ca89c00847a59484a7bcab205c8eec83837cdc0d",