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
5,553 changes: 2,817 additions & 2,736 deletions scripts/ci/test-durations.tsv

Large diffs are not rendered by default.

5,228 changes: 2,653 additions & 2,575 deletions scripts/ci/test-durations.windows.tsv

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion scripts/test-layout/layout-file-identity.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"file-identity.test.ts": "lib",
"file-identity-callers.test.ts": "lib"
"file-identity-callers.test.ts": "lib",
"private-sqlite-path.test.ts": "lib"
}
45 changes: 31 additions & 14 deletions src/codex/routing/thread-affinity-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,15 @@ import { Database } from "bun:sqlite";
import { createHash, createHmac, randomBytes } from "node:crypto";
import {
chmodSync,
closeSync,
lstatSync,
openSync,
realpathSync,
type Stats,
} from "node:fs";
import { resolve } from "node:path";
import { getConfigDir } from "../../config/paths";
import { initializeConfigOwnership, recordOwnedConfigPath } from "../../lib/config-ownership";
import { sameFileIdentity, type FileIdentity } from "../../lib/file-identity";
import { isPassiveRead } from "../../lib/passive-read";
import { assertPrivateSqliteEntryOrMissing, assertPrivateSqliteSidecars, preparePrivateSqliteDatabase } from "../../lib/private-sqlite-path";
import {
assertStorageOwned,
currentSpendLedgerOwnerGeneration,
Expand Down Expand Up @@ -67,6 +66,8 @@ type Scope = {
generation: number;
token: SpendLedgerStorage;
db?: Database;
/** The database file the handle was opened on; every write transaction re-checks the path against it. */
file?: FileIdentity;
secret?: Uint8Array;
disabled: boolean;
poisoned: boolean;
Expand Down Expand Up @@ -135,13 +136,15 @@ function retire(): void {
warning();
}
}
function checkFile(path: string): Stats {
const stat = lstatSync(path);
// BigInt stats: NTFS file IDs exceed Number.MAX_SAFE_INTEGER, and a rounded identity can match a
// different file.
function checkFile(path: string) {
const stat = lstatSync(path, { bigint: true });
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
if (
!stat.isFile() ||
stat.isSymbolicLink() ||
stat.nlink !== 1 ||
(process.platform !== "win32" && stat.uid !== process.getuid!())
stat.nlink !== 1n ||
(process.platform !== "win32" && stat.uid !== BigInt(process.getuid!()))
)
throw new Error("unsafe file");
return stat;
Expand All @@ -158,19 +161,18 @@ function open(s: Scope): void {
for (const suffix of ["", "-journal", "-wal", "-shm"])
if (!recordOwnedConfigPath(s.home, path + suffix) && initializeConfigOwnership(s.home))
throw new Error("ownership");
try {
closeSync(openSync(path, "wx", 0o600));
} catch (e) {
if ((e as NodeJS.ErrnoException).code !== "EEXIST") throw e;
}
// Refuses a link or reparse point at the database or a sidecar before anything is created:
// Windows creates a new file through a link at its name.
preparePrivateSqliteDatabase(path);
const before = checkFile(path);
if (before.size > 8 * 1024 * 1024) throw new Error("oversized");
if (before.size > 8n * 1024n * 1024n) throw new Error("oversized");
if (process.platform !== "win32") chmodSync(path, 0o600);
if (!hardenSecretPath(path, { required: true }).ok) throw new Error("acl");
checkFile(path);
assertStorageOwned(s.token);
// SQLite creates sidecars in the private, hardened config directory, as with
// spend-ledger-owner.sqlite and config-mutation.sqlite.
assertPrivateSqliteSidecars(path);
db = new Database(path);
db.exec("PRAGMA busy_timeout = 0; BEGIN IMMEDIATE");
const version = (
Expand Down Expand Up @@ -254,8 +256,9 @@ function open(s: Scope): void {
).value;
db.exec("COMMIT");
const after = checkFile(path);
if (before.dev !== after.dev || before.ino !== after.ino) throw new Error("replaced");
if (!sameFileIdentity(before, after)) throw new Error("replaced");
s.db = db;
s.file = after;
s.secret = secret;
s.pruneTimer = setInterval(() => {
if (current === s) {
Expand All @@ -279,6 +282,18 @@ function open(s: Scope): void {
warning();
}
}
/**
* Refuse a write unless the database is still the single-link private file this handle opened and
* no sidecar name holds a link: the rollback journal is created per transaction, and a link added
* to the database since open would expose every later write.
*/
function assertWritable(s: Scope): void {
const path = spendLedgerStoragePath(s.token);
assertPrivateSqliteSidecars(path);
if (!assertPrivateSqliteEntryOrMissing(path)) throw new Error("missing");
const now = checkFile(path);
if (!s.file || !sameFileIdentity(now, s.file)) throw new Error("replaced");
}
function resolvedHome(acquiredHome?: string): string {
let home = resolve(acquiredHome ?? getConfigDir());
try {
Expand Down Expand Up @@ -473,6 +488,7 @@ function pruneOnly(s: Scope): boolean {
if (!s.db || s.disabled) return false;
try {
assertStorageOwned(s.token);
assertWritable(s);
s.db.exec("BEGIN IMMEDIATE");
// Pending touches first, so a pin used since its last persisted timestamp is not evicted as idle.
if (!s.poisoned) applyTouches(s, s.db);
Expand Down Expand Up @@ -517,6 +533,7 @@ function flush(s: Scope, clean: boolean): boolean {
const db = s.db;
for (let attempt = 0; attempt < 2; attempt++) try {
assertStorageOwned(s.token);
assertWritable(s);
db.exec("BEGIN IMMEDIATE");
// Touches go before the log as well as after it, so the pruning between chunks sees the live
// last-used times and cannot evict a pin that is in use but persisted with an old timestamp.
Expand Down
124 changes: 124 additions & 0 deletions src/lib/private-sqlite-path.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { closeSync, lstatSync, openSync } from "node:fs";

/** The files SQLite opens next to a database: the rollback journal, and the WAL and its index. */
export const SQLITE_SIDECAR_SUFFIXES = ["-journal", "-wal", "-shm"] as const;

/** A database or sidecar name holds something other than a private regular file. Carries no path. */
export class UnsafeSqlitePathError extends Error {
constructor() {
super("SQLite storage path is not a private regular file");
this.name = "UnsafeSqlitePathError";
}
}

const FILE_ATTRIBUTE_REPARSE_POINT = 0x400;
const INVALID_FILE_ATTRIBUTES = 0xffffffff;
type ReparseProbe = (path: string) => boolean;
let nativeAttributes: ((path: string) => number) | undefined;
let probeForTests: ReparseProbe | null = null;
/**
* Whether the native attribute query runs: on Windows, except ARM64, whose Bun build cannot execute
* `bun:ffi` (the limitation src/lib/windows-user-principal.ts works around). There `lstat` still
* reports symbolic links, junctions and app execution aliases as links; other reparse tags are not
* visible without native code.
*/
export function nativeReparseProbeApplies(platform: string, arch: string): boolean {
return platform === "win32" && arch !== "arm64";
}
// Read once at load: tests replace `process.platform` to drive Windows-only branches on POSIX, and
// the native query must follow the system the process actually runs on.
const NATIVE_ATTRIBUTES = nativeReparseProbeApplies(process.platform, process.arch);

function errorCode(error: unknown): unknown {
return error !== null && typeof error === "object" && "code" in error ? error.code : undefined;
}

/**
* Whether the entry at `path` itself is a Windows reparse point: a symbolic link, a junction, or any
* other tag. `GetFileAttributesW` reports the named entry rather than what it points to, so this
* sees what `lstat` cannot classify. Throws when the attribute cannot be read.
*/
export function isWindowsReparsePoint(path: string): boolean {
if (!nativeAttributes) {
// Loaded on first use, only on Windows; the repository's other Windows file checks use the same call.
const ffi = require("bun:ffi") as typeof import("bun:ffi");
const kernel = ffi.dlopen("kernel32.dll", {
GetFileAttributesW: { args: [ffi.FFIType.ptr], returns: ffi.FFIType.u32 },
});
Comment thread
lidge-jun marked this conversation as resolved.
Resolved
nativeAttributes = (name: string) => {
const wide = Buffer.from(`${name}\0`, "utf16le");
return kernel.symbols.GetFileAttributesW(ffi.ptr(wide)) >>> 0;
};
}
const attributes = nativeAttributes(path);
if (attributes === INVALID_FILE_ATTRIBUTES) throw new Error("file attributes unavailable");
return (attributes & FILE_ATTRIBUTE_REPARSE_POINT) !== 0;
}

/** Test seam: replace the reparse-point probe on every platform; null restores the default. */
export function setSqlitePathReparseProbeForTests(probe: ReparseProbe | null): void {
probeForTests = probe;
}

function isReparsePoint(path: string): boolean {
if (probeForTests) return probeForTests(path);
return NATIVE_ATTRIBUTES && isWindowsReparsePoint(path);
}

/**
* Refuse unless nothing is at `path` or it is a private regular file: not a link or other reparse
* point, not a directory, a single directory entry, and on POSIX owned by this user. Returns
* whether an entry exists. A probe that cannot answer refuses.
*/
export function assertPrivateSqliteEntryOrMissing(path: string): boolean {
let stat;
try {
stat = lstatSync(path);
} catch (error) {
if (errorCode(error) === "ENOENT") return false;
throw error;
}
let reparse: boolean;
try {
reparse = isReparsePoint(path);
} catch {
throw new UnsafeSqlitePathError();
}
if (
reparse
|| !stat.isFile()
|| stat.isSymbolicLink()
|| stat.nlink !== 1
|| (process.platform !== "win32" && stat.uid !== process.getuid!())
) throw new UnsafeSqlitePathError();
return true;
}

/**
* Refuse unless every sidecar of `databasePath` is absent or a private regular file. SQLite's POSIX
* VFS opens them with O_NOFOLLOW; its Windows VFS opens them by name with CreateFileW, which follows
* a link planted there. Check before opening the database and before every write transaction: the
* rollback journal is created and deleted per transaction. Something planted after the check, while
* SQLite opens the file, is outside what this can see.
*/
export function assertPrivateSqliteSidecars(databasePath: string): void {
for (const suffix of SQLITE_SIDECAR_SUFFIXES) assertPrivateSqliteEntryOrMissing(databasePath + suffix);
}

/**
* Make sure `databasePath` is a private regular file without creating anything through a link at
* its name or at a sidecar's. POSIX O_CREAT|O_EXCL refuses a link at the final component, but
* Windows CREATE_NEW follows a dangling file symlink and creates its target, so every entry is
* checked before the create. An entry planted between the check and the create is not covered;
* callers check the file again after hardening it.
*/
export function preparePrivateSqliteDatabase(databasePath: string): void {
assertPrivateSqliteSidecars(databasePath);
if (assertPrivateSqliteEntryOrMissing(databasePath)) return;
try {
closeSync(openSync(databasePath, "wx", 0o600));
} catch (error) {
if (errorCode(error) !== "EEXIST") throw error;
}
if (!assertPrivateSqliteEntryOrMissing(databasePath)) throw new UnsafeSqlitePathError();
}
9 changes: 6 additions & 3 deletions src/lib/spend-ledger-owner.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
/** Process-lifetime single-writer ownership for the shared spend journal. */
import { Database } from "bun:sqlite";
import { chmodSync, closeSync, lstatSync, mkdirSync, openSync, realpathSync } from "node:fs";
import { chmodSync, lstatSync, mkdirSync, realpathSync } from "node:fs";
import { join, resolve } from "node:path";
import { getConfigDir } from "../config/paths";
import { recordOwnedConfigPath } from "./config-ownership";
import { assertPrivateSqliteSidecars, preparePrivateSqliteDatabase } from "./private-sqlite-path";
import { assertNotRealHomeUnderTest } from "./test-home-guard";
import { hardenSecretDir, hardenSecretPath } from "./windows-secret-acl";

Expand Down Expand Up @@ -115,8 +116,9 @@ function prepareOwnerPath(configDir: string): { home: string; path: string } {
// its sidecars were left behind by a later uninstall because nothing recorded them.
recordOwnedConfigPath(home, path);
for (const suffix of OWNER_SIDECARS) recordOwnedConfigPath(home, `${path}${suffix}`);
try { closeSync(openSync(path, "wx", 0o600)); }
catch (error) { if (errorCode(error) !== "EEXIST") throw error; }
// Refuses a link or reparse point at the database or a sidecar before anything is created:
// Windows creates a new file through a link at its name.
preparePrivateSqliteDatabase(path);
assertPrivateFile(path);
if (process.platform !== "win32") chmodSync(path, 0o600);
hardenSecretPath(path, { required: true });
Expand Down Expand Up @@ -196,6 +198,7 @@ export function acquireSpendLedgerOwner(configDir = getConfigDir()): SpendLedger

let database: Database | undefined;
try {
assertPrivateSqliteSidecars(prepared.path);
database = new Database(prepared.path, { create: true });
database.exec(`PRAGMA locking_mode = NORMAL; PRAGMA busy_timeout = ${busyTimeout}; BEGIN IMMEDIATE`);
} catch (cause) {
Expand Down
2 changes: 1 addition & 1 deletion structure/providers/codex-pool-affinity.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ the single-writer lease is owned by [Responses spend](../transports/responses-sp

`src/codex/routing/thread-affinity-store.ts` restores eligible pool bindings after a graceful proxy restart for up to 24 hours idle. Session-bearing requests and session-less requests anchored on their immediate parent have derivable anchors. Deeper session-less chains, keys derived under another config home, legacy raw-parent keys and evicted anchors remain memory-only. Removed, re-logged, expired or identity-less accounts are not restored; normal selection and usability rules still apply.

The config home contains `codex-affinity.sqlite`, mode 0600 with a hardened Windows ACL. Its v1 schema has `meta` (store secret and clean flag), `threads` (scoped bindings), and `issuers` (conversation state issuers). Row keys are domain-separated HMACs of the cohort anchor under the store's own secret. Each row stores the local pool account id (the label that config.json and the credential store in this directory already key the account by, as the Claude affinity store does), an identity fence digest and timestamps. Session ids, conversation keys (raw or process-local), emails, ChatGPT account ids and tokens are not stored; a keyed digest of the pool id would hide nothing from a reader of this directory, which holds both the store secret and the account list. The identity fence derives from `quotaHistoryIdentity`, captured at bind and at issuance. Issuance requires both generation and `chatgptAccountId` to match the current record. `__main__` pins and issuers are never persisted. Request-owned keys (`isRequestOwnedAffinityKey`, from `src/codex/request-owned-affinity-key.ts`) are refused at the store's row-key derivation and anchor observer, so they are never anchored, written or restored, as the [request-owned persistence contract](codex-request-owned-affinity.md#persistence-contract) requires. An unknown restored issuer scrubs continuation state; uploaded files are refused when the stored account differs from the serving account, as for known account changes and prior-account moves.
The config home contains `codex-affinity.sqlite`, mode 0600 with a hardened Windows ACL. Before the store creates or opens the database, `src/lib/private-sqlite-path.ts` requires the database and its `-journal`, `-wal` and `-shm` names to be absent or private regular files: a symbolic link, junction or other Windows reparse point, directory, hard link or (on POSIX) foreign-owned file disables persistence without creating or writing what it names. On Windows ARM64, whose Bun build cannot run the native attribute query, only the symbolic links, junctions and app execution aliases that `lstat` reports as links are seen. On Windows ownership is not compared; the boundary there is the hardened ACL of the config directory and file. Before every write transaction the store checks the sidecar names again and that the database path is still the single-link file it opened; a refusal there is a failed flush: three in a row poison the store, and when the store is poisoned or its final flush is refused the clean flag stays unset, so the next owner discards rows. The check comes before the create because Windows creates a new file through a link at its name, and SQLite opens Windows sidecars by name; an entry planted between the check and SQLite's own open is outside this guarantee. `tests/codex-integration/codex-affinity-restart-matrix.test.ts` (which emulates the Windows create-through-link behaviour on other platforms) and `tests/lib/private-sqlite-path.test.ts` cover it. Its v1 schema has `meta` (store secret and clean flag), `threads` (scoped bindings), and `issuers` (conversation state issuers). Row keys are domain-separated HMACs of the cohort anchor under the store's own secret. Each row stores the local pool account id (the label that config.json and the credential store in this directory already key the account by, as the Claude affinity store does), an identity fence digest and timestamps. Session ids, conversation keys (raw or process-local), emails, ChatGPT account ids and tokens are not stored; a keyed digest of the pool id would hide nothing from a reader of this directory, which holds both the store secret and the account list. The identity fence derives from `quotaHistoryIdentity`, captured at bind and at issuance. Issuance requires both generation and `chatgptAccountId` to match the current record. `__main__` pins and issuers are never persisted. Request-owned keys (`isRequestOwnedAffinityKey`, from `src/codex/request-owned-affinity-key.ts`) are refused at the store's row-key derivation and anchor observer, so they are never anchored, written or restored, as the [request-owned persistence contract](codex-request-owned-affinity.md#persistence-contract) requires. An unknown restored issuer scrubs continuation state; uploaded files are refused when the stored account differs from the serving account, as for known account changes and prior-account moves.

The spend-ledger lease is the single-writer authority. The final releasing hook flushes pending work and marks the store clean before ownership is released; it does so even when the release happens inside a passive read, because it commits only work recorded outside one. A crash, failed final flush or poisoned store leaves the clean flag unset, so the next owner discards rows. Structural changes use an ordered write log; UPDATE-only touches read live timestamps at flush, avoiding stale timestamps and resurrection. The 24-hour idle limit bounds reuse. Expired rows are removed by the next prune that commits: one is attempted when the store opens at lease acquisition, on an hourly timer while it stays open, and after every failed flush, including in a poisoned store. A prune can still be delayed or fail: a lease acquired inside a passive read does not open the store, a store that failed to open is disabled and cannot prune, a prune transaction can roll back, and timers can run late. SQLite secure_delete overwrites freed pages in the database file, but not filesystem snapshots, backups or storage remapping; its transient rollback journal is removed after each commit. The store secret pseudonymizes row keys and does not protect integrity from a local process that can already read the config directory. Pruning also removes future-dated rows and bounds each table to 2,048 entries, evicting model detours first. Other passive reads do not open, adopt or mutate the store. Live affinity survives a lease change in one process. A persistable change to pins or issuers made while the home's database is not writable (between two leases of one process, or while a scope could not open it) marks that database stale; the next scope that opens it clears it before use, so a restart restores nothing rather than a row the process had replaced or deleted. This also drops rows for conversations the process never loaded, which loses pins but never revives one; a serving proxy holds one lease for its whole life and does not reach this path. Row keys and anchors carry across a lease change in the same home, including past a scope that failed to open, so deletes still find their rows after an anchor is evicted. A change made after the final release, before the process exits without taking the lease again, cannot be saved: the lease is gone and another process may already own the database, so the next process can restore the row that change replaced. Such a row still has to pass the identity fence and the resolver's usability checks, and a serving proxy releases its lease only after its listeners have stopped. A manual account switch deletes saved pins and keeps saved issuers, matching the live maps; a full reset deletes both.

Expand Down
Loading
Loading