diff --git a/src/codex/auth-context.ts b/src/codex/auth-context.ts index 21c36632a..8f1416715 100644 --- a/src/codex/auth-context.ts +++ b/src/codex/auth-context.ts @@ -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 }); diff --git a/src/codex/native-profile-manager.ts b/src/codex/native-profile-manager.ts index e4dd68db1..34d7b6867 100644 --- a/src/codex/native-profile-manager.ts +++ b/src/codex/native-profile-manager.ts @@ -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 { return this.withLock(() => this.sweepStagesLocked()); } diff --git a/src/codex/native-profile-startup.ts b/src/codex/native-profile-startup.ts index 28462ea76..8b4eba3c0 100644 --- a/src/codex/native-profile-startup.ts +++ b/src/codex/native-profile-startup.ts @@ -130,6 +130,14 @@ function ownerBlockedReason(owner: NativeMainOwnerSnapshot): "owner-conflict" | async function runOwnedStageSweep(entry: StartupEntry): Promise { if (typeof (entry.manager as Partial).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).stageSweepRequired === "function" + && !entry.manager.stageSweepRequired() + ) return true; try { const result = await withNativeMainOwnerOperation(entry.manager.context, () => entry.manager.sweepStages()); return !result.plaintextMayRemain; diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts index e88072e3b..1b3ed2232 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -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, @@ -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)), { diff --git a/structure/02_config-and-codex-home.md b/structure/02_config-and-codex-home.md index 9610fdbb7..2234a3f1a 100644 --- a/structure/02_config-and-codex-home.md +++ b/structure/02_config-and-codex-home.md @@ -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 diff --git a/tests/codex-auth-context.test.ts b/tests/codex-auth-context.test.ts index c3210902c..8a3f1fa43 100644 --- a/tests/codex-auth-context.test.ts +++ b/tests/codex-auth-context.test.ts @@ -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, @@ -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"); diff --git a/tests/native-profile-drain-server.test.ts b/tests/native-profile-drain-server.test.ts index a59256c17..17b3fe271 100644 --- a/tests/native-profile-drain-server.test.ts +++ b/tests/native-profile-drain-server.test.ts @@ -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"; @@ -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(); diff --git a/tests/native-profile-stage-lifecycle.test.ts b/tests/native-profile-stage-lifecycle.test.ts index d262d0569..dd521089e 100644 --- a/tests/native-profile-stage-lifecycle.test.ts +++ b/tests/native-profile-stage-lifecycle.test.ts @@ -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): Promise { try { await operation(); @@ -99,6 +125,42 @@ async function waitFor(predicate: () => boolean, timeoutMs = 5_000): Promise { + 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");