Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions src/cli/update-restart-child.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,11 @@ function parseMarker(raw: string): UpdateRestartChildMarker {
|| typeof value.home.serviceRecord.digest !== "string" || !/^[a-f0-9]{64}$/.test(value.home.serviceRecord.digest)) throw new Error("update_restart_child_marker_invalid");
for (const directory of [value.home.config, value.home.codex]) {
// Windows reports 64-bit NTFS file ids (MFT sequence in the high 16 bits) as doubles above
// 2^53. Parent and child read them through the same stat API, so any finite integer is a
// faithful identity token; assertUpdateRestartHome still compares path, dev and ino exactly.
// 2^53, where neighbouring ids collapse. dev and ino may be large finite integers; fileId
// carries the exact "dev:ino" that assertUpdateRestartHome actually relies on.
if (!directory || typeof directory.path !== "string" || !directory.path
|| !Number.isInteger(directory.dev) || !Number.isInteger(directory.ino)) {
|| !Number.isInteger(directory.dev) || !Number.isInteger(directory.ino)
|| typeof directory.fileId !== "string" || !/^\d{1,20}:\d{1,20}$/.test(directory.fileId)) {
throw new Error("update_restart_child_marker_invalid");
}
}
Expand Down
28 changes: 20 additions & 8 deletions src/cli/update-restart-home.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,39 @@
import { realpathSync, statSync } from "node:fs";
import { realpathSync, statSync, type BigIntStats } from "node:fs";
import { readClientConnectionState } from "../client/state";
import { readConfigDiagnostics } from "../config/diagnostics";
import { getConfigDir } from "../config/paths";
import { currentCodexHome } from "../service/state";
import { captureUpdateRestartServiceRecord, type UpdateRestartServiceRecord, type UpdateRestartServiceRecordDeps } from "./update-restart-service-record";
import { probeUpdateRestartSupervision, type UpdateRestartSupervisionDeps } from "./update-restart-supervision";

/**
* `fileId` is the exact identity ("dev:ino" from a bigint stat). `dev` and `ino` are doubles and
* cannot be: an NTFS file id carries the MFT sequence in its high bits, so above 2^53 a replacement
* directory with a nearby MFT index can round to the same `ino` as the one it replaced.
*/
export interface UpdateRestartDirectory { path: string; dev: number; ino: number; fileId: string }

export interface UpdateRestartHome {
config: { path: string; dev: number; ino: number };
codex: { path: string; dev: number; ino: number };
config: UpdateRestartDirectory;
codex: UpdateRestartDirectory;
revision: number;
serviceRecord: UpdateRestartServiceRecord;
}

function physicalDirectory(path: string): UpdateRestartHome["config"] {
export type UpdateRestartDirectoryStat = Pick<BigIntStats, "dev" | "ino" | "isDirectory">;

function physicalDirectory(path: string, stat: (path: string) => UpdateRestartDirectoryStat): UpdateRestartDirectory {
const canonical = realpathSync.native(path);
const stat = statSync(canonical);
if (!stat.isDirectory()) throw new Error("update_restart_home_unverified");
return { path: canonical, dev: stat.dev, ino: stat.ino };
const observed = stat(canonical);
if (!observed.isDirectory()) throw new Error("update_restart_home_unverified");
return { path: canonical, dev: Number(observed.dev), ino: Number(observed.ino), fileId: `${observed.dev}:${observed.ino}` };
}

export interface UpdateRestartHomeDeps {
record?: UpdateRestartServiceRecordDeps;
supervision?: UpdateRestartSupervisionDeps;
/** Test seam for the directory identity source; production reads a bigint stat. */
statDirectory?: (path: string) => UpdateRestartDirectoryStat;
}

/** Capture eligibility from the same bytes that supply the frozen fingerprint. */
Expand All @@ -32,7 +43,8 @@ export function readUpdateRestartHome(deps: UpdateRestartHomeDeps = {}): UpdateR
if (owner.kind !== "none" || (state.kind === "state" && platform !== "darwin" && platform !== "linux")) {
throw new Error("update_restart_owner_unverified");
}
return { config: physicalDirectory(getConfigDir()), codex: physicalDirectory(currentCodexHome()), revision: owner.revision, serviceRecord };
const stat = deps.statDirectory ?? (path => statSync(path, { bigint: true }));
return { config: physicalDirectory(getConfigDir(), stat), codex: physicalDirectory(currentCodexHome(), stat), revision: owner.revision, serviceRecord };
}

export function assertUpdateRestartHome(expected: UpdateRestartHome, deadlineAt = Date.now() + 2000, deps: UpdateRestartHomeDeps = {}): void {
Expand Down
24 changes: 18 additions & 6 deletions src/codex/desktop-compatibility/windows-key-protection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,24 @@ const ENTROPY = new TextEncoder().encode(PURPOSE);
// Only the PowerShell fallback is bounded by a clock. A cold Windows PowerShell that must also load
// System.Security has been measured past 10s on a loaded machine (Windows CI run 38591626343).
const DPAPI_TIMEOUT_MS = 20_000;
// Cmdlet-free on purpose. The first cmdlet a PowerShell session runs (Add-Type, ConvertFrom-Json)
// sends it through command discovery and module auto-loading, which without a module-analysis cache
// for the profile scans the modules on PSModulePath. The test runner gives every run a fresh profile
// and CI images carry large module sets, so that scan, not DPAPI, is what cold runs waited on.
// Assembly loading, string splitting and base64 are plain .NET calls outside command discovery.
// The request is "<operation>\n<base64 data>" on stdin.
const SCRIPT = [
"$ErrorActionPreference='Stop'",
"Add-Type -AssemblyName System.Security",
"$r=[Console]::In.ReadToEnd()|ConvertFrom-Json",
"$data=[Convert]::FromBase64String($r.data)",
"[void][Reflection.Assembly]::Load('System.Security, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a')",
"$r=[Console]::In.ReadToEnd().Split([char]10)",
"if($r.Length -ne 2){throw 'Invalid request'}",
"$data=[Convert]::FromBase64String($r[1])",
`if($data.Length -eq 0 -or $data.Length -gt ${MAX_INPUT}){throw 'Invalid data size'}`,
`$entropy=[Text.Encoding]::UTF8.GetBytes('${PURPOSE}')`,
"$scope=[Security.Cryptography.DataProtectionScope]::CurrentUser",
"if($r.operation -eq 'protect'){",
"if($r[0] -ceq 'protect'){",
" $out=[Security.Cryptography.ProtectedData]::Protect($data,$entropy,$scope)",
"}elseif($r.operation -eq 'unprotect'){",
"}elseif($r[0] -ceq 'unprotect'){",
" $out=[Security.Cryptography.ProtectedData]::Unprotect($data,$entropy,$scope)",
"}else{throw 'Invalid operation'}",
"[Console]::Out.Write([Convert]::ToBase64String($out))",
Expand Down Expand Up @@ -126,7 +133,7 @@ function runPowerShellDpapi(operation: Operation, data: Uint8Array, timeoutMs =
resolve(bytes);
});
child.stdin?.on("error", () => { /* execFile's completion reports the failed child. */ });
child.stdin?.end(JSON.stringify({ operation, data: Buffer.from(data).toString("base64") }));
child.stdin?.end(`${operation}\n${Buffer.from(data).toString("base64")}`);
});
}

Expand Down Expand Up @@ -163,3 +170,8 @@ export function powerShellAuthorityKeyProtectionForTests(timeoutMs: number): Aut
export function usesNativeDpapiForTests(): boolean {
return process.platform === "win32" && loadNativeDpapi() !== null;
}

/** Test-only: the PowerShell helper's script, so its cmdlet-free contract can be checked anywhere. */
export function powerShellDpapiScriptForTests(): string {
return SCRIPT;
}
8 changes: 5 additions & 3 deletions structure/clients/codex-desktop.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,12 +69,14 @@ registration refuse it, while exact trust removal and deliberate renewal remain
`CryptProtectData`/`CryptUnprotectData` in-process on Windows x64, loading `crypt32.dll`
from the `GetSystemDirectoryW` path and clearing unprotected output before `LocalFree`.
Windows ARM64, where Bun FFI is unavailable, keeps the trusted PowerShell helper with
bounded stdin/stdout. Both write the same blob format; neither puts secrets on a command
bounded stdin/stdout. The helper uses only .NET calls and never a cmdlet, because the first
cmdlet in a session pays module discovery, which a cold profile turns into the whole budget.
Both write the same blob format; neither puts secrets on a command
line or falls back to plaintext. CurrentUser protects against other OS identities, not
another process using the same credentials.
`tests/clients/desktop-compatibility-authority.test.ts` covers persistence, refusal,
a real Windows DPAPI round trip with synthetic data, and a PowerShell-written key opening
in-process.
a real Windows DPAPI round trip with synthetic data, a PowerShell-written key opening
in-process, and the helper's cmdlet-free script on every platform.

## Certificate setup API

Expand Down
2 changes: 1 addition & 1 deletion structure/ops/service-and-sidecars.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ without running handlers is not covered by this forwarding mechanism.
systemd unit. State and ownership resolve from those captured bytes. Each present regular file contributes SHA-256 content and bigint device, inode, size, nanosecond mtime/ctime,
mode, uid and gid. The opened descriptor must be regular and match device/inode before any read. Each record is limited to 1 MiB, read in bounded chunks with growth beyond that limit
refused. Descriptor stats bracket the read; a final pathname stat must agree. Symlinks, inconsistent reads, malformed state, unreadable candidates and membership drift
refuse. Canonical directory aliases remain equivalent. Lock, PID and runtime records are excluded.
refuse. Homes compare canonical path plus exact bigint `dev:ino` (`fileId`); rounded doubles merge NTFS ids above 2^53. Aliases stay equivalent. Lock, PID and runtime records are excluded.

`src/cli/update-restart.ts` requires known versions and a detached POSIX target with PID-1
parent, no ownership claim and no client/sibling role. An installed record without a claim
Expand Down
8 changes: 6 additions & 2 deletions tests/claude-integration/claude-intercept-proxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,18 +180,22 @@ test("pending async choice holds pipelined bytes, then connects; rejection falls
const echo = await startEchoUpstream();
cleanups.push(echo.close);
let settle!: (choice: { kind: "intercept"; port: number }) => void;
// Resolved inside selectTunnel itself: the proxy has parsed CONNECT and is now holding the
// pipelined bytes. A fixed sleep only guessed at that moment and called an unset settle when
// the proxy had not consulted the choice yet.
const consulted = Promise.withResolvers<void>();
let dialCount = 0;
const proxy = await startConnectProxy(0, {
interceptPort: 1,
authToken: AUTH_TOKEN,
selectTunnel: host => host === "claude.ai"
? new Promise(resolve => { settle = resolve; })
? new Promise(resolve => { settle = resolve; consulted.resolve(); })
: Promise.reject(new Error("decision failed")),
dialUpstream: () => { dialCount += 1; return connect({ host: "127.0.0.1", port: echo.port }); },
});
cleanups.push(proxy.close);
const delayed = tunnelPayload(proxy.port, "claude.ai", "pending");
await Bun.sleep(20);
await consulted.promise;
expect(dialCount).toBe(0);
settle({ kind: "intercept", port: echo.port });
expect(await delayed).toContain("echo:pending");
Expand Down
42 changes: 27 additions & 15 deletions tests/cli/cli-route-policy-write.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
import { afterEach, beforeAll, beforeEach, describe, expect, spyOn, test } from "bun:test";
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Readable } from "node:stream";
import { handleRoutePolicyWriteCommand } from "../../src/cli/route-policy-write";
import type { RuntimeApiDeps } from "../../src/cli/runtime-api";
import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home";
import { COLD_SPAWN_WARMUP_HOOK_BUDGET_MS, warmModuleGraph } from "../helpers/cold-spawn-warmup";
import { removeTreeWithRetry } from "../helpers/remove-tree";
import { repoPath, repoRoot } from "../helpers/repo-root";
import { spawnTestChild } from "../helpers/spawn-test-child";
Expand Down Expand Up @@ -191,8 +192,7 @@ describe("routing profile wire contract and output", () => {

// A separate process contains Lab's process-global slots and all server imports.
// Its empty homes keep automation disabled; injected transports never open a listener.
test("actual routing editor preserves stale/create/update/alias/delete authority", async () => {
const script = `
const ROUTING_EDITOR_SCRIPT = `
import assert from "node:assert/strict";
import { writeFileSync, mkdirSync } from "node:fs";
import { join } from "node:path";
Expand Down Expand Up @@ -257,15 +257,27 @@ test("actual routing editor preserves stale/create/update/alias/delete authority
originalLog(JSON.stringify({ scenarios: 9, saves, refreshes, requests: requests.length, automationEnabled: false }));
} finally { resetLabActivationForTests(); }
`;
const childHome = join(home, "real-handler");
childCompleted = false;
const result = await spawnTestChild([process.execPath, "--eval", script], { cwd: repoRoot(), deadlineMs: 20_000,
env: { PATH: process.env.PATH, SystemRoot: process.env.SystemRoot, NODE_ENV: "test", HOME: childHome,
USERPROFILE: childHome, APPDATA: childHome, XDG_CONFIG_HOME: childHome, OPENCODEX_HOME: childHome,
CODEX_HOME: join(childHome, "codex"), NO_COLOR: "1" },
});
childCompleted = !result.timedOut && result.reaped && result.pipesClosed;
// Keep child diagnostics in the failure instead of leaving a live process after Bun times out.
expect(result).toMatchObject({ exitCode: 0, stderr: "", timedOut: false, reaped: true, pipesClosed: true });
expect(JSON.parse(result.stdout.trim())).toEqual({ scenarios: 9, saves: 3, refreshes: 3, requests: 9, automationEnabled: false });
}, SPAWN_BUDGET_MS);

describe("routing editor child", () => {
// The child loads the management API, routing and Lab graphs. On a cold Windows runner that
// load alone used most of the 20s child deadline, so pay it once here and time a warm child.
beforeAll(async () => {
await warmModuleGraph({ graph: "cli/route-policy-write-editor", source: ROUTING_EDITOR_SCRIPT });
}, COLD_SPAWN_WARMUP_HOOK_BUDGET_MS);

test("actual routing editor preserves stale/create/update/alias/delete authority", async () => {
const childHome = join(home, "real-handler");
childCompleted = false;
const result = await spawnTestChild([process.execPath, "--eval", ROUTING_EDITOR_SCRIPT], { cwd: repoRoot(), deadlineMs: 20_000,
env: { PATH: process.env.PATH, SystemRoot: process.env.SystemRoot, NODE_ENV: "test", HOME: childHome,
USERPROFILE: childHome, APPDATA: childHome, XDG_CONFIG_HOME: childHome, OPENCODEX_HOME: childHome,
CODEX_HOME: join(childHome, "codex"), NO_COLOR: "1",
// The run-private cache the warm-up filled; without it this child transpiles cold again.
BUN_RUNTIME_TRANSPILER_CACHE_PATH: process.env.BUN_RUNTIME_TRANSPILER_CACHE_PATH },
});
childCompleted = !result.timedOut && result.reaped && result.pipesClosed;
// Keep child diagnostics in the failure instead of leaving a live process after Bun times out.
expect(result).toMatchObject({ exitCode: 0, stderr: "", timedOut: false, reaped: true, pipesClosed: true });
expect(JSON.parse(result.stdout.trim())).toEqual({ scenarios: 9, saves: 3, refreshes: 3, requests: 9, automationEnabled: false });
}, SPAWN_BUDGET_MS);
});
2 changes: 1 addition & 1 deletion tests/cli/cli-update-restart-child.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ function setup() {
const calls: string[] = [];
let now = 1000;
let exit: (() => void) | undefined;
const marker = { home: { config: { path: "/test/ocx", dev: 1, ino: 2 }, codex: { path: "/test/codex", dev: 1, ino: 3 }, revision: 0, serviceRecord: { schema: 1 as const, digest: "a".repeat(64) } }, version: "2.77.0", port: 10100, hostname: "127.0.0.1", deadlineAt: 5000 };
const marker = { home: { config: { path: "/test/ocx", dev: 1, ino: 2, fileId: "1:2" }, codex: { path: "/test/codex", dev: 1, ino: 3, fileId: "1:3" }, revision: 0, serviceRecord: { schema: 1 as const, digest: "a".repeat(64) } }, version: "2.77.0", port: 10100, hostname: "127.0.0.1", deadlineAt: 5000 };
const env = { [UPDATE_RESTART_CHILD_ENV]: JSON.stringify(marker), [OWNERSHIP_MUTATION_LEASE_TOKEN_ENV]: "parent-token" };
const io: UpdateRestartChildIo = {
checkState: () => {},
Expand Down
16 changes: 16 additions & 0 deletions tests/cli/cli-update-restart-home.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,22 @@ test("physical home identity detects same-path directory replacement", () => {
renameSync(h.config, h.config + "-old"); mkdirSync(h.config);
expect(() => checkHome(captured)).toThrow();
});
test("file ids beyond double precision still distinguish a replaced directory", () => {
// NTFS ids carry the MFT sequence in the high bits; a replacement directory often gets the
// neighbouring MFT index, and above 2^53 a double cannot tell the two ids apart.
home();
const original = 2n ** 58n + 4096n;
expect(Number(original + 1n)).toBe(Number(original));
let ino = original;
const deps = {
statDirectory: () => ({ dev: 7n, ino, isDirectory: () => true }),
supervision: { stat: () => ({ isFile: () => true, mode: 0o100755 }), platform: "darwin" as const, run: () => ({ status: 113, stdout: "", stderr: "" }) },
};
const captured = readUpdateRestartHome(deps);
expect(() => assertUpdateRestartHome(captured, Date.now() + 5000, deps)).not.toThrow();
ino = original + 1n;
expect(() => assertUpdateRestartHome(captured, Date.now() + 5000, deps)).toThrow("update_restart_home_changed");
});
test("canonical aliases identify the same directory", () => {
const h = home(); const captured = readUpdateRestartHome();
const alias = join(h.root, "alias"); symlinkSync(h.config, alias, process.platform === "win32" ? "junction" : "dir");
Expand Down
4 changes: 2 additions & 2 deletions tests/cli/cli-update-restart.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,15 @@ import { updateRestartVeto } from "../../src/update/restart-ownership";
import type { LiveProxy } from "../../src/server/proxy-liveness";

const candidate: UpdateRestartCandidate = {
home: { config: { path: "/test/ocx", dev: 1, ino: 2 }, codex: { path: "/test/codex", dev: 1, ino: 3 }, revision: 0, serviceRecord: { schema: 1 as const, digest: "a".repeat(64) } },
home: { config: { path: "/test/ocx", dev: 1, ino: 2, fileId: "1:2" }, codex: { path: "/test/codex", dev: 1, ino: 3, fileId: "1:3" }, revision: 0, serviceRecord: { schema: 1 as const, digest: "a".repeat(64) } },
target: { pid: 123, port: 10100, hostname: "127.0.0.1", source: "runtime", version: "2.76.0" },
runtime: { pid: 123, port: 10100, hostname: "127.0.0.1", attestationSecret: "a".repeat(43) },
cliVersion: "2.77.0",
};
function setup() {
const calls: string[] = [];
let now = 1000;
const home = { config: { path: "/test/ocx", dev: 1, ino: 2 }, codex: { path: "/test/codex", dev: 1, ino: 3 }, revision: 0, serviceRecord: { schema: 1 as const, digest: "a".repeat(64) } };
const home = { config: { path: "/test/ocx", dev: 1, ino: 2, fileId: "1:2" }, codex: { path: "/test/codex", dev: 1, ino: 3, fileId: "1:3" }, revision: 0, serviceRecord: { schema: 1 as const, digest: "a".repeat(64) } };
const live: LiveProxy = { ...candidate.target, pid: 456, version: "2.77.0" };
const io: UpdateRestartIo = {
now: () => now, acquire: () => { calls.push("acquire"); return { release: () => { calls.push("release"); } }; },
Expand Down
2 changes: 1 addition & 1 deletion tests/cli/system-restart-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ function successfulDeps() {
// Matches the /healthz fixture version below so the skew guard stays out of the way;
// the dedicated skew tests override it explicitly.
cliVersion: "test",
readUpdateHome: () => ({ config: { path: "/test/ocx", dev: 1, ino: 2 }, codex: { path: "/test/codex", dev: 1, ino: 3 }, revision: 0, serviceRecord: { schema: 1 as const, digest: "a".repeat(64) } }),
readUpdateHome: () => ({ config: { path: "/test/ocx", dev: 1, ino: 2, fileId: "1:2" }, codex: { path: "/test/codex", dev: 1, ino: 3, fileId: "1:3" }, revision: 0, serviceRecord: { schema: 1 as const, digest: "a".repeat(64) } }),
},
};
}
Expand Down
Loading
Loading