diff --git a/src/config/paths.ts b/src/config/paths.ts index 9f470b9ebd4..cab40800655 100644 --- a/src/config/paths.ts +++ b/src/config/paths.ts @@ -1,4 +1,4 @@ -import { chmodSync, existsSync } from "node:fs"; +import { chmodSync, statSync } from "node:fs"; import { homedir } from "node:os"; import { join, resolve } from "node:path"; import { flushWindowsSecretAclReapsBeforeRemoval, hardenSecretDirAsync, windowsSecretAclApplies, withWindowsSecretAclSettled } from "../lib/windows-secret-acl"; @@ -37,7 +37,8 @@ export function hardenConfigDir(): void { // The guard runs before any mutation: refusing after chmod/ACL would already // have changed the protected directory used by the test-home boundary. assertNotRealHomeUnderTest(dir); - if (!existsSync(dir)) return; + // Follow directory links as before, but never apply directory permissions to a file. + try { if (!statSync(dir).isDirectory()) return; } catch { return; } // Windows privacy is enforced by NTFS ACLs. chmod only changes DOS attributes there; // doing it before the ACL lookup can invalidate its ctime-based successful observation. if (!windowsSecretAclApplies()) { diff --git a/structure/config.md b/structure/config.md index 079955e02c9..604f1c839b7 100644 --- a/structure/config.md +++ b/structure/config.md @@ -60,7 +60,7 @@ returns unavailable for missing, invalid, or persistently conflicting config. It test seam and the mutation types remain re-exported through `src/config.ts`. Successful, repaired, and salvaged file loads record their source path in the private WeakMap owned by `src/config/rebase-provenance.ts`; defaults and synthetic objects do not acquire it. This metadata is never serialized. Inventory drift cannot turn a file-backed instance into a synthetic discovery writer. Read publication may use the detached policy projection described by the [catalog contract](catalog.md#shared-catalog), without changing the live merge baseline; changing the resolved config home refuses publication. `src/config/paths.ts` owns `OPENCODEX_HOME` expansion and resolution, the config directory and `config.json` path. A relative home is resolved once per distinct raw environment value, so a later working-directory change cannot -silently move the active installation. Config reads in this leaf and `src/config/load-degrade.ts` use NTFS ACLs on Windows and private POSIX modes elsewhere. Windows reads omit `chmod` before ACL hardening so repeated loads preserve successful object/freshness observations; external metadata changes still invalidate them. Directory flights keep their existing flush/reap lifecycle, and file/required-write failure policy is unchanged. `tests/windows/windows-config-read-acl-memo.test.ts` covers repeated loads, external invalidation and POSIX permissions. +silently move the active installation. Config reads in this leaf and `src/config/load-degrade.ts` use NTFS ACLs on Windows and private POSIX modes elsewhere. Optional directory hardening follows directory symlinks/junctions but skips non-directory or unstatable targets before starting chmod or ACL work. Windows reads omit `chmod` before ACL hardening so repeated loads preserve successful object/freshness observations; external metadata changes still invalidate them. Directory flights keep their existing flush/reap lifecycle, and file/required-write failure policy is unchanged. `tests/windows/windows-config-read-acl-memo.test.ts` covers repeated loads, non-directory and directory-link behavior, external invalidation and POSIX permissions. Intercept writers use `withConfigDirHardeningSettled` from `src/config/paths.ts` after their last unrelated await. The registry includes the optional config flight, ancestors and retained timeout children; one existing ACL harden deadline bounds admission, including newly overlapping work. Expiry throws sanitized `WindowsSecretAclSettleTimeoutError` (`ETIMEDOUT`) without running the writer or relinquishing child cleanup ownership. The final empty-registry check and synchronous callback share one turn; the callback contains no await. Picker cleanup independently gates both SQLite owner inspection and post-untrust acknowledgement; profile and model-snapshot publication re-enter after trust/discovery awaits. Lexical/canonical aliases and uncertain identities drain conservatively. Removal retains its separate at-or-below scope and actual-exit requirement. diff --git a/tests/codex-integration/codex-account-slot-identity.test.ts b/tests/codex-integration/codex-account-slot-identity.test.ts index e62a1f0f807..1177ef62b77 100644 --- a/tests/codex-integration/codex-account-slot-identity.test.ts +++ b/tests/codex-integration/codex-account-slot-identity.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { flushConfigDirHardening } from "../../src/config/paths"; import { saveCodexAccountCredential } from "../../src/codex/account-store"; import { codexAccountUnusableReason } from "../../src/codex/account-usability"; import { reconcileMainCodexAccountRuntimeState, resetMainCodexAccountIdentityTrackingForTests } from "../../src/codex/account-lifecycle"; @@ -13,7 +14,7 @@ import { clearAccountQuota } from "../../src/codex/quota"; import { clearCodexUpstreamHealth, clearThreadAccountMap } from "../../src/codex/routing"; import { runPassiveRead } from "../../src/lib/passive-read"; import type { OcxConfig } from "../../src/types"; -import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { drainAndRemoveFixtureRoots } from "../helpers/fixture-teardown"; /** * Security proof for the slot identity fields on the account-list API: `slotId`, `slotKind` @@ -94,14 +95,21 @@ beforeEach(() => { resetRuntime(); }); -afterEach(() => { +afterEach(async () => { globalThis.fetch = originalFetch; resetRuntime(); - for (const [name, value] of Object.entries(saved)) { - if (value === undefined) delete process.env[name]; - else process.env[name] = value; - } - removeTreeWithRetry(home); + await drainAndRemoveFixtureRoots({ + roots: [{ path: home }], + restoreEnvironment: () => { + for (const [name, value] of Object.entries(saved)) { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } + }, + }, { + // The tracked flight belongs to the nested config directory, not its fixture root. + settleConfigFlights: () => flushConfigDirHardening(join(home, "ocx")), + }); }); describe("Codex account slot identity metadata", () => { diff --git a/tests/codex-integration/codex-auth-headless-accounts.test.ts b/tests/codex-integration/codex-auth-headless-accounts.test.ts index d5d1c815298..d095cebfcd5 100644 --- a/tests/codex-integration/codex-auth-headless-accounts.test.ts +++ b/tests/codex-integration/codex-auth-headless-accounts.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { flushConfigDirHardening } from "../../src/config/paths"; import { getCodexAccountCredential, saveCodexAccountCredential } from "../../src/codex/account-store"; import { resetMainCodexAccountIdentityTrackingForTests } from "../../src/codex/account-lifecycle"; import { clearAccountNeedsReauth, isAccountNeedsReauth, markAccountNeedsReauth } from "../../src/codex/account-runtime-state"; @@ -19,6 +20,7 @@ import { clearAccountQuota } from "../../src/codex/quota"; import { resetQuotaQueryBackoffForTests } from "../../src/codex/quota-query-backoff"; import { resetLifecycleDrainStateForTests } from "../../src/server/lifecycle"; import type { OcxConfig } from "../../src/types"; +import { drainAndRemoveFixtureRoots } from "../helpers/fixture-teardown"; import { removeTreeWithRetry } from "../helpers/remove-tree"; let root: string; @@ -96,16 +98,23 @@ beforeEach(() => { resetRuntimeState(); }); -afterEach(() => { +afterEach(async () => { defaultHomeSpy?.mockRestore(); defaultHomeSpy = undefined; globalThis.fetch = originalFetch; resetRuntimeState(); - if (previousOcxHome === undefined) delete process.env.OPENCODEX_HOME; - else process.env.OPENCODEX_HOME = previousOcxHome; - if (previousCodexHome === undefined) delete process.env.CODEX_HOME; - else process.env.CODEX_HOME = previousCodexHome; - removeTreeWithRetry(root); + await drainAndRemoveFixtureRoots({ + roots: [{ path: root }], + restoreEnvironment: () => { + if (previousOcxHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousOcxHome; + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + }, + }, { + // Tests can replace OPENCODEX_HOME; await the original fixture's exact flight key. + settleConfigFlights: () => flushConfigDirHardening(join(root, "ocx")), + }); }); describe("account listing without a native Codex home", () => { diff --git a/tests/windows/windows-config-read-acl-memo.test.ts b/tests/windows/windows-config-read-acl-memo.test.ts index ec7ed5160a2..2fc46e74943 100644 --- a/tests/windows/windows-config-read-acl-memo.test.ts +++ b/tests/windows/windows-config-read-acl-memo.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; -import { chmodSync, mkdtempSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, statSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { loadConfig } from "../../src/config"; @@ -71,6 +71,38 @@ test("repeated Windows config reads preserve successful ACL observations", async expect(readFileSync(join(home, "config.json"), "utf8")).toBe(before); }); +for (const platform of ["win32", "darwin"]) { + test(`optional directory hardening leaves a regular file unchanged on ${platform}`, async () => { + Object.defineProperty(process, "platform", { value: platform, configurable: true }); + acl.setPlatformForTests(platform); + const path = join(home, "not-a-config-directory"); + writeFileSync(path, "fixture-unsafe-root"); + const mode = statSync(path).mode; + process.env.OPENCODEX_HOME = path; + + hardenConfigDir(); + await flushConfigDirHardeningForTests(); + + expect(calls).toEqual([]); + expect(readFileSync(path, "utf8")).toBe("fixture-unsafe-root"); + expect(statSync(path).mode).toBe(mode); + }); +} + +test("optional directory hardening still follows a directory symlink or junction", async () => { + const target = join(home, "directory-target"); + const link = join(home, "directory-link"); + mkdirSync(target); + symlinkSync(target, link, originalPlatform === "win32" ? "junction" : "dir"); + process.env.OPENCODEX_HOME = link; + + hardenConfigDir(); + await flushConfigDirHardeningForTests(); + + expect(calls.length).toBeGreaterThan(0); + expect(statSync(link).isDirectory()).toBe(true); +}); + test("an external metadata change still invalidates a Windows file ACL observation", () => { const path = join(home, "auth.json"); let freshness = 1n;