Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions scripts/test-layout/layout-clients.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
22 changes: 16 additions & 6 deletions src/client/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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");
}
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -1128,15 +1138,15 @@ 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.
if (prepared.connection && !disconnectAtLeast(prepared.receipt, "desktop_restored")) {
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");
Expand Down Expand Up @@ -1213,7 +1223,7 @@ export async function disconnectClient(
apiKeyId: receipt.owner.apiKeyId, restartRequired: desktop.restartRequired,
...(desktop.restoration ? { desktopRestoration: desktop.restoration } : {}),
};
}), deps.lifecycleLockDeps);
}), deps.lifecycleLockDeps));
}

/**
Expand Down
55 changes: 44 additions & 11 deletions src/integrations/writer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -953,14 +955,19 @@ function withClientLocks(
const run = async (heldStoreLock: string | null): Promise<WriteOutcome> => {
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.
Expand All @@ -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<T>(frozen: FrozenIntegrationInput, transaction: () => T): Promise<T | WriteRefused> {
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,
Expand All @@ -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));
Comment on lines 1017 to +1018

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Recheck the confirmed plan after ACL admission

On Windows, if config-root hardening is still running, settledTransaction now waits after revalidate has approved the confirmation. For Aside, asideLateGuardFor in src/server/management/aside-profile-routes.ts is supplied only through revalidateBeforeWrite, and Aside takes no writer lock. If the profile is edited during this new wait, a confirmed overwrite proceeds against the changed file without rejecting the stale fingerprint. The writer's compare-before-commit check does not catch this because it first reads the file after admission. Keep a synchronous confirmation-fingerprint check inside the admitted callback immediately before mutation; the same ordering issue also affects the locked and restore paths.

Useful? React with 👍 / 👎.

}

// 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,
Expand Down Expand Up @@ -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(
Expand Down
Loading
Loading