Skip to content
Closed
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: 5 additions & 2 deletions src/codex/auth-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,15 +112,18 @@ export class CodexPoolAuthenticationError extends Error {
}
}

export const CODEX_MAIN_PROFILE_MAINTENANCE_MESSAGE =
"OpenCodex local native-main profile maintenance is active; retry this request";

export class CodexMainProfileDrainingError extends Error {
constructor() {
super("Native Codex main profile is switching; retry this request");
super(CODEX_MAIN_PROFILE_MAINTENANCE_MESSAGE);
this.name = "CodexMainProfileDrainingError";
}
}

export function codexMainProfileDrainingResponse(): Response {
const response = formatErrorResponse(503, "server_busy", "Native Codex main profile is switching; retry this request");
const response = formatErrorResponse(503, "server_busy", CODEX_MAIN_PROFILE_MAINTENANCE_MESSAGE);
const headers = new Headers(response.headers);
headers.set("Retry-After", "1");
return new Response(response.body, { status: response.status, headers });
Expand Down
19 changes: 19 additions & 0 deletions src/codex/native-profile-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -913,6 +913,25 @@ export class NativeProfileManager {
return { removed, live, cleanupFailed, plaintextMayRemain };
}

/**
* Whether startup or the periodic cleaner has any stage state to inspect.
*
* Absence is the only lock-free result. Any entry or observation failure
* keeps the existing fail-closed sweep, so an unsafe/unreadable stage path
* can never be mistaken for an unused profile subsystem.
*/
stageSweepRequired(): boolean {
for (const path of [this.context.stageRegistryPath, this.context.stagingRoot]) {
try {
lstatSync(path);
return true;
} catch (error) {
if (errorCode(error) !== "ENOENT") return true;
}
}
return false;
}

async sweepStages(): Promise<NativeStageSweepResult> {
return this.withLock(() => this.sweepStagesLocked());
}
Expand Down
8 changes: 8 additions & 0 deletions src/codex/native-profile-startup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,14 @@ function ownerBlockedReason(owner: NativeMainOwnerSnapshot): "owner-conflict" |

async function runOwnedStageSweep(entry: StartupEntry): Promise<boolean> {
if (typeof (entry.manager as Partial<NativeProfileManager>).sweepStages !== "function") return true;
// A fresh installation has no stage registry or staging tree. Avoid creating
// and contending on the profile transaction database for an inert subsystem.
// Real managers treat every present or uncertain artifact as sweep-required;
// partial test/library managers without the preflight keep the old behavior.
if (
typeof (entry.manager as Partial<NativeProfileManager>).stageSweepRequired === "function"
&& !entry.manager.stageSweepRequired()
) return true;
try {
const result = await withNativeMainOwnerOperation(entry.manager.context, () => entry.manager.sweepStages());
return !result.plaintextMayRemain;
Expand Down
3 changes: 2 additions & 1 deletion src/server/claude-messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import { responseWithDeferredRequestLog } from "./relay";
import { handleResponses } from "./responses";
import type { AdmissionLease } from "../lib/admission";
import { tryClaimNativeMainProfileForTurn } from "../codex/native-main-admission";
import { CODEX_MAIN_PROFILE_MAINTENANCE_MESSAGE } from "../codex/auth-context";
import {
createTranslatorBudget,
finalizeTranslatorBudgetResponse,
Expand Down Expand Up @@ -765,7 +766,7 @@ async function handleClaudeMessagesWithBudget(
// share a backoff hint when the upstream omitted the header.
const nativeMainFence = response.status === 503
&& upstreamRetryAfter?.trim() === "1"
&& message === "Native Codex main profile is switching; retry this request";
&& message === CODEX_MAIN_PROFILE_MAINTENANCE_MESSAGE;
const transient = !nativeMainFence && isTransientUpstreamStatus(response.status);
const outStatus = nativeMainFence ? 503 : transient ? 529 : response.status;
const out = new Response(JSON.stringify(anthropicErrorBody(outStatus, message)), {
Expand Down
14 changes: 14 additions & 0 deletions structure/02_config-and-codex-home.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,20 @@ trust boundary and already has direct access to active native credentials. OpenC
fails closed on file identities that change during an operation, but it does not claim isolation
from a malicious process already running as that same trusted OS account.

Startup and the periodic stage cleaner do not acquire the profile transaction lock when both the
stage registry and this instance's staging tree are proven absent. This keeps an unused profile
subsystem from fencing native traffic or creating lock contention. Presence, an unsafe entry type,
or any observation error still takes the locked sweep and fails closed; the fast path is based only
on proven absence, never on an unreadable path.

[Decision Log]
- 목적과 의도: Keep zero-profile and zero-stage installations out of the native-profile transaction path without weakening staged-credential cleanup.
- 기존 구현 및 제약 조건: Every live server swept stages at startup and every minute, and a failed sweep closed the global native-main gate even when no stage artifact existed.
- 검토한 주요 대안: Disable native-main ownership entirely when the vault is empty, add a stale-lock deletion command, or skip only the stage sweep when both artifact paths are absent.
- 선택한 방식: Preserve owner and claim protection, but bypass `sweepStages()` only after proving the registry and staging tree are both absent.
- 다른 대안 대신 이 방식을 선택한 이유: Physical credential ownership remains cross-process safe, while an inert optional subsystem can no longer create the reported lock/recovery catch-22.
- 장점, 단점 및 영향: Fresh installs avoid the SQLite profile lock; any present or uncertain stage state retains the existing locked fail-closed cleanup and recovery behavior.

OpenCodex never overrides an explicit `CODEX_HOME`. On Windows, `ocx doctor` and `ocx status`
nevertheless diagnose the high-confidence Orca dual-home case: both `CODEX_HOME` and
`ORCA_CODEX_HOME` select Orca's `orca/codex-runtime-home/home`, while the ChatGPT/Codex app uses the
Expand Down
14 changes: 14 additions & 0 deletions tests/codex-auth-context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@ import { join } from "node:path";
import {
applyCodexAuthContextToProvider,
assertCodexAuthContextNotCooled,
CODEX_MAIN_PROFILE_MAINTENANCE_MESSAGE,
CodexAccountCooldownError,
CodexAuthContextError,
CodexDirectAuthenticationError,
CodexMainProfileDrainingError,
CodexPoolAuthenticationError,
CodexThreadAffinityExpiredError,
codexMainProfileDrainingResponse,
cooldownErrorMessage,
cooldownErrorResponse,
headersForCodexAuthContext,
Expand Down Expand Up @@ -1222,6 +1224,18 @@ describe("Codex auth context", () => {
// to say who is cooled, until when, and how to escape — without leaking the raw id to a
// possibly remote data-plane client.
describe("cooldown error surface", () => {
test("native-main maintenance identifies OpenCodex instead of upstream capacity", async () => {
const response = codexMainProfileDrainingResponse();

expect(response.status).toBe(503);
expect(response.headers.get("Retry-After")).toBe("1");
const body = await response.json() as { error?: { message?: string; code?: string } };
expect(body.error).toMatchObject({
message: CODEX_MAIN_PROFILE_MAINTENANCE_MESSAGE,
code: "server_is_overloaded",
});
});

test("message names the account, deadline, source, and escape command", () => {
const until = Date.parse("2026-07-26T10:00:00.000Z");
const err = new CodexAccountCooldownError("acct_9f3c21", until, "reset-derived");
Expand Down
3 changes: 2 additions & 1 deletion tests/native-profile-drain-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { clearAccountQuota, updateAccountQuota } from "../src/codex/quota";
import { clearThreadAccountMap } from "../src/codex/routing";
import { saveConfig } from "../src/config";
import { handleNativeProfileAPI } from "../src/codex/native-profile-api";
import { CODEX_MAIN_PROFILE_MAINTENANCE_MESSAGE } from "../src/codex/auth-context";
import type { NativeProfileManager } from "../src/codex/native-profile-manager";
import { waitForNativeMainStartupGate } from "../src/codex/native-profile-startup";
import { startServer } from "../src/server";
Expand Down Expand Up @@ -148,7 +149,7 @@ describe("native main profile scoped server admission", () => {
mainWs.addEventListener("open", () => resolve(), { once: true });
mainWs.addEventListener("error", () => reject(new Error("websocket failed to open")), { once: true });
});
const mainRejected = waitForFrame(mainWs, "main profile is switching");
const mainRejected = waitForFrame(mainWs, CODEX_MAIN_PROFILE_MAINTENANCE_MESSAGE);
mainWs.send(JSON.stringify({ type: "response.create", model: "gpt-test", input: "hello" }));
expect(await mainRejected).toContain("503");
mainWs.close();
Expand Down
62 changes: 62 additions & 0 deletions tests/native-profile-stage-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,32 @@ function fixture() {
};
}

function emptyFixture(options: { lockUnavailable?: boolean } = {}) {
const root = mkdtempSync(join(tmpdir(), "ocx-empty-stage-lifecycle-"));
roots.push(root);
const codexHome = join(root, "codex");
const configDir = join(root, "opencodex");
mkdirSync(codexHome, { recursive: true });
mkdirSync(configDir, { recursive: true });
let profileLockAttempts = 0;
const unavailable = Object.assign(new Error("injected unavailable profile lock"), { code: "EACCES" });
const manager = new NativeProfileManager({
codexHome,
configDir,
keyProvider: new MemoryKeyProvider(),
atomicWrite: atomic,
hardenPath: async () => {},
processProbe: async () => ({ status: "clear", count: 0 }),
...(options.lockUnavailable ? {
stableLockOpen: () => {
profileLockAttempts += 1;
throw unavailable;
},
} : {}),
});
return { root, manager, profileLockAttempts: () => profileLockAttempts };
}

async function caught(operation: () => Promise<unknown>): Promise<NativeProfileError> {
try {
await operation();
Expand All @@ -99,6 +125,42 @@ async function waitFor(predicate: () => boolean, timeoutMs = 5_000): Promise<voi
}

describe("native main stage writer lifecycle", () => {
test("zero-profile startup stays ready without acquiring the profile transaction lock (#1120)", async () => {
const f = emptyFixture({ lockUnavailable: true });
expect(await f.manager.list()).toMatchObject({ activeProfileId: null, profiles: [] });
expect(f.manager.stageSweepRequired()).toBe(false);

const lifecycle = startNativeMainStartupLifecycle({
manager: f.manager,
owner: { retryMs: 10, hardenPath: async () => {} },
stageSweepIntervalMs: 20,
});
lifecycles.push(lifecycle);

expect(await lifecycle.settled).toMatchObject({ status: "ready" });
await Bun.sleep(75);
expect(f.profileLockAttempts()).toBe(0);
});

test("a present stage artifact keeps the locked fail-closed sweep (#1120)", async () => {
const f = emptyFixture({ lockUnavailable: true });
mkdirSync(f.manager.context.stagingRoot, { recursive: true });
expect(f.manager.stageSweepRequired()).toBe(true);

const lifecycle = startNativeMainStartupLifecycle({
manager: f.manager,
owner: { retryMs: 10, hardenPath: async () => {} },
stageSweepIntervalMs: 10_000,
});
lifecycles.push(lifecycle);

expect(await lifecycle.settled).toMatchObject({
status: "blocked",
reason: "stage-cleanup-required",
});
expect(f.profileLockAttempts()).toBeGreaterThan(0);
});

test("requires the writer token and heartbeat protects a live login beyond its original lease", async () => {
const f = fixture();
await f.manager.register("source");
Expand Down
Loading