diff --git a/src/lib/windows-secret-acl.ts b/src/lib/windows-secret-acl.ts index e5ce2eee9..fa898c3b2 100644 --- a/src/lib/windows-secret-acl.ts +++ b/src/lib/windows-secret-acl.ts @@ -363,7 +363,11 @@ async function describeAclStateAfterTimeoutAsync(targetPath: string, deadline: n function timeoutMemoKey(targetPath: string, opts: HardenOptions): string { // Destination-path memo only (issue #612). Never a parent directory — directory ACLs // are not authoritative for newly created temps. - return opts.timeoutMemoKey ?? targetPath; + // + // Namespace by required-ness (#766): a soft `required:false` timeout during loadConfig + // must not poison a later `required:true` management-token harden of the same path. + const base = opts.timeoutMemoKey ?? targetPath; + return `${opts.required ? "required" : "optional"}:${base}`; } /** diff --git a/src/server/management-auth.ts b/src/server/management-auth.ts index 52fd46401..164b843f0 100644 --- a/src/server/management-auth.ts +++ b/src/server/management-auth.ts @@ -56,7 +56,11 @@ function assertSafeDirectory(path: string): void { if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("management token directory is not a regular directory"); chmodSync(path, 0o700); const hardened = hardenSecretDir(path, { required: true }); - if (!hardened.ok) throw new Error("management token directory ACL hardening did not complete"); + if (!hardened.ok) { + throw new Error( + "management token directory ACL hardening did not complete; set OPENCODEX_ADMIN_AUTH_TOKEN to use an environment token instead of a file-backed token", + ); + } } function readExistingToken(path: string): string { @@ -66,7 +70,11 @@ function readExistingToken(path: string): string { } chmodSync(path, 0o600); const hardened = hardenSecretPath(path, { required: true }); - if (!hardened.ok) throw new Error("management token file ACL hardening did not complete"); + if (!hardened.ok) { + throw new Error( + "management token file ACL hardening did not complete; set OPENCODEX_ADMIN_AUTH_TOKEN to use an environment token instead of a file-backed token", + ); + } const token = readFileSync(path, "utf8").trim(); if (!/^ocx_admin_[A-Za-z0-9_-]{43}$/.test(token)) throw new Error("management token file is invalid"); return token; @@ -90,7 +98,11 @@ function createTokenFile(path: string): string { fd = null; chmodSync(temporary, 0o600); const temporaryHardened = hardenSecretPath(temporary, { required: true }); - if (!temporaryHardened.ok) throw new Error("management token temporary ACL hardening did not complete"); + if (!temporaryHardened.ok) { + throw new Error( + "management token temporary ACL hardening did not complete; set OPENCODEX_ADMIN_AUTH_TOKEN to use an environment token instead of a file-backed token", + ); + } try { linkSync(temporary, path); linked = true; @@ -99,7 +111,11 @@ function createTokenFile(path: string): string { throw error; } const finalHardened = hardenSecretPath(path, { required: true }); - if (!finalHardened.ok) throw new Error("management token file ACL hardening did not complete"); + if (!finalHardened.ok) { + throw new Error( + "management token file ACL hardening did not complete; set OPENCODEX_ADMIN_AUTH_TOKEN to use an environment token instead of a file-backed token", + ); + } return token; } catch (error) { if (linked) removeBestEffort(path); @@ -190,7 +206,11 @@ export function requireManagementAuth( config?: OcxConfig, ): Response | null { if (!state.available) { - return Response.json({ error: "management API unavailable" }, { status: 503 }); + return Response.json({ + error: "management API unavailable", + reason: state.reason, + hint: "Set OPENCODEX_ADMIN_AUTH_TOKEN to bypass file-backed admin token ACL hardening", + }, { status: 503 }); } const actual = req.headers.get("x-opencodex-api-key")?.trim() || req.headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim(); diff --git a/tests/server-management-auth.test.ts b/tests/server-management-auth.test.ts index 1484eb2eb..322c1b60c 100644 --- a/tests/server-management-auth.test.ts +++ b/tests/server-management-auth.test.ts @@ -16,6 +16,7 @@ import { resetHardenedStateForTests, setIcaclsRunnerForTests, setPlatformForTests, + hardenSecretDir, } from "../src/lib/windows-secret-acl"; const previousHome = process.env.OPENCODEX_HOME; @@ -204,7 +205,11 @@ describe("management and data-plane credential separation", () => { headers: { "x-opencodex-api-key": "ocx_admin_unhardened" }, }); expect(management.status).toBe(503); - expect(await management.json()).toEqual({ error: "management API unavailable" }); + const body = await management.json() as { error?: string; hint?: string; reason?: string }; + expect(body.error).toBe("management API unavailable"); + expect(body.hint).toContain("OPENCODEX_ADMIN_AUTH_TOKEN"); + expect(typeof body.reason).toBe("string"); + expect(body.reason!.length).toBeGreaterThan(0); } finally { await server.stop(true); } @@ -436,4 +441,99 @@ describe("management and data-plane credential separation", () => { await server.stop(true); } }); + + test("directory ACL timeout keeps management unavailable and names OPENCODEX_ADMIN_AUTH_TOKEN", async () => { + delete process.env.OPENCODEX_ADMIN_AUTH_TOKEN; + saveConfig(remoteConfig()); + const adminToken = `ocx_admin_${"d".repeat(43)}`; + writeFileSync(join(testHome, "admin-api-token"), `${adminToken}\n`, { mode: 0o600 }); + process.env.USERNAME ??= "tester"; + setPlatformForTests("win32"); + setIcaclsRunnerForTests(args => { + const target = args[0] ?? ""; + if (target.endsWith("admin-api-token")) { + return { success: true, exitCode: 0, timedOut: false, stdout: "" }; + } + return { success: false, exitCode: null, timedOut: true, stdout: "" }; + }); + resetHardenedStateForTests(); + const state = initializeManagementAuthState(remoteConfig()); + expect(state.available).toBe(false); + if (state.available) return; + expect(state.reason).toContain("OPENCODEX_ADMIN_AUTH_TOKEN"); + + const server = startServer(0); + try { + const settings = await fetch(new URL("/api/settings", server.url), { + headers: { "x-opencodex-api-key": adminToken }, + }); + expect(settings.status).toBe(503); + const body = await settings.json() as { error?: string; hint?: string; reason?: string }; + expect(body.error).toBe("management API unavailable"); + expect(body.hint).toContain("OPENCODEX_ADMIN_AUTH_TOKEN"); + expect(body.reason).toContain("OPENCODEX_ADMIN_AUTH_TOKEN"); + expect((await fetch(new URL("/healthz", server.url))).status).toBe(200); + } finally { + await server.stop(true); + } + }); + + test("required management harden retries after a soft loadConfig directory timeout", async () => { + delete process.env.OPENCODEX_ADMIN_AUTH_TOKEN; + saveConfig(remoteConfig()); + const adminToken = `ocx_admin_${"f".repeat(43)}`; + writeFileSync(join(testHome, "admin-api-token"), `${adminToken}\n`, { mode: 0o600 }); + process.env.USERNAME ??= "tester"; + setPlatformForTests("win32"); + + let softPhase = true; + let requiredPhaseCalls = 0; + setIcaclsRunnerForTests(args => { + const target = args[0] ?? ""; + if (target.endsWith("admin-api-token")) { + return { success: true, exitCode: 0, timedOut: false, stdout: "" }; + } + if (softPhase) { + return { success: false, exitCode: null, timedOut: true, stdout: "" }; + } + requiredPhaseCalls += 1; + return { success: true, exitCode: 0, timedOut: false, stdout: "" }; + }); + resetHardenedStateForTests(); + + const soft = hardenSecretDir(testHome, { required: false }); + expect(soft.ok).toBe(false); + expect(soft.diagnostics).toMatch(/timed out|budget exhausted|previous attempt/i); + + softPhase = false; + const state = initializeManagementAuthState(remoteConfig()); + expect(state.available).toBe(true); + if (!state.available) return; + expect(state.source).toBe("file"); + expect(requiredPhaseCalls).toBeGreaterThan(0); + }); + + test("OPENCODEX_ADMIN_AUTH_TOKEN bypasses file-backed ACL hardening", async () => { + process.env.OPENCODEX_ADMIN_AUTH_TOKEN = "env-admin-secret"; + saveConfig(remoteConfig()); + process.env.USERNAME ??= "tester"; + setPlatformForTests("win32"); + setIcaclsRunnerForTests(() => ({ success: false, exitCode: null, timedOut: true, stdout: "" })); + resetHardenedStateForTests(); + + const state = initializeManagementAuthState(remoteConfig()); + expect(state.available).toBe(true); + if (!state.available) return; + expect(state.source).toBe("environment"); + + const server = startServer(0); + try { + const management = await fetch(new URL("/api/config", server.url), { + headers: { "x-opencodex-api-key": "env-admin-secret" }, + }); + expect(management.status).toBe(200); + } finally { + await server.stop(true); + } + }); }); diff --git a/tests/storage-cleanup.test.ts b/tests/storage-cleanup.test.ts index eecdf5e19..1403dc7a9 100644 --- a/tests/storage-cleanup.test.ts +++ b/tests/storage-cleanup.test.ts @@ -220,7 +220,7 @@ describe("previewArchivedCleanup", () => { "archived_sessions/rollout-new.jsonl", ]); expect(listed.some(c => c.relPath.includes("sessions/2026"))).toBe(false); - }); + }, { timeout: STORE_BUDGET_MS }); test("percent selects oldest subset and includes digest", () => { home = buildHome(); @@ -236,7 +236,7 @@ describe("previewArchivedCleanup", () => { expect(preview.bytes).toBe(preview.candidates[0]!.bytes); expect(preview.digest).toBe(computePreviewDigest(preview.candidates, 50)); expect(preview.digest).toMatch(/^[a-f0-9]{64}$/); - }); + }, { timeout: STORE_BUDGET_MS }); test("treats .jsonl and .jsonl.zst as one logical rollout", () => { home = buildHome(); @@ -250,7 +250,7 @@ describe("previewArchivedCleanup", () => { "archived_sessions/rollout-old.jsonl.zst", ]); expect(listed.filter(c => c.relPath.includes("rollout-old"))).toHaveLength(1); - }); + }, { timeout: STORE_BUDGET_MS }); }); describe("normalizeArchivedRolloutPath", () => { @@ -267,7 +267,7 @@ describe("normalizeArchivedRolloutPath", () => { // ISO timestamps in filenames must not be treated as Windows drive letters. expect(normalizeArchivedRolloutPath("archived_sessions/rollout-2026-01-01T10:00:00.jsonl", home)) .toBe("archived_sessions/rollout-2026-01-01T10:00:00.jsonl"); - }); + }, { timeout: STORE_BUDGET_MS }); }); describe("executeArchivedCleanup", () => { diff --git a/tests/windows-secret-acl.test.ts b/tests/windows-secret-acl.test.ts index 95871b44b..55c445474 100644 --- a/tests/windows-secret-acl.test.ts +++ b/tests/windows-secret-acl.test.ts @@ -548,6 +548,21 @@ describe("async hardenSecretPath (issue #612)", () => { expect(calls).toBe(0); // destination-keyed memo; not a parent-directory shortcut }); + test("optional timeout memo does not poison a later required harden of the same path", () => { + setIcaclsRunnerForTests(() => timeout); + const first = hardenSecretPath(secretFile(), { required: false }); + expect(first.ok).toBe(false); + + let calls = 0; + setIcaclsRunnerForTests(() => { + calls += 1; + return ok; + }); + const second = hardenSecretPath(secretFile(), { required: true }); + expect(second.ok).toBe(true); + expect(calls).toBeGreaterThan(0); + }); + test("async harden still grants owner before inheritance removal", async () => { const steps: string[] = []; setAsyncIcaclsRunnerForTests(async args => {