From 322df269c229f6814eeb48c0769ab172bac34375 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 11 Nov 2026 18:29:18 +0900 Subject: [PATCH] fix(config): admit integration and client-disconnect writes after config-root ACL work On Windows a config load can leave the optional config-root harden in flight, and its inheritance work can drop a grant a concurrent writer just applied. The integrations writer (catalog fan-out, Aside sync and profile routes, integration routes) and the client disconnect and key-rotation Desktop store writes under the config root still ran without waiting for it. Each coordinated integration transaction now runs inside withConfigDirHardeningSettled on its store root, with the store-lock re-probe inside the same callback; an admission timeout returns a write_failed refusal without a write. disconnectClient admits both of its synchronous phases, and rotation admits the two Desktop credential alignments that precede the hub commit. Rollback and post-commit finalization are unchanged. Refs #7815 --- scripts/test-layout/layout-clients.json | 1 + src/client/connect.ts | 22 ++- src/integrations/writer.ts | 55 +++++-- structure/config.md | 2 +- tests/fixtures/test-layout-expected.json | 1 + ...ws-config-root-integration-writers.test.ts | 155 ++++++++++++++++++ 6 files changed, 218 insertions(+), 18 deletions(-) create mode 100644 tests/windows/windows-config-root-integration-writers.test.ts diff --git a/scripts/test-layout/layout-clients.json b/scripts/test-layout/layout-clients.json index c392db24cae..9eab95873c5 100644 --- a/scripts/test-layout/layout-clients.json +++ b/scripts/test-layout/layout-clients.json @@ -4,6 +4,7 @@ "codex-launcher-endpoint.test.ts": "codex-integration", "claude-translated-tool-restrictions.test.ts": "claude-integration", "windows-config-root-async-writers.test.ts": "windows", + "windows-config-root-integration-writers.test.ts": "windows", "t3code-client-lifecycle.test.ts": "clients", "fix-jsonc-cr-comments.test.ts": "lib", "fix-jsonc-empty-comma.test.ts": "lib", diff --git a/src/client/connect.ts b/src/client/connect.ts index af6bf711eaf..e9b7b79a37a 100644 --- a/src/client/connect.ts +++ b/src/client/connect.ts @@ -7,6 +7,7 @@ import { } from "node:fs"; import { hostname } from "node:os"; import { atomicWriteFile, loadConfig, withConfigMutationLockSync } from "../config"; +import { getConfigDir, withConfigDirHardeningSettled } from "../config/paths"; import { claudeDesktopIntegrationEnabledNow } from "../codex/desired-state"; import { siblingOfLivePort, siblingSkipMessage } from "../codex/sibling-start"; import { @@ -454,7 +455,10 @@ async function rotateConnectedClientKeyHeld( const desktop = inspectRemoteDesktopStore(desktopOwner(connection)); if (["conflict", "unsafe", "pending"].includes(desktop.kind)) throw new Error("desktop_lifecycle_recovery_required"); // Establish legacy fallback ownership and reject edited projections BEFORE hub issuance. - alignDesktopCredential(held, connection, current.fingerprint, current); + // The remote Desktop store writes under the config root with required ACLs, so it admits + // after overlapping config-root ACL work (#7815); a timeout here refuses before the hub. + const unchanged = connection; + await withConfigDirHardeningSettled(getConfigDir(), () => alignDesktopCredential(held, unchanged, current.fingerprint, current)); withConfigMutationLockSync(() => { assertClientConnectionUnchanged(connection!); const orphan = readTokenBackupState(); @@ -486,7 +490,10 @@ async function rotateConnectedClientKeyHeld( assertRotationCandidates(connection!, current.fingerprint, current.fingerprint); return replaceServiceApiTokenFile(rotation.key); }); - alignDesktopCredential(held, connection, current.fingerprint, { token: rotation.key, fingerprint: replacement.fingerprint }); + // Still before the hub commit: an admission timeout takes the ordinary rollback below. + const marking = connection; + await withConfigDirHardeningSettled(getConfigDir(), () => + alignDesktopCredential(held, marking, current.fingerprint, { token: rotation.key, fingerprint: replacement.fingerprint })); if (!await probeClientKeyId(connection.serverUrl, rotation.key, connection.apiKeyId, { fetchImpl: deps.fetchImpl })) { throw new Error("new client key admission probe was refused"); } @@ -1092,7 +1099,10 @@ export async function disconnectClient( }> { if (siblingOfLivePort() !== null) throw new Error(siblingSkipMessage()); const keepCatalog = options.keepCatalog === true; - const prepared = withClientLifecycleSync(held => withConfigMutationLockSync(() => { + // Both phases write the disconnect receipt and the remote Desktop store under the config root + // with required ACLs, so each admits after overlapping config-root ACL work (#7815). A timeout + // before phase 1 writes nothing; one before phase 2 leaves the prepared receipt it resumes from. + const prepared = await withConfigDirHardeningSettled(getConfigDir(), () => withClientLifecycleSync(held => withConfigMutationLockSync(() => { const read = readDesktopDisconnectReceipt(); if (read.kind === "unsafe") throw new Error("client_disconnect_receipt_unsafe"); const state = readClientConnectionState(); @@ -1128,7 +1138,7 @@ export async function disconnectClient( } if (!receipt || receipt.keepCatalog !== keepCatalog) throw new Error("client_disconnect_options_changed"); return { receipt, connection }; - }), deps.lifecycleLockDeps); + }), deps.lifecycleLockDeps)); // The prepared receipt blocks even a sync queued for its actual N/C injection commit. // Codex-only restore MUST stay outside L; it never invokes Desktop cleanup. @@ -1136,7 +1146,7 @@ export async function disconnectClient( restoreConnectedCodex(prepared.connection); } - return withClientLifecycleSync(held => withConfigMutationLockSync(() => { + return withConfigDirHardeningSettled(getConfigDir(), () => withClientLifecycleSync(held => withConfigMutationLockSync(() => { const read = readDesktopDisconnectReceipt(); if (read.kind !== "valid" || JSON.stringify(read.value) !== JSON.stringify(prepared.receipt)) { throw new Error("client_disconnect_receipt_changed"); @@ -1213,7 +1223,7 @@ export async function disconnectClient( apiKeyId: receipt.owner.apiKeyId, restartRequired: desktop.restartRequired, ...(desktop.restoration ? { desktopRestoration: desktop.restoration } : {}), }; - }), deps.lifecycleLockDeps); + }), deps.lifecycleLockDeps)); } /** diff --git a/src/integrations/writer.ts b/src/integrations/writer.ts index 673226058e3..b1f68317da0 100644 --- a/src/integrations/writer.ts +++ b/src/integrations/writer.ts @@ -77,6 +77,8 @@ function yamlRefusalReason( return `${configPath} uses YAML source opencodex cannot patch without risking unrelated comments or formatting, so ${outcome}`; } import { withIntegrationWriterLock, type IntegrationWriterLockSeams } from "./writer-lock"; +import { withConfigDirHardeningSettled } from "../config/paths"; +import { WindowsSecretAclSettleTimeoutError } from "../lib/windows-secret-acl"; /** * Owned by the planner so a plan can report a refusal without depending on the writer, and @@ -953,14 +955,19 @@ function withClientLocks( const run = async (heldStoreLock: string | null): Promise => { const refused = await options?.revalidate?.(frozen); if (refused) return refused; - // A profile can appear while revalidation awaits. Acquire its lock and - // repeat the validation before observing or mutating its document. - const required = storeLockFile(frozen); - if (required !== null && required !== heldStoreLock) { - return withIntegrationWriterLock(required, () => run(required), options?.lockSeams, ".lock"); - } - // No await between the last store probe and the synchronous transaction. - return operation(); + let required: string | null = null; + const outcome = await settledTransaction(frozen, () => { + // A profile can appear while revalidation or ACL admission awaits. Acquire its lock and + // repeat the validation before observing or mutating its document. The probe sits inside + // the admitted callback: no await between the last store probe and the transaction. + required = storeLockFile(frozen); + if (required !== null && required !== heldStoreLock) return null; + return operation(); + }); + if (outcome !== null) return outcome; + const lock = required as string | null; + if (lock === null) throw new Error("integration store lock probe returned no lock to acquire"); + return withIntegrationWriterLock(lock, () => run(lock), options?.lockSeams, ".lock"); }; return withIntegrationWriterLock(frozen.resolvedPaths.configPath, () => { // Decide only after the outer lock has actually been acquired. @@ -970,6 +977,30 @@ function withClientLocks( }, options?.lockSeams, suffix); } +/** + * Run one synchronous integration transaction after overlapping config-root ACL work (#7815). + * + * The transaction writes ownership records, the journal and snapshots under the integrations + * store with required ACLs. On Windows a config load can leave the optional config-root harden + * in flight, and its inheritance work can drop a grant this transaction just applied. The store + * root is the admission directory: settlement covers its ancestors, so the config-root flight is + * waited for. An admission that times out never entered the transaction and is refused without a + * write; a timeout thrown from inside the transaction keeps the transaction's own handling. + */ +async function settledTransaction(frozen: FrozenIntegrationInput, transaction: () => T): Promise { + let entered = false; + try { + return await withConfigDirHardeningSettled(frozen.store.root, () => { + entered = true; + return transaction(); + }); + } catch (error) { + if (entered || !(error instanceof WindowsSecretAclSettleTimeoutError)) throw error; + return refuse(frozen.clientId, "write_failed", "unsafe", + "The opencodex config directory was still being secured, so nothing was written. Try again in a moment."); + } +} + async function coordinatedWrite( input: IntegrationWriteInput, operation: (frozen: IntegrationWriteInput) => WriteOutcome, @@ -984,14 +1015,16 @@ async function coordinatedWrite( ?? staleModelNameRefusal(bound, input.config, operationKind) ?? operation(bound); if (!spec.writerLock) { const refused = await options?.revalidate?.(frozen); - return refused ?? guarded(frozen); + return refused ?? settledTransaction(frozen, () => guarded(frozen)); } // An absent client home is not created merely to acquire a sibling lock. if (frozen.io.statKind(frozen.resolvedPaths.detectDir) !== "dir") { const refused = await options?.revalidate?.(frozen); if (refused) return refused; - if (frozen.io.statKind(frozen.resolvedPaths.detectDir) !== "dir") return guarded(frozen); + const absent = await settledTransaction(frozen, () => + frozen.io.statKind(frozen.resolvedPaths.detectDir) !== "dir" ? guarded(frozen) : null); + if (absent !== null) return absent; } return withClientLocks( frozen, @@ -1042,7 +1075,7 @@ export async function restoreIntegrationCoordinated( ?? restoreIntegration({ ...frozen, opId: input.opId, confirmDrift: input.confirmDrift }); if (!spec.writerLock) { const refused = await options?.revalidate?.(frozen); - return refused ?? run(); + return refused ?? settledTransaction(frozen, run); } if (frozen.io.statKind(frozen.resolvedPaths.detectDir) !== "dir") { return refuse( diff --git a/structure/config.md b/structure/config.md index c40be224b38..bd654e7b16a 100644 --- a/structure/config.md +++ b/structure/config.md @@ -62,7 +62,7 @@ test seam and the mutation types remain re-exported through `src/config.ts`. Suc `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. 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. -`withConfigDirHardeningSettled` in `src/config/paths.ts` admits synchronous mutation after the last unrelated await. Its registry includes the optional config flight, ancestors and retained timeout children; one existing ACL deadline bounds admission, including newly overlapping work. Expiry throws sanitized `WindowsSecretAclSettleTimeoutError` (`ETIMEDOUT`) without running the writer or dropping child cleanup ownership. The final empty-registry check and callback share one turn, with no await in the callback. Intercept/picker CA, token, cleanup, profile and snapshot phases re-enter after trust/discovery awaits. Config saves in `src/server/management/agent-settings-routes.ts` and `src/server/management/config-routes.ts`, Desktop intent/mode commits in `src/server/management/native-integration-routes.ts`, and gateway publication/cleanup in those routes and `src/cli/ensure-desired-integrations.ts` use the same admission. `src/cli/claude-desktop.ts` admits local profile/mode commits and the complete remote-store transaction again after hub download. Lifecycle/config locks are acquired inside admission; refresh re-read, mode/freshness checks, profile and marker publication share one callback. Live setting edits and their save/publication share the admitted synchronous callback; a refused admission leaves both live and durable state untouched. Ordered post-commit phases preserve applied-state warnings or settled compensation, including mode/fingerprint marker refusal and retained CLI intent after incomplete rollback. Synchronous leaf APIs retain their contracts. `tests/claude-integration/claude-config-root-writer-barrier.test.ts` holds parent flights and drives refusal deadlines without sleeps. Lexical/canonical aliases and uncertain identities drain conservatively; removal keeps its separate at-or-below scope and actual-exit requirement. Async local config commands (`src/cli/config-command.ts`, `src/cli/effort.ts`, `src/cli/models.ts`, `src/cli/provider.ts`) admit complete synchronous read/validate/save phases through `withConfigDirHardeningSettled`. Preliminary model selection and client-connection diagnostics use passive observations that cannot publish recovery copies. Final authority and recovery publication are re-read inside admission without starting optional hardening. `src/cli/config-mutation.ts` refuses invalid or unreadable authority, including array/non-object roots rejected before defaults repair, with `CliUsageError`; full-config commands retain fresh-install initialization when the file is absent, while set/unset retain the persisted mutation owner's missing-file refusal. Custom-model removal revalidates the confirmed row by identity and contents while retaining current siblings. Import reads its input and checks its target inside admission. Provider-add credential cleanup shares the synchronous post-save phase and retains its existing post-commit warning. Timeout leaves the mutation database and config untouched and follows existing CLI failure handling. `tests/windows/windows-config-root-async-writers.test.ts` covers held parent flights, deadline refusal, recovery copies, invalid authority and concurrent edits. Codex multi-agent management changes that can resolve a runtime additionally await fresh [selection admission](codex-cli-update.md#worker-realm-and-the-selection-commit-window) before entering this barrier, keep that owner across hardening, and re-evaluate the mode plan inside the synchronous callback; neither ACL wait is inserted into the callback, and refusal leaves the combined mutation untouched. Timer-driven caches under the config root admit their write the same way and build the snapshot inside the callback: the provider account quota cache (`src/providers/account-quota-disk.ts`), the Codex account quota cache (`src/codex/quota.ts`) and the reasoning support cache, while a models.dev refresh admits its publication after the network read (`src/providers/reasoning-metadata.ts`). A timed-out admission skips a best-effort cache write and fails that refresh unpublished, a joined refresh reports the same failure, and a cancel, clear or reset invalidates a write still waiting for admission (#7815; `tests/providers/provider-account-quota-persistence.test.ts`, `tests/codex-integration/reasoning-metadata.test.ts`). +`withConfigDirHardeningSettled` in `src/config/paths.ts` admits synchronous mutation after the last unrelated await. Its registry includes the optional config flight, ancestors and retained timeout children; one existing ACL deadline bounds admission, including newly overlapping work. Expiry throws sanitized `WindowsSecretAclSettleTimeoutError` (`ETIMEDOUT`) without running the writer or dropping child cleanup ownership. The final empty-registry check and callback share one turn, with no await in the callback. Intercept/picker CA, token, cleanup, profile and snapshot phases re-enter after trust/discovery awaits. Config saves in `src/server/management/agent-settings-routes.ts` and `src/server/management/config-routes.ts`, Desktop intent/mode commits in `src/server/management/native-integration-routes.ts`, and gateway publication/cleanup in those routes and `src/cli/ensure-desired-integrations.ts` use the same admission. `src/cli/claude-desktop.ts` admits local profile/mode commits and the complete remote-store transaction again after hub download. Lifecycle/config locks are acquired inside admission; refresh re-read, mode/freshness checks, profile and marker publication share one callback. Live setting edits and their save/publication share the admitted synchronous callback; a refused admission leaves both live and durable state untouched. Ordered post-commit phases preserve applied-state warnings or settled compensation, including mode/fingerprint marker refusal and retained CLI intent after incomplete rollback. Synchronous leaf APIs retain their contracts. `tests/claude-integration/claude-config-root-writer-barrier.test.ts` holds parent flights and drives refusal deadlines without sleeps. Lexical/canonical aliases and uncertain identities drain conservatively; removal keeps its separate at-or-below scope and actual-exit requirement. Async local config commands (`src/cli/config-command.ts`, `src/cli/effort.ts`, `src/cli/models.ts`, `src/cli/provider.ts`) admit complete synchronous read/validate/save phases through `withConfigDirHardeningSettled`. Preliminary model selection and client-connection diagnostics use passive observations that cannot publish recovery copies. Final authority and recovery publication are re-read inside admission without starting optional hardening. `src/cli/config-mutation.ts` refuses invalid or unreadable authority, including array/non-object roots rejected before defaults repair, with `CliUsageError`; full-config commands retain fresh-install initialization when the file is absent, while set/unset retain the persisted mutation owner's missing-file refusal. Custom-model removal revalidates the confirmed row by identity and contents while retaining current siblings. Import reads its input and checks its target inside admission. Provider-add credential cleanup shares the synchronous post-save phase and retains its existing post-commit warning. Timeout leaves the mutation database and config untouched and follows existing CLI failure handling. `tests/windows/windows-config-root-async-writers.test.ts` covers held parent flights, deadline refusal, recovery copies, invalid authority and concurrent edits. Codex multi-agent management changes that can resolve a runtime additionally await fresh [selection admission](codex-cli-update.md#worker-realm-and-the-selection-commit-window) before entering this barrier, keep that owner across hardening, and re-evaluate the mode plan inside the synchronous callback; neither ACL wait is inserted into the callback, and refusal leaves the combined mutation untouched. Timer-driven caches under the config root admit their write the same way and build the snapshot inside the callback: the provider account quota cache (`src/providers/account-quota-disk.ts`), the Codex account quota cache (`src/codex/quota.ts`) and the reasoning support cache, while a models.dev refresh admits its publication after the network read (`src/providers/reasoning-metadata.ts`). A timed-out admission skips a best-effort cache write and fails that refresh unpublished, a joined refresh reports the same failure, and a cancel, clear or reset invalidates a write still waiting for admission (#7815; `tests/providers/provider-account-quota-persistence.test.ts`, `tests/codex-integration/reasoning-metadata.test.ts`). The integrations writer (`src/integrations/writer.ts`) runs every coordinated transaction (apply, refresh, overwrite, disable, restore; reached by the catalog fan-out, the Aside sync and profile routes, and integration routes) inside admission on its store root, with the store-lock re-probe in the same callback so no await separates it from the transaction; an admission timeout returns a `write_failed` refusal without a write. An async `revalidate` such as the Aside late confirmation check still runs before admission, so the wait sits between it and the transaction while the writer locks stay held. `disconnectClient` in `src/client/connect.ts` admits each of its two synchronous phases (a timeout before the second leaves the prepared receipt it resumes from), and key rotation admits both remote Desktop credential alignments that precede the hub commit; rollback and post-commit finalization keep their unadmitted paths (`tests/windows/windows-config-root-integration-writers.test.ts`). `src/config/process-state.ts` derives `ocx.pid` and `runtime-port.json` from that resolved directory. Ordinary and connected-client runtimes publish their absolute relaunch command in the runtime record; older records can omit it. Tracked runtime inspection retains an omitted hostname as `undefined` for guarded stop's legacy-record check. It owns their byte-compatible writes, parsing, expected-PID filters, cheap liveness, full OCX command diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index c390a1b1b72..e4c92eb5138 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -25,6 +25,7 @@ "graceful-max-output-tokens.test.ts": "responses", "graceful-max-output-tokens-exclusions.test.ts": "responses", "windows-config-root-async-writers.test.ts": "windows", + "windows-config-root-integration-writers.test.ts": "windows", "windows-atomic-write-acl-rearm.test.ts": "windows", "advisor-web-search.test.ts": "advisor", "codex-launcher-endpoint.test.ts": "codex-integration", diff --git a/tests/windows/windows-config-root-integration-writers.test.ts b/tests/windows/windows-config-root-integration-writers.test.ts new file mode 100644 index 00000000000..70b62166b7b --- /dev/null +++ b/tests/windows/windows-config-root-integration-writers.test.ts @@ -0,0 +1,155 @@ +import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, readdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, sep } from "node:path"; +import type { ExportModel } from "../../src/clients/config-export"; +import { disconnectClient } from "../../src/client/connect"; +import * as paths from "../../src/config/paths"; +import { INTEGRATION_CLIENTS } from "../../src/integrations/registry"; +import { createIntegrationStateStore, type IntegrationStateStore } from "../../src/integrations/store"; +import { applyIntegrationCoordinated, type IntegrationWriteInput } from "../../src/integrations/writer"; +import { resetHardenedStateForTests, setAsyncIcaclsBeltSchedulerForTests, setAsyncIcaclsRunnerForTests, + setIcaclsRunnerForTests, setNowForTests, setPlatformForTests, type IcaclsResult } from "../../src/lib/windows-secret-acl"; +import { setSyntheticWindowsPrincipalForTests } from "../../src/lib/windows-user-principal"; +import type { OcxConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +/** + * #7815: the integrations writer (catalog fan-out, Aside sync and every other coordinated + * integration write) and the client disconnect write under the config root with required ACLs. + * On Windows a config load can leave the optional config-root harden in flight; these writers now + * admit after it, like the #7777/#7826/#7869/#7998 writers, and refuse cleanly on its deadline. + */ +const success: IcaclsResult = { success: true, exitCode: 0, timedOut: false, stdout: "" }; +const turn = () => new Promise(done => setImmediate(done)); +function deferred() { + let resolve!: () => void; + const promise = new Promise(done => { resolve = done; }); + return { promise, resolve }; +} +const keys = ["OPENCODEX_HOME", "OPENCODEX_ACL_TIMEOUT_MS", "OPENCODEX_ACL_VERIFY_EXISTING"] as const; +const models: ExportModel[] = [{ namespaced: "mock/alpha", provider: "mock", id: "alpha", contextWindow: 128_000 }]; +const config = { port: 12345, hostname: "127.0.0.1", defaultProvider: "mock", + providers: { mock: { adapter: "openai-chat", baseUrl: "https://fixture.invalid/v1" } } } as OcxConfig; +let root: string, clientHome: string, store: IntegrationStateStore; +let previous: (string | undefined)[]; +let restores: Array<() => void>; + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "ocx-integration-writer-admission-")); + clientHome = mkdtempSync(join(tmpdir(), "ocx-integration-client-home-")); + store = createIntegrationStateStore(join(root, "integrations")); + previous = keys.map(key => process.env[key]); + process.env.OPENCODEX_HOME = root; process.env.OPENCODEX_ACL_TIMEOUT_MS = "1000"; + delete process.env.OPENCODEX_ACL_VERIFY_EXISTING; + restores = []; + resetHardenedStateForTests(); setPlatformForTests("win32"); + setSyntheticWindowsPrincipalForTests("*S-1-5-21-1-2-3-1001"); setIcaclsRunnerForTests(() => success); +}); +afterEach(async () => { + await paths.flushConfigDirHardeningAndReaps(root); + for (const restore of restores.reverse()) restore(); + setAsyncIcaclsRunnerForTests(null); setAsyncIcaclsBeltSchedulerForTests(null); + setNowForTests(null); setIcaclsRunnerForTests(null); setPlatformForTests(null); + setSyntheticWindowsPrincipalForTests(null); resetHardenedStateForTests(); + keys.forEach((key, index) => { + if (previous[index] === undefined) delete process.env[key]; else process.env[key] = previous[index]; + }); + removeTreeWithRetry(root); removeTreeWithRetry(clientHome); +}); + +function clock() { + let now = 0; + const timers = new Map<() => void, number>(); + setNowForTests(() => now); + setAsyncIcaclsBeltSchedulerForTests((callback, milliseconds) => { + timers.set(callback, now + milliseconds); return () => { timers.delete(callback); }; + }); + return { advance(milliseconds: number) { + now += milliseconds; + for (const [callback, deadline] of timers) if (deadline <= now) { timers.delete(callback); callback(); } + } }; +} + +/** Park the optional config-root flight and record any descendant ACL command issued meanwhile. */ +function holdParent() { + const entered = deferred(), released = deferred(), reached = deferred(); + let held = true; + const descendants: string[] = []; + setAsyncIcaclsRunnerForTests(async () => { entered.resolve(); await released.promise; return success; }); + setIcaclsRunnerForTests(args => { + if (held && args[0]?.startsWith(root + sep)) { descendants.push(args[0]); reached.resolve(); } + return success; + }); + const barrier = paths.withConfigDirHardeningSettled; + const hook = spyOn(paths, "withConfigDirHardeningSettled").mockImplementation((dir: string, mutate: () => T) => { + const result = barrier(dir, mutate); reached.resolve(); return result; + }); + restores.push(() => hook.mockRestore()); paths.hardenConfigDir(); + return { entered: entered.promise, reached: reached.promise, arrive: reached.resolve, descendants, + release() { held = false; released.resolve(); } }; +} + +const writerPaths = ["no-lock", "absent-home", "locked"] as const; +function input(path: typeof writerPaths[number]): IntegrationWriteInput { + const clientId = path === "no-lock" ? "pi" : "commandcode"; + const spec = INTEGRATION_CLIENTS[clientId]; + if (path !== "absent-home") { + mkdirSync(spec.detectDir({}, clientHome), { recursive: true }); + mkdirSync(dirname(spec.configPath({}, clientHome)), { recursive: true }); + writeFileSync(spec.configPath({}, clientHome), "{}\n"); + } + return { clientId, models, config, port: 12345, env: {}, home: clientHome, store }; +} +const storeFiles = () => (existsSync(store.root) ? readdirSync(store.root) : []); + +for (const path of writerPaths) { + test.each([false, true])(`integration write on ${path} waits for parent ACL admission (timeout=%p)`, async timeout => { + const time = clock(), parent = holdParent(); await parent.entered; + let settled = false; + const work = applyIntegrationCoordinated(input(path)).then(result => { settled = true; return result; }); + void work.finally(parent.arrive); + try { + await parent.reached; await turn(); + expect(parent.descendants).toEqual([]); + expect(settled).toBe(false); + expect(storeFiles()).toEqual([]); + if (timeout) { + time.advance(999); await turn(); expect(settled).toBe(false); time.advance(1); + const result = await work; + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toBe("write_failed"); + expect(storeFiles()).toEqual([]); + expect(parent.descendants).toEqual([]); + } + } finally { parent.release(); await work; } + if (!timeout) { + const result = await work; + if (path === "absent-home") expect(result.ok).toBe(false); + else { + expect(result.ok).toBe(true); + expect(storeFiles().length).toBeGreaterThan(0); + } + } + }); +} + +test.each([false, true])("client disconnect waits for parent ACL admission before its first phase (timeout=%p)", async timeout => { + const time = clock(), parent = holdParent(); await parent.entered; + let settled = false; + const work = disconnectClient({}, { lifecycleLockDeps: { lockPath: join(root, "lifecycle.sqlite") } }) + .then(() => null, (error: Error & { code?: string }) => error) + .finally(() => { settled = true; }); + void work.finally(parent.arrive); + try { + await parent.reached; await turn(); + expect(parent.descendants).toEqual([]); + expect(settled).toBe(false); + expect(existsSync(join(root, "lifecycle.sqlite"))).toBe(false); + if (timeout) { + time.advance(999); await turn(); expect(settled).toBe(false); time.advance(1); + expect((await work)?.code).toBe("ETIMEDOUT"); + } + } finally { parent.release(); await work; } + if (!timeout) expect((await work)?.message).toContain("disconnect refused"); +});