Skip to content
6 changes: 5 additions & 1 deletion src/lib/windows-secret-acl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
}

/**
Expand Down
30 changes: 25 additions & 5 deletions src/server/management-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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);
Expand Down Expand Up @@ -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();
Expand Down
102 changes: 101 additions & 1 deletion tests/server-management-auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
resetHardenedStateForTests,
setIcaclsRunnerForTests,
setPlatformForTests,
hardenSecretDir,
} from "../src/lib/windows-secret-acl";

const previousHome = process.env.OPENCODEX_HOME;
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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);
}
});
});
8 changes: 4 additions & 4 deletions tests/storage-cleanup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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();
Expand All @@ -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", () => {
Expand All @@ -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", () => {
Expand Down
15 changes: 15 additions & 0 deletions tests/windows-secret-acl.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down
Loading