Skip to content
Merged
85 changes: 65 additions & 20 deletions src/codex/account-store.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { createHash } from "node:crypto";
import { closeSync, existsSync, readFileSync, mkdirSync, openSync, unlinkSync, writeFileSync } from "node:fs";
import { closeSync, existsSync, fstatSync, readFileSync, mkdirSync, openSync, statSync, unlinkSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import {
ConfigMutationLockError,
Expand Down Expand Up @@ -655,10 +655,43 @@ function isRefreshLockStale(path: string): boolean {
const parsed = JSON.parse(readFileSync(path, "utf-8")) as { acquiredAt?: unknown };
return typeof parsed.acquiredAt !== "number" || Date.now() - parsed.acquiredAt > REFRESH_LOCK_STALE_MS;
} catch {
return true;
// The owner creates the file and writes its metadata in two steps, so a live lock is
// briefly unreadable. Age the file itself instead of calling that window stale, which
// let a waiter delete a lock whose owner was still inside its critical section.
try {
return Date.now() - statSync(path).mtimeMs > REFRESH_LOCK_STALE_MS;
Comment thread
luvs01 marked this conversation as resolved.
Comment thread
luvs01 marked this conversation as resolved.
Comment thread
luvs01 marked this conversation as resolved.
} catch {
return false;
}
}
}

function releaseCodexRefreshFileLock(path: string, fd: number): void {
let owned: { dev: bigint; ino: bigint } | null = null;
try {
const info = fstatSync(fd, { bigint: true });
if (info.dev >= 0n && info.ino > 0n) owned = { dev: info.dev, ino: info.ino };
} catch { /* Unknown descriptor identity never authorizes unlink. */ }
try {
withConfigMutationLockSync(() => {
let current: { dev: bigint; ino: bigint } | null = null;
try {
const info = statSync(path, { bigint: true });
if (info.dev >= 0n && info.ino > 0n) current = { dev: info.dev, ino: info.ino };
} catch { /* Keep the lock and the callback outcome when the path probe fails. */ }
if (owned && current && current.dev === owned.dev && current.ino === owned.ino) {
try { unlinkSync(path); } catch (err) {
if (errCode(err) !== "ENOENT") throw err;
}
}
});
} catch (err) {
// Keep the descriptor alive through comparison/unlink so its inode cannot be recycled.
// Unavailable coordination leaves the path without masking the completed refresh.
if (!(err instanceof ConfigMutationLockError)) throw err;
} finally { closeSync(fd); }
}

export async function withCodexRefreshFileLock<T>(lockKey: string, signal: AbortSignal, fn: () => Promise<T>): Promise<T> {
hardenConfigDir();
const dir = getConfigDir();
Expand All @@ -670,33 +703,45 @@ export async function withCodexRefreshFileLock<T>(lockKey: string, signal: Abort
while (fd == null) {
if (signal.aborted) throw signal.reason;
try {
fd = openSync(path, "wx", 0o600);
writeFileSync(fd, JSON.stringify({ acquiredAt: Date.now(), pid: process.pid }) + "\n");
break;
} catch (err) {
if (errCode(err) !== "EEXIST") throw err;
if (isRefreshLockStale(path)) {
// Serialize only metadata operations, never the async refresh callback. Cooperating
// contenders cannot reclaim a successor between stale observation and path mutation.
withConfigMutationLockSync(() => {
try {
unlinkSync(path);
} catch (unlinkErr) {
if (errCode(unlinkErr) !== "ENOENT") throw unlinkErr;
fd = openSync(path, "wx", 0o600);
writeFileSync(fd, JSON.stringify({ acquiredAt: Date.now(), pid: process.pid }) + "\n");
} catch (err) {
if (fd != null) {
const failedFd = fd;
fd = null;
try { releaseCodexRefreshFileLock(path, failedFd); } catch { /* Preserve write failure. */ }
throw err;
}
if (errCode(err) !== "EEXIST") throw err;
if (isRefreshLockStale(path)) {
try { unlinkSync(path); } catch (unlinkErr) {
if (errCode(unlinkErr) !== "ENOENT") throw unlinkErr;
}
}
}
continue;
});
} catch (err) {
// A failed SQLite commit can follow successful file creation; it still owns an fd.
if (fd != null) {
const failedFd = fd;
fd = null;
try { releaseCodexRefreshFileLock(path, failedFd); } catch { /* Preserve admission failure. */ }
}
if (Date.now() >= deadline) throw new CodexCredentialRefreshLockTimeoutError();
await sleep(REFRESH_LOCK_POLL_MS, signal);
if (!(err instanceof ConfigMutationLockError)) throw err;
}
if (fd != null) break;
if (Date.now() >= deadline) throw new CodexCredentialRefreshLockTimeoutError();
await sleep(REFRESH_LOCK_POLL_MS, signal);
}

try {
return await fn();
} finally {
if (fd != null) closeSync(fd);
try {
unlinkSync(path);
} catch (err) {
if (errCode(err) !== "ENOENT") throw err;
}
releaseCodexRefreshFileLock(path, fd);
}
}

Expand Down
3 changes: 3 additions & 0 deletions structure/catalog.md
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,9 @@ Pool mode routes across main plus added Codex credentials. Key rules:
generation it started from still holds; a lost race raises a generation-conflict error rather
than overwriting the newer credential (`src/codex/account-store.ts`). Callers handle that error;
they do not assume a silent retry.
The lock itself is identity-scoped: a not-yet-readable lock counts as held until it ages out,
and release requires a usable matching descriptor identity. Unknown identity leaves the path
for stale recovery without replacing the callback outcome when the path probe fails; confirmed-owner unlink errors other than `ENOENT` still propagate. Acquisition, stale reclamation and identity-checked release run inside the existing synchronous SQLite config-mutation transaction; the async refresh callback runs outside it. Release keeps the descriptor open through identity comparison and any unlink, then closes it. Failed metadata writes remove only a matching owned path after successful coordination; unknown identity, failed probes or unavailable coordination retain the path for stale recovery. Busy release coordination preserves the callback outcome and leaves the path for stale recovery. This serializes cooperating writers; stat/unlink is not atomic against non-cooperating filesystem writers.
- **Authentication identity, quota domain, and cache domain are tracked separately**
(`src/routing/identity-domains.ts`). `classifyCredential` returns all three with provenance:
`pool.credentialGroups` supplies operator-declared quota domains, a small built-in table
Expand Down
2 changes: 2 additions & 0 deletions structure/codex-home.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# Codex Home

A lock in the Codex credential store is governed by [descriptor identity and age](catalog.md#accounts-namespaces-and-pool-rotation), so the mere presence of its filename is neither acquisition nor release authority. Failed path-identity probes leave the lock for stale recovery and preserve the refresh callback outcome. Cooperating lock metadata changes serialize through the existing SQLite mutation transaction; release keeps the descriptor open through identity comparison and any unlink, then closes it. Failed metadata writes remove only a matching owned path after successful coordination; unknown identity, failed probes or unavailable coordination retain the path for stale recovery. Async refresh work holds no metadata transaction.

## Codex home

`src/codex/paths.ts` resolves Codex state from `CODEX_HOME` when set and valid, otherwise from
Expand Down
2 changes: 2 additions & 0 deletions structure/config.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# Config Surface

Configuration consumers retain the [refresh-lock ownership boundary](catalog.md#accounts-namespaces-and-pool-rotation); failing to establish a usable matching lock identity does not authorize deleting its path or replacing the refresh callback outcome with a path-probe error. Cooperating lock metadata changes serialize through the existing SQLite mutation transaction; release keeps the descriptor open through identity comparison and any unlink, then closes it. Failed metadata writes remove only a matching owned path after successful coordination; unknown identity, failed probes or unavailable coordination retain the path for stale recovery. Async refresh work holds no metadata transaction.

The configuration-only [plaintext V2 contract](subagents.md#plaintext-v2-agent-messages)
is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged.

Expand Down
2 changes: 1 addition & 1 deletion structure/gui-and-management-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ is scoped to canonical ChatGPT Responses forwarding; other source-area behavior

## Dashboard serving

The bundled React dashboard is built into `gui/dist` and served by the same Bun proxy. `ocx gui` starts
Account refresh actions follow the [credential refresh-lock identity contract](catalog.md#accounts-namespaces-and-pool-rotation): a held unreadable lock is distinct from one this process may release, and path-probe errors preserve the callback outcome. Cooperating lock metadata changes serialize through the existing SQLite mutation transaction; release keeps the descriptor open through identity comparison and any unlink, then closes it. Failed metadata writes remove only a matching owned path after successful coordination; unknown identity, failed probes or unavailable coordination retain the path for stale recovery. Async refresh work holds no metadata transaction. The bundled React dashboard is built into `gui/dist` and served by the same Bun proxy. `ocx gui` starts
the proxy when needed and opens `http://localhost:<port>`, or `http://127.0.0.1:<management port>` when `hub.managementIngress.enabled` is true — see [the hub management dashboard address](runtime.md#hub-management-dashboard-address).

All ordinary HTTP responses (excluding successful WebSocket upgrades) include `X-Frame-Options: DENY` and
Expand Down
2 changes: 2 additions & 0 deletions structure/ops/docs-and-release.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# Docs And Release

Refresh-lock validation covers fresh unreadable locks, descriptor-matched release, path-probe failures preserving callback outcomes, and confirmed-owner unlink error handling in `tests/codex-integration/codex-account-store.test.ts`; the [catalog contract](../catalog.md#accounts-namespaces-and-pool-rotation) explicitly does not promise atomic compare-and-delete. Cooperating lock metadata changes serialize through the existing SQLite mutation transaction; release keeps the descriptor open through identity comparison and any unlink, then closes it. Failed metadata writes remove only a matching owned path after successful coordination; unknown identity, failed probes or unavailable coordination retain the path for stale recovery. Async refresh work holds no metadata transaction.

The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages)
is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged.

Expand Down
10 changes: 8 additions & 2 deletions structure/providers/openai-tiers.md
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,12 @@ Pool mode needs stable public names and a store that survives concurrent refresh
- The credential store is generation-guarded and refresh-locked (`src/codex/account-store.ts`): a
refresh persists only if the generation it started from still holds, and a lost race raises a
generation-conflict error instead of overwriting the newer credential.
The lock is held and released by file identity rather than by path. A lock that exists but is
not yet readable counts as held until it ages past the stale window, because its owner creates
the file and writes its metadata as two steps, and a holder deletes the lock only while the
path still resolves to the file it created. If descriptor identity is unavailable or unusable,
release leaves the path for stale-lock recovery. Path-probe errors preserve the callback outcome; confirmed-owner unlink errors other than `ENOENT` still propagate. The stat/unlink pair is not an atomic
compare-and-delete against non-cooperating writers. Cooperating acquisition, stale reclamation and release serialize inside the synchronous config-mutation transaction, released before the async callback. Release keeps its descriptor open through identity comparison and any unlink, then closes it. Failed metadata writes remove only a matching owned path after successful coordination; unknown identity, failed probes or unavailable coordination retain the path for stale recovery.

## Sidecars, management, and UI

Expand Down Expand Up @@ -560,8 +566,8 @@ consulted, and they run in `resolveCodexAccountForThreadDetailed` ahead of it. A
with no recorded refusal is deliberately not a release path on its own — stickiness until the
account actually refuses is intended — but it does surrender the binding as soon as a sibling with
headroom exists. Unbound assignment is untouched and still takes the coolest eligible account,
because a fresh request has no warm prefix to lose. `pool.cacheAffinity` remains the stronger
opt-in, raising the bar from the threshold to genuine exhaustion.
because a fresh request has no warm prefix to lose. `pool.cacheAffinity` is enabled by default,
raising the bar from the threshold to genuine exhaustion.

Two call sites need the rule — the live path in `reevaluateAffinityQuota` and the side-effect-free
`previewReusableAffinityAccount` that subagent fallback reads — and they share one helper rather
Expand Down
2 changes: 2 additions & 0 deletions structure/runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
Responses admission and finalization are composed through the
[core module ownership](transports/responses.md#core-module-ownership). This surface retains its existing behavior.

OAuth refresh coordination follows the [refresh-lock identity contract](catalog.md#accounts-namespaces-and-pool-rotation): a fresh unreadable lock remains held, and release requires matching descriptor identity. A failed path-identity probe preserves the refresh callback outcome. Cooperating lock metadata changes serialize through the existing SQLite mutation transaction; release keeps the descriptor open through identity comparison and any unlink, then closes it. Failed metadata writes remove only a matching owned path after successful coordination; unknown identity, failed probes or unavailable coordination retain the path for stale recovery. Async refresh work holds no metadata transaction.

The configuration-only [plaintext V2 contract](subagents.md#plaintext-v2-agent-messages)
is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged.

Expand Down
2 changes: 2 additions & 0 deletions structure/subagents.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
Encrypted-task and fallback request handling follow the Responses
[core module ownership](transports/responses.md#core-module-ownership). This surface retains its existing behavior.

Concurrent refreshes triggered by independent agent work share the [credential refresh-lock contract](catalog.md#accounts-namespaces-and-pool-rotation); unknown lock identity remains available for stale recovery rather than immediate removal, and a failed path probe cannot mask the callback outcome. Cooperating lock metadata changes serialize through the existing SQLite mutation transaction; release keeps the descriptor open through identity comparison and any unlink, then closes it. Failed metadata writes remove only a matching owned path after successful coordination; unknown identity, failed probes or unavailable coordination retain the path for stale recovery. Async refresh work holds no metadata transaction.

## Plaintext V2 agent messages

`src/responses/plaintext-v2-agent-messages.ts` owns the experimental, configuration-only
Expand Down
Loading
Loading