mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-11 17:32:22 -10:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e6c3ea667 | ||
|
|
2123324b67 | ||
|
|
9d9e3aa558 | ||
|
|
8f1693b796 | ||
|
|
60a669fd6b |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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";
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user