From d677804d946486da8cc94c62f6250ae4f182a72a Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 20 Oct 2026 05:50:49 +0900 Subject: [PATCH 01/22] fix(oauth): allow dismissing a settled failed login flow (carry #7379) Co-authored-by: Ingwannu <186453546+Ingwannu@users.noreply.github.com> --- .../content/docs/reference/management-api.md | 5 ++- src/oauth/login-flow-state.ts | 13 +++++- src/server/management/oauth-account-routes.ts | 3 +- .../ADR-7378-oauth-failed-flow-dismissal.md | 14 ++++++ .../providers/oauth-code-display-login.md | 7 +++ tests/oauth/oauth-status-privacy.test.ts | 43 +++++++++++++++++++ 6 files changed, 81 insertions(+), 4 deletions(-) create mode 100644 structure/decisions/ADR-7378-oauth-failed-flow-dismissal.md diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index c5176ae844..5325a41989 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -609,7 +609,7 @@ Labels are read on login/import or refresh, never inferred from quota percentage | `GET /api/key-providers` | List providers configured through API-key login | — | | `POST /api/oauth/login` | Start an OAuth login or account-add flow. `mode: "code"` starts a code-display login (Anthropic only) and returns `mode`, `flowId`, `expiresAt` | 400 unknown/invalid provider, invalid `mode`, `code_mode_unsupported`; `oauth_mutation_busy` | | `POST /api/oauth/login/code` | Submit a manual callback URL or authorization code. For a code-display flow (with `flowId`) returns the token-exchange outcome `{ok, account}` / `{ok:false, error}` | 400 invalid provider/code, `state_mismatch`, `invalid_or_expired_code`, `provider_unreachable`, `malformed_input`; 409 `no_pending_login`; `oauth_mutation_busy` | -| `POST /api/oauth/login/cancel` | Cancel a public in-progress OAuth flow; an optional `flowId` names one code-display flow (a flow of another provider is refused) | 400 unknown provider | +| `POST /api/oauth/login/cancel` | Cancel a public in-progress OAuth flow or dismiss its settled failure; an optional `flowId` names one code-display flow (a flow of another provider is refused) | 400 unknown provider | | `GET /api/oauth/status` | Poll one provider's OAuth flow | 400 unknown provider | | `POST /api/oauth/logout` | Remove the selected provider credential | 400 unknown provider; `oauth_mutation_busy` | | `GET /api/oauth/accounts` | List masked accounts; Anthropic and generic OAuth account rows include their `paused` state. Kiro rows include `autoSelectable` and a closed `skipReason` when excluded from automatic selection; an active singleton may still send. Quota remains opt-in. | 400 invalid provider | @@ -625,6 +625,9 @@ Labels are read on login/import or refresh, never inferred from quota percentage | `PUT /api/providers/keys/alias` | Set or clear a provider-key alias | 400 invalid input; 404 provider/key missing | | `GET, POST, PATCH, DELETE /api/keys` | List, create, edit, or delete data-plane admission keys | 400 invalid body/id; 404 key missing | +`POST /api/oauth/login/cancel` can also dismiss the selected settled failed login attempt. It leaves stored credentials and successful login results intact; status then reports the remaining flow or idle. Cancelling an in-progress attempt still records its cancelled outcome. + + For Anthropic, `routes` is an ordered array of `{name, match, accounts, fallback?}` rules on both settings endpoints. GET and write echoes include `routes` (`null` when absent); unified DTOs expose `routes: null` for other kinds. A supplied `routes` on another kind is rejected. Omission preserves rules, `[]` matches nothing, and `null` clears them. The `accounts` values are stored IDs; removed IDs remain valid in a rule so re-adding an account can restore routing. Management responses retain valid configured names; request logs identify a match only as `route:#` (1-based list position). If a hand-edited stored rule is invalid, both settings GETs return `routes: null` and a `routesError` diagnostic instead of presenting that rule as valid; the stored value remains available for correction. Valid or absent rules omit `routesError`. Credential list responses are deliberately masked. OAuth access tokens and complete provider API diff --git a/src/oauth/login-flow-state.ts b/src/oauth/login-flow-state.ts index 3a52a6a715..703f090dc1 100644 --- a/src/oauth/login-flow-state.ts +++ b/src/oauth/login-flow-state.ts @@ -297,14 +297,23 @@ export function clearLoginState(provider: string): void { /** * Cancel one login. `flowId` names it exactly; without one the provider's newest in-flight flow * is cancelled, which is every single-flow caller (GUI, CLI) unchanged. A flow id belonging to a - * different provider is refused rather than cancelled. + * different provider is refused rather than cancelled. Only the public OAuth cancellation + * endpoint opts into failed-outcome dismissal; callers using this boolean as proof of an + * aborted controller (Codex flow ownership) retain their original contract. */ -export function cancelLoginFlow(provider: string, flowId?: string): boolean { +export function cancelLoginFlow(provider: string, flowId?: string, options?: { dismissSettledFailure?: boolean }): boolean { const flowKey = flowId ?? latestLoginFlowKey(provider); if (flowKey === undefined) return false; const active = loginAbort.get(flowKey); const existing = loginState.get(flowKey); if ((active?.provider ?? loginFlowProvider(flowKey)) !== provider) return false; + if (options?.dismissSettledFailure && !active && existing?.done && existing.error) { + // A failed add-account attempt does not invalidate a previously signed-in account. Let + // its owner dismiss the stale UI outcome without erasing credentials or another flow. + clearManualCodeSlot(flowKey); + dropLoginFlow(flowKey); + return true; + } if (!active && (!existing || existing.done)) return false; abandonLoginFlow(flowKey, { error: "Login cancelled", errorCode: "no_pending_login" }); return true; diff --git a/src/server/management/oauth-account-routes.ts b/src/server/management/oauth-account-routes.ts index 865995ecbf..853e9d4d13 100644 --- a/src/server/management/oauth-account-routes.ts +++ b/src/server/management/oauth-account-routes.ts @@ -374,7 +374,8 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< // Without a flow id this cancels the provider's newest in-flight login, which is what the // single-user GUI has always meant by "Cancel". A host running concurrent code logins for // several users passes the flowId it was given at /api/oauth/login. - const cancelled = cancelLoginFlow(provider, typeof body.flowId === "string" ? body.flowId : undefined); + const cancelled = cancelLoginFlow(provider, typeof body.flowId === "string" ? body.flowId : undefined, + { dismissSettledFailure: true }); return jsonResponse({ ok: true, cancelled }); } diff --git a/structure/decisions/ADR-7378-oauth-failed-flow-dismissal.md b/structure/decisions/ADR-7378-oauth-failed-flow-dismissal.md new file mode 100644 index 0000000000..a0846fb817 --- /dev/null +++ b/structure/decisions/ADR-7378-oauth-failed-flow-dismissal.md @@ -0,0 +1,14 @@ +# ADR-7378: decision recorded under "OAuth code-display login" + +- Purpose: let an operator dismiss the obsolete error of a failed add-account attempt (#7378). +- Constraint: flow outcome and persisted active-account health are independent; concurrent code + logins and provider ownership already have dedicated flow-key APIs. +- Alternatives: hide every flow error whenever a credential exists, expire outcomes by time, or + reuse the explicit cancellation endpoint. Hiding errors would conceal a real failed add-account + attempt; a timer could erase an outcome before a polling caller reads it. +- Decision: extend the existing cancellation owner to drop exactly one settled failed flow with + no live controller, only when the public endpoint explicitly opts in. Internal callers retain + the boolean meaning of live cancellation. Reuse paste cleanup and waiter-safe flow removal, without credential I/O. +- Consequences: failures remain observable until explicitly dismissed; successful outcomes and + pending-cancellation results keep their contract. Repeated dismissal is a no-op. This provides + API recovery without an implicit login, logout, credential refresh or service restart. diff --git a/structure/providers/oauth-code-display-login.md b/structure/providers/oauth-code-display-login.md index b66b2e9b98..1112c6fbb5 100644 --- a/structure/providers/oauth-code-display-login.md +++ b/structure/providers/oauth-code-display-login.md @@ -24,6 +24,11 @@ unchanged. lookup is provider-scoped (`loginFlowKeys` matches `(state.provider ?? key) === provider`), so status, cancellation, clearing and the admission cap never see another provider's flows. A cancel naming a `flowId` that belongs to a different provider is refused. +- **Failed-flow dismissal.** The public cancellation endpoint opts into dismissing a settled failed attempt after its + controller is gone. It drops only the selected provider-owned flow and its paste slot, so status + returns to the remaining flow or idle. Active-account credentials and successful terminal + outcomes remain untouched; cancelling a pending attempt still records its cancelled outcome. + The internal default still reports only live cancellation, preserving Codex flow ownership. - **Expiry and limits.** A code flow's paste window is `CODE_LOGIN_TTL_MS` (10 minutes); an expired flow is reaped as `no_pending_login` the next time that provider starts a login. At most `MAX_LOGIN_FLOWS_PER_PROVIDER` (64) rows per provider are kept; settled rows are evicted first @@ -53,3 +58,5 @@ unchanged. Tests: `tests/oauth/oauth-code-login.test.ts` (flow keying, cross-provider isolation, error classification, paste contract) and the `--headless` cases in `tests/cli/cli-provider.test.ts`. + +> Decision record: [ADR-7378](../decisions/ADR-7378-oauth-failed-flow-dismissal.md) diff --git a/tests/oauth/oauth-status-privacy.test.ts b/tests/oauth/oauth-status-privacy.test.ts index fa50763c47..f998e9dc17 100644 --- a/tests/oauth/oauth-status-privacy.test.ts +++ b/tests/oauth/oauth-status-privacy.test.ts @@ -3,6 +3,7 @@ import { mkdtempSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { + cancelLoginFlow, clearLoginState, getLoginStatus, getValidAccessToken, @@ -17,6 +18,7 @@ import { publicOAuthAuthenticationErrorMessage, UnsupportedOAuthProviderError, } from "../../src/oauth"; +import { loginState } from "../../src/oauth/login-flow-state"; import { getAccountSet, OAuthMutationBusyError, saveCredential, setAccountPaused } from "../../src/oauth/store"; import { handleManagementAPI } from "../../src/server/management-api"; import { handleResponses } from "../../src/server/responses"; @@ -53,6 +55,47 @@ describe("OAuth status privacy", () => { TEST_DIR = ""; }); + test("a settled failed login can be dismissed without changing the active credential (#7378)", async () => { + await saveCredential("xai", { access: "fixture-access", refresh: "fixture-refresh", + expires: Date.now() + 60_000, email: "person@example.test", accountId: "fixture-account" }); + const before = getAccountSet("xai"); + const authBytes = readFileSync(join(TEST_DIR, "auth.json")); + loginState.set("failed-xai", { provider: "xai", flowId: "failed-xai", done: true, error: PUBLIC_OAUTH_ERROR }); + expect(getLoginStatus("xai")).toMatchObject({ loggedIn: true, status: "error", error: PUBLIC_OAUTH_ERROR }); + const request = new ManagementRequest("http://localhost/api/oauth/login/cancel", { + method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ provider: "xai" }), + }); + const response = await handleManagementAPI(request, new URL(request.url), { + defaultProvider: "xai", providers: { xai: { adapter: "openai-responses", baseUrl: "https://api.x.ai/v1", authMode: "oauth" } }, + } as OcxConfig); + expect(response?.status).toBe(200); + expect(await response!.json()).toEqual({ ok: true, cancelled: true }); + expect(getLoginStatus("xai")).toMatchObject({ loggedIn: true, status: "idle", error: undefined }); + expect(getAccountSet("xai")).toEqual(before); + expect(readFileSync(join(TEST_DIR, "auth.json"))).toEqual(authBytes); + expect(cancelLoginFlow("xai")).toBe(false); + }); + + test("failed-flow dismissal is exact and provider scoped", () => { + loginState.set("failed-xai", { provider: "xai", flowId: "failed-xai", done: true, error: PUBLIC_OAUTH_ERROR }); + loginState.set("pending-xai", { provider: "xai", flowId: "pending-xai", done: false }); + expect(cancelLoginFlow("anthropic", "failed-xai")).toBe(false); + expect(loginState.has("failed-xai")).toBe(true); + expect(cancelLoginFlow("xai", "failed-xai")).toBe(false); + expect(cancelLoginFlow("xai", "failed-xai", { dismissSettledFailure: true })).toBe(true); + expect(loginState.get("pending-xai")?.done).toBe(false); + expect(getLoginStatus("xai").status).toBe("pending"); + }); + + test("successful terminal state remains available and pending cancellation keeps its outcome", () => { + loginState.set("completed-xai", { provider: "xai", flowId: "completed-xai", done: true }); + expect(cancelLoginFlow("xai", "completed-xai")).toBe(false); + expect(getLoginStatus("xai", true, "completed-xai").status).toBe("complete"); + loginState.set("pending-xai", { provider: "xai", flowId: "pending-xai", done: false }); + expect(cancelLoginFlow("xai", "pending-xai")).toBe(true); + expect(getLoginStatus("xai", true, "pending-xai")).toMatchObject({ status: "error", error: "Login cancelled" }); + }); + test("getLoginStatus returns a masked provider email", async () => { await saveCredential("xai", { access: "access-token", From c9e382bf4db2d45c6b81fb12de38b25ff6f0abe2 Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 20 Oct 2026 05:50:49 +0900 Subject: [PATCH 02/22] fix(providers): preserve explicit API-key mode when keys are unavailable (carry #7304) Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com> --- scripts/test-layout/layout.json | 2 +- src/providers/initial-model-selection.ts | 4 +- src/providers/key-store.ts | 11 +- src/providers/quota-routing-cache.ts | 2 +- src/providers/routed-config.ts | 2 +- structure/subagents.md | 5 +- tests/fixtures/test-layout-expected.json | 2 +- .../oauth-upsert-preserves-api-key.test.ts | 46 ++--- .../explicit-key-pause-oauth-boundary.test.ts | 170 ++++++++++++++++++ .../providers/initial-model-selection.test.ts | 6 +- tests/providers/resolved-model-policy.test.ts | 4 +- tests/routing/router.test.ts | 8 +- 12 files changed, 224 insertions(+), 38 deletions(-) create mode 100644 tests/providers/explicit-key-pause-oauth-boundary.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 9c7bec47c0..ccadc2d6d6 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -2,7 +2,7 @@ "version": 1, "root": "tests", "explicitShards": ["layout-clients.json"], - "explicit": { "combo-description.test.ts": "routing", "combo-description-catalog.test.ts": "codex-integration", "codex-provider-routing.test.ts": "codex-integration", "config-native-reasoning-retention.test.ts": "config", "server-native-reasoning-retention-merge.test.ts": "server", "server-native-reasoning-retention.test.ts": "server", "responses-retained-reasoning-stream-recovery.test.ts": "responses", "openai-native-hosted-search-history.test.ts": "adapters/openai", "responses-native-compact-search-history.test.ts": "responses", "config-hosted-search-projection.test.ts": "config", "gcp-credential-boundary.test.ts": "adapters/google", "gcp-sa-marker.test.ts": "adapters/google", "gcp-sa-adapter.test.ts": "adapters/google", "management-gcp-credential-input.test.ts": "server", "codex-preserve-signed-in.test.ts": "codex-integration", "cli-account-preservation.test.ts": "cli", "service-state-isolation.test.ts": "service", "compaction-history-framing.test.ts": "responses", "catalog-native-max-context.test.ts": "codex-integration", "protocol-argon-thought-boundary.test.ts": "responses", "service-log.test.ts": "service", "messages-native-empty-inline.test.ts": "claude-integration", "catalog-auto-review-override.test.ts": "codex-integration", "management-auto-review-settings.test.ts": "server", "management-auto-review-auth.test.ts": "server", "prefer-model-alias-ids.test.ts": "server", "responses-mimo-envelope.test.ts": "responses", "responses-call-id-linkage.test.ts": "responses", "bridge-terminal-refusal.test.ts": "adapters", "loop-terminal-refusal.test.ts": "images", "web-search-terminal-refusal.test.ts": "web-search", "combo-terminal-refusal.test.ts": "routing", "server-terminal-refusal-wiring.test.ts": "server", "incomplete-reason-normalization.test.ts": "responses", "advisor-retention.test.ts": "advisor", "responses-native-key-cooldown.test.ts": "responses", "responses-run-turn-key-readmission.test.ts": "responses", "qoder-spawn-readmission.test.ts": "adapters", "api-key-selection-cooldown.test.ts": "providers", "api-key-auto-pause-distribution.test.ts": "providers", "server-chat-native-key-selection.test.ts": "server", "compact-key-selection.test.ts": "responses", "messages-native-key-selection.test.ts": "claude-integration", + "explicit": { "combo-description.test.ts": "routing", "combo-description-catalog.test.ts": "codex-integration", "codex-provider-routing.test.ts": "codex-integration", "config-native-reasoning-retention.test.ts": "config", "server-native-reasoning-retention-merge.test.ts": "server", "server-native-reasoning-retention.test.ts": "server", "responses-retained-reasoning-stream-recovery.test.ts": "responses", "openai-native-hosted-search-history.test.ts": "adapters/openai", "responses-native-compact-search-history.test.ts": "responses", "config-hosted-search-projection.test.ts": "config", "gcp-credential-boundary.test.ts": "adapters/google", "gcp-sa-marker.test.ts": "adapters/google", "gcp-sa-adapter.test.ts": "adapters/google", "management-gcp-credential-input.test.ts": "server", "codex-preserve-signed-in.test.ts": "codex-integration", "cli-account-preservation.test.ts": "cli", "service-state-isolation.test.ts": "service", "compaction-history-framing.test.ts": "responses", "catalog-native-max-context.test.ts": "codex-integration", "protocol-argon-thought-boundary.test.ts": "responses", "service-log.test.ts": "service", "messages-native-empty-inline.test.ts": "claude-integration", "catalog-auto-review-override.test.ts": "codex-integration", "management-auto-review-settings.test.ts": "server", "management-auto-review-auth.test.ts": "server", "prefer-model-alias-ids.test.ts": "server", "responses-mimo-envelope.test.ts": "responses", "responses-call-id-linkage.test.ts": "responses", "bridge-terminal-refusal.test.ts": "adapters", "loop-terminal-refusal.test.ts": "images", "web-search-terminal-refusal.test.ts": "web-search", "combo-terminal-refusal.test.ts": "routing", "server-terminal-refusal-wiring.test.ts": "server", "incomplete-reason-normalization.test.ts": "responses", "advisor-retention.test.ts": "advisor", "responses-native-key-cooldown.test.ts": "responses", "responses-run-turn-key-readmission.test.ts": "responses", "qoder-spawn-readmission.test.ts": "adapters", "api-key-selection-cooldown.test.ts": "providers", "api-key-auto-pause-distribution.test.ts": "providers", "explicit-key-pause-oauth-boundary.test.ts": "providers", "server-chat-native-key-selection.test.ts": "server", "compact-key-selection.test.ts": "responses", "messages-native-key-selection.test.ts": "claude-integration", "openai-chat-standalone-bare-block.test.ts": "adapters/openai", "owner-registry-isolation.test.ts": "config", "responses-auto-pause-replay-owner.test.ts": "responses", "responses-account-auto-pause.test.ts": "responses", "oauth-account-auto-pause.test.ts": "oauth", "responses-account-auto-pause-wire-owner.test.ts": "responses", "oauth-upsert-paused-api-key.test.ts": "oauth", "management-account-auto-pause.test.ts": "server", "cli-account-key-pause.test.ts": "cli", "oauth-account-auto-pause-revision.test.ts": "oauth", "config-account-auto-pause-revisions.test.ts": "config", "codex-history-pending-wal.test.ts": "codex-integration", "responses-anthropic-billing-send-budget.test.ts": "responses", "anthropic-billing-ownership.test.ts": "adapters/anthropic", "server-anthropic-billing.test.ts": "server", "server-anthropic-billing-boundaries.test.ts": "server", "router-anthropic-billing-failover.test.ts": "routing", "anthropic-billing-refusal.test.ts": "adapters/anthropic", "messages-request-id-headers.test.ts": "claude-integration", "bounded-file-read.test.ts": "lib", "responses-undeclared-tool-guard-inbound.test.ts": "responses", "explicit-empty-tool-catalog.test.ts": "responses", "explicit-empty-tool-catalog-history.test.ts": "responses", diff --git a/src/providers/initial-model-selection.ts b/src/providers/initial-model-selection.ts index d00ae9c19f..01cf3b0050 100644 --- a/src/providers/initial-model-selection.ts +++ b/src/providers/initial-model-selection.ts @@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto"; import { getProviderRegistryEntry, providerMatchesRegistryTransport } from "./registry"; import { routedSlug, slugEquivalenceKey } from "./slug-codec"; import { comboDisabledModelSelectors } from "../combos/types"; -import { providerUsesKeyAuthOverride, resolveProviderApiKey } from "./key-store"; +import { providerUsesKeyAuthOverride } from "./key-store"; export const INITIAL_MODEL_SELECTION_THRESHOLD = 20; type Selection = NonNullable; @@ -31,7 +31,7 @@ function loginConnection(name: string, provider: OcxProviderConfig): boolean { if (entry && providerMatchesRegistryTransport(name, provider)) { if (entry.authKind === "forward") return true; if (entry.authKind === "oauth") { - return !providerUsesKeyAuthOverride(entry, provider, resolveProviderApiKey(provider.apiKey)); + return !providerUsesKeyAuthOverride(entry, provider); } } return provider.authMode === "oauth" || provider.authMode === "forward"; diff --git a/src/providers/key-store.ts b/src/providers/key-store.ts index e714dc9634..6b3eac3502 100644 --- a/src/providers/key-store.ts +++ b/src/providers/key-store.ts @@ -32,14 +32,19 @@ export type { ProviderKeyStoreKind, } from "./api-key-resolve"; -/** Shared with routing: a key-mode override is effective only while its key resolves. */ +/** + * Shared with routing: a key-mode override is operator intent, not a credential probe. + * The configured authMode alone decides the lane; a key that cannot be resolved right now + * (paused last key, missing env var, disabled pool) fails closed at the transport and must + * never implicitly reclassify the provider to the registry's OAuth lane, where a stored + * OAuth account would be billed instead of the operator's key. + */ export function providerUsesKeyAuthOverride( entry: Pick, provider: Pick, - resolvedKey: string | undefined, ): boolean { return entry.authKind === "oauth" && entry.allowKeyAuthOverride === true - && provider.authMode === "key" && typeof resolvedKey === "string" && resolvedKey.trim().length > 0; + && provider.authMode === "key"; } /** diff --git a/src/providers/quota-routing-cache.ts b/src/providers/quota-routing-cache.ts index 7c26bfd635..e52d091405 100644 --- a/src/providers/quota-routing-cache.ts +++ b/src/providers/quota-routing-cache.ts @@ -29,7 +29,7 @@ export function providerQuotaRoutingBinding( // A key probe must not constrain that later account selection. const entry = getProviderRegistryEntry(name); if (entry && (entry.authKind === "oauth" || entry.authKind === "forward") - && !providerUsesKeyAuthOverride(entry, provider, credential)) return null; + && !providerUsesKeyAuthOverride(entry, provider)) return null; // Static auth headers can replace or combine with the probed API-key header. // Its semantics belong to the adapter, so it is not provider-wide quota evidence. if (Object.keys(provider.headers ?? {}).some(header => diff --git a/src/providers/routed-config.ts b/src/providers/routed-config.ts index 07fde43897..df6f20cd0c 100644 --- a/src/providers/routed-config.ts +++ b/src/providers/routed-config.ts @@ -134,7 +134,7 @@ export function routedProviderConfig(providerName: string, provider: OcxProvider const repairLegacyMimoFreeAuth = providerName === "mimo-free" && staticModelCatalog && (provider.authMode === undefined || provider.authMode === "local"); - const explicitKeyOverride = providerUsesKeyAuthOverride(registryEntry, provider, resolvedApiKey); + const explicitKeyOverride = providerUsesKeyAuthOverride(registryEntry, provider); const canonicalAuthMode = explicitKeyOverride ? "key" : repairLegacyMimoFreeAuth diff --git a/structure/subagents.md b/structure/subagents.md index b965522e29..a1616cfea6 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -235,7 +235,10 @@ catalogs and model candidates withhold those providers' models; the provider its stays active. At 20 or more canonical Models switch rows, initialization appends all corresponding disabled selectors once. Existing registrations and later manual choices are not reinitialized. OAuth/ChatGPT forwarding is exempt using the same -usable-key override predicate as routing. Display aliases do not add switch rows. +key-mode override predicate as routing: a persisted `authMode: "key"` on an +opted-in OAuth preset is honored even while its credential cannot be resolved +(paused last key, missing env reference, disabled pool), so that row registers +as pending rather than as an OAuth login. Display aliases do not add switch rows. `src/providers/initial-model-selection-runtime.ts` commits the decision against a matching registration/inventory snapshot before catalog authority is captured. diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index f6c4ff57e1..1ae09d1c06 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -13,7 +13,7 @@ "codex-provider-routing.test.ts": "codex-integration", "claude-picker-trust-windows.test.ts": "claude-integration", "claude-picker-windows.test.ts": "claude-integration", "config-native-reasoning-retention.test.ts": "config", "server-native-reasoning-retention-merge.test.ts": "server", "server-native-reasoning-retention.test.ts": "server", "responses-retained-reasoning-stream-recovery.test.ts": "responses", "advisor-retention.test.ts": "advisor", "responses-native-key-cooldown.test.ts": "responses", "responses-run-turn-key-readmission.test.ts": "responses", "qoder-spawn-readmission.test.ts": "adapters", - "api-key-selection-cooldown.test.ts": "providers", "api-key-auto-pause-distribution.test.ts": "providers", "server-chat-native-key-selection.test.ts": "server", "compact-key-selection.test.ts": "responses", + "api-key-selection-cooldown.test.ts": "providers", "api-key-auto-pause-distribution.test.ts": "providers", "explicit-key-pause-oauth-boundary.test.ts": "providers", "server-chat-native-key-selection.test.ts": "server", "compact-key-selection.test.ts": "responses", "messages-native-key-selection.test.ts": "claude-integration", "messages-native-empty-inline.test.ts": "claude-integration", "explicit-empty-tool-catalog.test.ts": "responses", diff --git a/tests/oauth/oauth-upsert-preserves-api-key.test.ts b/tests/oauth/oauth-upsert-preserves-api-key.test.ts index b4c979ab93..44256d1779 100644 --- a/tests/oauth/oauth-upsert-preserves-api-key.test.ts +++ b/tests/oauth/oauth-upsert-preserves-api-key.test.ts @@ -149,7 +149,7 @@ describe("upsertOAuthProvider credential preservation", () => { } }); - test("falls back to OAuth at routing time when the proxy cannot resolve the active key", () => { + test("preserves explicit key mode at routing time when the proxy cannot resolve the active key", () => { const config = configWithKey("xai", "openai-chat", "https://api.x.ai/v1"); config.providers.xai!.apiKey = "${OCX_TEST_XAI_API_KEY_MISSING}"; config.providers.xai!.apiKeyPool = [{ id: "env-key", key: "${OCX_TEST_XAI_API_KEY_MISSING}" }]; @@ -160,7 +160,7 @@ describe("upsertOAuthProvider credential preservation", () => { delete process.env.OCX_TEST_XAI_API_KEY_MISSING; try { const routed = routeModel(config, "xai/grok-4.5").provider; - expect(routed.authMode).toBe("oauth"); + expect(routed.authMode).toBe("key"); expect(routed.apiKey).toBeUndefined(); expect(config.providers.xai!.authMode).toBe("key"); } finally { @@ -189,33 +189,35 @@ describe("upsertOAuthProvider credential preservation", () => { } }); - test("CLI and proxy env visibility can diverge without rewriting stored authMode", () => { + test("CLI and proxy env visibility can diverge without changing explicit key mode", () => { const config = configWithKey("xai", "openai-chat", "https://api.x.ai/v1"); config.providers.xai!.apiKey = "${OCX_TEST_XAI_SPLIT_ENV}"; config.providers.xai!.apiKeyPool = [{ id: "env-key", key: "${OCX_TEST_XAI_SPLIT_ENV}" }]; const previous = process.env.OCX_TEST_XAI_SPLIT_ENV; - // Login CLI sees the secret; upsert still records key intent, not CLI resolution. - process.env.OCX_TEST_XAI_SPLIT_ENV = "cli-only-secret"; - upsertOAuthProvider(config, "xai"); - expect(config.providers.xai!.authMode).toBe("key"); - - // Running proxy lacks the env var: route on OAuth without touching stored mode. - delete process.env.OCX_TEST_XAI_SPLIT_ENV; - const oauthFallback = routeModel(config, "xai/grok-4.5").provider; - expect(oauthFallback.authMode).toBe("oauth"); - expect(oauthFallback.apiKey).toBeUndefined(); - expect(config.providers.xai!.authMode).toBe("key"); + try { + // Login CLI sees the secret; upsert still records key intent, not CLI resolution. + process.env.OCX_TEST_XAI_SPLIT_ENV = "cli-only-secret"; + upsertOAuthProvider(config, "xai"); + expect(config.providers.xai!.authMode).toBe("key"); - // Proxy later gains the env var: key billing resumes without a config rewrite. - process.env.OCX_TEST_XAI_SPLIT_ENV = "proxy-secret"; - const keyRoute = routeModel(config, "xai/grok-4.5").provider; - expect(keyRoute.authMode).toBe("key"); - expect(keyRoute.apiKey).toBe("proxy-secret"); - expect(config.providers.xai!.authMode).toBe("key"); + // A missing proxy env var leaves the explicit key lane without a credential. + delete process.env.OCX_TEST_XAI_SPLIT_ENV; + const unresolvedKeyRoute = routeModel(config, "xai/grok-4.5").provider; + expect(unresolvedKeyRoute.authMode).toBe("key"); + expect(unresolvedKeyRoute.apiKey).toBeUndefined(); + expect(config.providers.xai!.authMode).toBe("key"); - if (previous === undefined) delete process.env.OCX_TEST_XAI_SPLIT_ENV; - else process.env.OCX_TEST_XAI_SPLIT_ENV = previous; + // Proxy later gains the env var: key billing resumes without a config rewrite. + process.env.OCX_TEST_XAI_SPLIT_ENV = "proxy-secret"; + const keyRoute = routeModel(config, "xai/grok-4.5").provider; + expect(keyRoute.authMode).toBe("key"); + expect(keyRoute.apiKey).toBe("proxy-secret"); + expect(config.providers.xai!.authMode).toBe("key"); + } finally { + if (previous === undefined) delete process.env.OCX_TEST_XAI_SPLIT_ENV; + else process.env.OCX_TEST_XAI_SPLIT_ENV = previous; + } }); test("inserts a missing active key into the pool so listing matches routing", () => { diff --git a/tests/providers/explicit-key-pause-oauth-boundary.test.ts b/tests/providers/explicit-key-pause-oauth-boundary.test.ts new file mode 100644 index 0000000000..c1ba18bbfb --- /dev/null +++ b/tests/providers/explicit-key-pause-oauth-boundary.test.ts @@ -0,0 +1,170 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { getDefaultConfig, loadConfig, saveConfig } from "../../src/config"; +import { routedProviderConfig } from "../../src/router"; +import { providerConfigSeed } from "../../src/providers/derive"; +import { getProviderRegistryEntry } from "../../src/providers/registry"; +import { setProviderApiKeyPaused } from "../../src/providers/api-key-pause"; +import { captureAccountAutoPauseAttempt } from "../../src/oauth/account-auto-pause"; +import { getAccountSet, saveCredential, setActiveAccount } from "../../src/oauth/store"; +import { openAIChatTransport } from "../../src/adapters/openai-chat/wire"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import type { OcxConfig, OcxProviderConfig } from "../../src/types"; + +const KEY_ID = "key-1"; +const KEY_VALUE = "synthetic-key-1"; +const refusal = () => Response.json( + { error: { type: "permission_error", message: "Your subscription has expired." } }, + { status: 403 }, +); + +const oldHome = process.env.OPENCODEX_HOME; +let home: string; +beforeEach(() => { home = mkdtempSync(join(tmpdir(), "ocx-key-pause-oauth-")); process.env.OPENCODEX_HOME = home; }); +afterEach(() => { + if (oldHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = oldHome; + removeTreeWithRetry(home); +}); + +function keyModeProvider(name: "xai" | "github-copilot", keys: Array<{ id: string; key: string }> = [{ id: KEY_ID, key: KEY_VALUE }]): OcxProviderConfig { + const provider = providerConfigSeed(getProviderRegistryEntry(name)!); + provider.authMode = "key"; + provider.apiKey = keys[0]!.key; + provider.apiKeyPool = keys; + return provider; +} + +function configFor(provider: OcxProviderConfig, name: "xai" | "github-copilot"): OcxConfig { + const config = getDefaultConfig(); + config.providers = { [name]: provider }; + config.defaultProvider = name; + return config; +} + +/** A stored OAuth account must exist so the regression cannot pass by having nothing to leak. */ +async function storeOAuthAccount(name: string): Promise { + await saveCredential(name, { + access: "synthetic-access", refresh: "synthetic-refresh", + expires: Date.now() + 3_600_000, accountId: "synthetic-account", source: "oauth", + }); + const set = getAccountSet(name)!; + const id = set.accounts[0]!.id; + await setActiveAccount(name, id); + expect(set.accounts.length).toBe(1); + return id; +} + +describe("explicit key mode with stored OAuth and no usable key", () => { + for (const name of ["xai", "github-copilot"] as const) { + test(name + ": manual last-key pause stays key auth and the transport refuses", async () => { + const provider = keyModeProvider(name); + const config = configFor(provider, name); + saveConfig(config); + await storeOAuthAccount(name); + + expect(setProviderApiKeyPaused(config, name, KEY_ID, true)).toBe(true); + const stored = config.providers[name]!; + expect(stored.apiKey).toBeUndefined(); + expect(stored.apiKeyPool![0]!.disabled).toBe(true); + expect(loadConfig().providers[name]!.apiKeyPool![0]!.disabled).toBe(true); + + // authMode "key" is operator intent: an unavailable credential fails closed and must + // never be reclassified to registry OAuth, which would attach the stored account. + const routed = routedProviderConfig(name, stored); + expect(routed.authMode).toBe("key"); + expect(routed.apiKey).toBeUndefined(); + expect(() => openAIChatTransport(routed)).toThrow(/requires a non-empty credential/); + }); + + test(name + ": automatic last-key pause stays key auth and the transport refuses", async () => { + const provider = keyModeProvider(name); + provider.accountAutoPause = { enabled: true, consecutiveFailures: 1 }; + const config = configFor(provider, name); + saveConfig(config); + await storeOAuthAccount(name); + + const routed = routedProviderConfig(name, config.providers[name]!); + const observe = captureAccountAutoPauseAttempt(name, routed, { config }, + new Headers({ authorization: "Bearer " + routed.apiKey }))!; + await observe(refusal()); + + const stored = config.providers[name]!; + expect(stored.apiKeyPool![0]!.disabled).toBe(true); + expect(stored.apiKey).toBeUndefined(); + const after = routedProviderConfig(name, stored); + expect(after.authMode).toBe("key"); + expect(after.apiKey).toBeUndefined(); + expect(() => openAIChatTransport(after)).toThrow(/requires a non-empty credential/); + }); + + test(name + ": unpausing the key restores key auth and its Bearer credential", async () => { + const provider = keyModeProvider(name); + const config = configFor(provider, name); + saveConfig(config); + await storeOAuthAccount(name); + + expect(setProviderApiKeyPaused(config, name, KEY_ID, true)).toBe(true); + expect(setProviderApiKeyPaused(config, name, KEY_ID, false)).toBe(true); + const routed = routedProviderConfig(name, config.providers[name]!); + expect(routed.authMode).toBe("key"); + expect(routed.apiKey).toBe(KEY_VALUE); + const transport = openAIChatTransport(routed); + expect(transport.hasCredential).toBe(true); + expect(transport.headers.Authorization).toBe("Bearer " + KEY_VALUE); + }); + + test(name + ": an unresolved env key reference stays key mode and fails closed", async () => { + const envName = "OCX_KEY_PAUSE_BOUNDARY_" + name.replace(/-/g, "_").toUpperCase(); + const previous = process.env[envName]; + delete process.env[envName]; + try { + const provider = keyModeProvider(name, [{ id: KEY_ID, key: "$" + "{" + envName + "}" }]); + const config = configFor(provider, name); + saveConfig(config); + await storeOAuthAccount(name); + + const routed = routedProviderConfig(name, config.providers[name]!); + expect(routed.authMode).toBe("key"); + expect(routed.apiKey).toBeUndefined(); + expect(() => openAIChatTransport(routed)).toThrow(/requires a non-empty credential/); + } finally { + if (previous === undefined) delete process.env[envName]; + else process.env[envName] = previous; + } + }); + + test(name + ": pausing one pool key rotates to the next key", async () => { + const provider = keyModeProvider(name, [ + { id: "key-1", key: "synthetic-key-1" }, + { id: "key-2", key: "synthetic-key-2" }, + ]); + const config = configFor(provider, name); + saveConfig(config); + + expect(setProviderApiKeyPaused(config, name, "key-1", true)).toBe(true); + const routed = routedProviderConfig(name, config.providers[name]!); + expect(routed.authMode).toBe("key"); + expect(routed.apiKey).toBe("synthetic-key-2"); + expect(openAIChatTransport(routed).headers.Authorization).toBe("Bearer synthetic-key-2"); + }); + + test(name + ": an explicit oauth authMode still routes through OAuth", () => { + const provider = keyModeProvider(name); + provider.authMode = "oauth"; + const config = configFor(provider, name); + const routed = routedProviderConfig(name, config.providers[name]!); + expect(routed.authMode).toBe("oauth"); + }); + + test(name + ": an unset authMode keeps the registry OAuth lane", () => { + const provider = providerConfigSeed(getProviderRegistryEntry(name)!); + delete provider.authMode; + provider.apiKey = KEY_VALUE; + const config = configFor(provider, name); + const routed = routedProviderConfig(name, config.providers[name]!); + expect(routed.authMode).toBe("oauth"); + }); + } +}); diff --git a/tests/providers/initial-model-selection.test.ts b/tests/providers/initial-model-selection.test.ts index dd9b0469e0..0573d02980 100644 --- a/tests/providers/initial-model-selection.test.ts +++ b/tests/providers/initial-model-selection.test.ts @@ -129,7 +129,7 @@ describe("initial provider model switches", () => { expect(config.providers.xai.initialModelSelection).toBeUndefined(); }); - test("an unresolved mixed-auth key follows the router's OAuth exemption", () => { + test("an unresolved mixed-auth key stays a key connection pending selection", () => { const env = "OCX_INITIAL_SELECTION_KEY_FIXTURE"; const previous = process.env[env]; delete process.env[env]; @@ -138,7 +138,9 @@ describe("initial provider model switches", () => { provider.authMode = "key"; provider.apiKey = `\${${env}}`; initializeProviderModelSelection("xai", provider); - expect(provider.initialModelSelection).toBeUndefined(); + // Explicit key mode is a key connection even while its reference cannot resolve: + // the provider registers as pending instead of being treated as an OAuth login. + expect(provider.initialModelSelection?.status).toBe("pending"); process.env[env] = "fixture-key"; initializeProviderModelSelection("xai", provider); expect(provider.initialModelSelection?.status).toBe("pending"); diff --git a/tests/providers/resolved-model-policy.test.ts b/tests/providers/resolved-model-policy.test.ts index 44d91277bc..d186f87075 100644 --- a/tests/providers/resolved-model-policy.test.ts +++ b/tests/providers/resolved-model-policy.test.ts @@ -300,7 +300,7 @@ describe("resolved static model policy parity", () => { expect(JSON.stringify(policy)).not.toContain("usable-private-key"); }); - test("captured unresolved key authority falls back to registry OAuth", () => { + test("captured unresolved key authority stays explicit key mode", () => { const entry = PROVIDER_REGISTRY.find(candidate => ( candidate.authKind === "oauth" && candidate.allowKeyAuthOverride === true ))!; @@ -316,7 +316,7 @@ describe("resolved static model policy parity", () => { apiKey: reference, }); const authority = routedProviderConfig(entry.id, configured); - expect(authority.authMode).toBe("oauth"); + expect(authority.authMode).toBe("key"); const policy = resolveModelPolicy({ providerName: entry.id, modelId: entry.defaultModel ?? MODEL, diff --git a/tests/routing/router.test.ts b/tests/routing/router.test.ts index d8b92dfdc7..239461bc01 100644 --- a/tests/routing/router.test.ts +++ b/tests/routing/router.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test"; +import { openAIChatTransport } from "../../src/adapters/openai-chat/wire"; import { mapReasoningEffort } from "../../src/reasoning-effort"; import { NoEnabledOpenAiProviderError, routeCompactionModel, routeModel } from "../../src/router"; import type { OcxConfig, OcxProviderConfig } from "../../src/types"; @@ -39,7 +40,7 @@ describe("routeModel registry effort defaults", () => { expect(routeModel(cursorKeyAttempt, "cursor/auto").provider.authMode).toBe("oauth"); }); - test("falls back to OAuth routing for allowKeyAuthOverride providers when the active key is unresolved", () => { + test("keeps explicit key auth for allowKeyAuthOverride providers when the active key is unresolved", () => { const envName = "OCX_TEST_XAI_ROUTER_ENV"; const config: OcxConfig = { port: 10100, @@ -57,8 +58,11 @@ describe("routeModel registry effort defaults", () => { delete process.env[envName]; try { const routed = routeModel(config, "xai/grok-4.5").provider; - expect(routed.authMode).toBe("oauth"); + // authMode "key" is operator intent: an unresolved reference fails closed at the + // transport instead of silently reclassifying the provider to registry OAuth. + expect(routed.authMode).toBe("key"); expect(routed.apiKey).toBeUndefined(); + expect(() => openAIChatTransport(routed)).toThrow(/requires a non-empty credential/); expect(config.providers.xai!.authMode).toBe("key"); } finally { if (previous === undefined) delete process.env[envName]; From 5044df759e3c2fe35ba434782f33d5e747e15f07 Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 20 Oct 2026 05:50:50 +0900 Subject: [PATCH 03/22] fix(dsh): bound account profile response reads (carry #7333) Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com> --- src/oauth/dsh.ts | 47 ++++++++++++- structure/providers/dsh-account.md | 1 + tests/oauth/dsh-login.test.ts | 103 +++++++++++++++++++++++++++++ 3 files changed, 149 insertions(+), 2 deletions(-) diff --git a/src/oauth/dsh.ts b/src/oauth/dsh.ts index 6dbf5e29cf..b1ea8b3e8c 100644 --- a/src/oauth/dsh.ts +++ b/src/oauth/dsh.ts @@ -9,6 +9,7 @@ */ import type { OAuthController, OAuthCredentials } from "./types"; +import { readBoundedResponseBody } from "../lib/bounded-body"; import { homedir } from "node:os"; import { join } from "node:path"; import { existsSync, readFileSync } from "node:fs"; @@ -16,6 +17,20 @@ import { existsSync, readFileSync } from "node:fs"; export const DSH_PLATFORM_ORIGIN = "https://platform.deepseek.com"; export const DSH_INFERENCE_ORIGIN = "https://api.deepseek.com/anthropic"; +/** + * Byte ceiling for the profile document returned by /auth-api/v0/users/current. + * + * A legitimate profile is a small fixed-shape JSON object (code, data.biz_code, + * biz_data.id/email/id_profile{name,picture}) well under a few KiB; 64 KiB matches + * the shared bounded-body ceiling and still leaves orders of magnitude of + * headroom. Anything larger is rejected before parsing so a hostile or broken + * upstream cannot turn an import validation into unbounded memory work. + */ +export const DSH_PROFILE_MAX_BYTES = 64 * 1024; + +/** Wall-clock budget for the profile request and its body read, in milliseconds. */ +const DSH_PROFILE_FETCH_TIMEOUT_MS = 10_000; + export interface DshAccountDetectionResult { detected: boolean; token?: string; @@ -80,7 +95,7 @@ export async function validateDshAccountToken( signal?: AbortSignal, ): Promise { try { - const timeoutSignal = AbortSignal.timeout(10_000); + const timeoutSignal = AbortSignal.timeout(DSH_PROFILE_FETCH_TIMEOUT_MS); const combinedSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal; const response = await fetch(`${DSH_PLATFORM_ORIGIN}/auth-api/v0/users/current`, { method: "GET", @@ -99,7 +114,35 @@ export async function validateDshAccountToken( return { valid: false, error: `Platform returned HTTP ${response.status}` }; } - const body = (await response.json()) as { + // A declared length past the cap is rejected without touching the stream. + const declaredLength = Number(response.headers.get("content-length")); + if (Number.isFinite(declaredLength) && declaredLength > DSH_PROFILE_MAX_BYTES) { + try { + void response.body?.cancel( + new DOMException("DSH profile response is too large", "QuotaExceededError"), + ).catch(() => undefined); + } catch { + // Best-effort cancellation only. + } + return { valid: false, error: "PROFILE_VALIDATION_FAILED" }; + } + + // Missing or understated Content-Length still meets the cap on the decoded + // stream itself: once an over-limit byte is observed the retained buffer is + // discarded and cancellation is requested on the body. Malformed UTF-8 fails + // closed so a replacement character can never alter the authoritative ID. + const bounded = await readBoundedResponseBody(response, { + signal: combinedSignal, + maxBytes: DSH_PROFILE_MAX_BYTES, + totalTimeoutMs: DSH_PROFILE_FETCH_TIMEOUT_MS, + inactivityTimeoutMs: DSH_PROFILE_FETCH_TIMEOUT_MS, + fatalUtf8: true, + }); + if (bounded.oversized || bounded.truncated || !bounded.displaySafe) { + return { valid: false, error: "PROFILE_VALIDATION_FAILED" }; + } + + const body = JSON.parse(bounded.text) as { code?: number; data?: { biz_code?: number; diff --git a/structure/providers/dsh-account.md b/structure/providers/dsh-account.md index 083c5df2a1..e7285f8ced 100644 --- a/structure/providers/dsh-account.md +++ b/structure/providers/dsh-account.md @@ -7,6 +7,7 @@ `src/oauth/dsh.ts` reads `$DSH_HOME/.credentials.yaml` (default `~/.dsh/.credentials.yaml`) only inside `loginDshAccount`, which runs on `ocx login dsh-account` or the dashboard's **Log in** action for the provider. Nothing else reads the file, and nothing in opencodex writes, deletes or changes the permissions of it. The `deepseek-account-platform/default` grant record is validated against `https://platform.deepseek.com/auth-api/v0/users/current` with manual redirects before anything is stored. Only a successful business response with a nonempty authoritative account ID imports. A missing ID fails closed; email, phone and display name never supply a slot identity, so two DSH accounts sharing an email land in different slots and reauthentication uses the shared account-ID comparison in `src/oauth/index.ts`. Parser and transport failures surface fixed error codes instead of credential or profile fragments. +The profile body is consumed under a fixed 64 KiB decoded-byte ceiling (`DSH_PROFILE_MAX_BYTES`) with the same bounded-read helper the quota path uses: a Content-Length above the cap is rejected without reading, and cancellation is requested on the streamed body once it exceeds the cap, so a hostile or broken upstream cannot turn an import validation into unbounded memory work. Malformed UTF-8 in the body fails validation instead of letting a replacement character alter the authoritative account ID. The imported copy lives in the opencodex credential store. Removing the account deletes only that copy and never signs DSH Desktop out. diff --git a/tests/oauth/dsh-login.test.ts b/tests/oauth/dsh-login.test.ts index 8c84d97a88..dcaed6acf2 100644 --- a/tests/oauth/dsh-login.test.ts +++ b/tests/oauth/dsh-login.test.ts @@ -8,6 +8,7 @@ import { loginDshAccount, refreshDshAccountToken, resolveDshCredentialsPath, + DSH_PROFILE_MAX_BYTES, } from "../../src/oauth/dsh"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -163,6 +164,19 @@ describe("DSH Account Validation", () => { expect(await validateDshAccountToken("mock-token")).toEqual({ valid: false, error: "PROFILE_VALIDATION_FAILED" }); }); + test("rejects malformed UTF-8 inside the authoritative account ID instead of accepting an altered ID", async () => { + const encoder = new TextEncoder(); + const prefix = encoder.encode('{"code":0,"data":{"biz_code":0,"biz_data":{"id":"user-'); + const suffix = encoder.encode('","email":"u@example.com"}}}'); + const malformed = new Uint8Array(prefix.length + 1 + suffix.length); + malformed.set(prefix); + malformed.set([0xff], prefix.length); + malformed.set(suffix, prefix.length + 1); + globalThis.fetch = async () => new Response(malformed, { status: 200 }); + + expect(await validateDshAccountToken("mock-token")).toEqual({ valid: false, error: "PROFILE_VALIDATION_FAILED" }); + }); + test("returns ACCOUNT_TOKEN_INVALID when upstream returns 401", async () => { globalThis.fetch = async (input, init) => { const url = String(input); @@ -199,6 +213,95 @@ describe("DSH Account Validation", () => { expect(res.valid).toBe(false); expect(evilTargetReceivedToken).toBe(false); }); + + test("rejects a declared oversized profile body without reading the stream", async () => { + let cancelReason: unknown; + let pulled = false; + const body = new ReadableStream({ + pull(controller) { + // A regression that reads the body closes it instead of stalling on an + // open stream, so this asserts the pull flag directly instead of + // failing through an indirect timeout path. + pulled = true; + controller.close(); + }, + cancel(reason) { + cancelReason = reason; + }, + }, { highWaterMark: 0 }); + globalThis.fetch = async () => new Response(body, { + status: 200, + headers: { "content-length": String(DSH_PROFILE_MAX_BYTES + 1) }, + }); + + const res = await validateDshAccountToken("mock-token"); + expect(res).toEqual({ valid: false, error: "PROFILE_VALIDATION_FAILED" }); + expect(cancelReason).toBeInstanceOf(DOMException); + expect((cancelReason as DOMException).name).toBe("QuotaExceededError"); + expect(pulled).toBe(false); + }); + + test("rejects an oversized profile stream when content-length is missing or understated", async () => { + for (const headers of [undefined, { "content-length": "8" }]) { + let cancelled = false; + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(DSH_PROFILE_MAX_BYTES + 1)); + }, + cancel() { + cancelled = true; + }, + }); + globalThis.fetch = async () => new Response(body, { status: 200, headers }); + + const res = await validateDshAccountToken("mock-token"); + expect(res).toEqual({ valid: false, error: "PROFILE_VALIDATION_FAILED" }); + expect(cancelled).toBe(true); + } + }); + + test("accepts a profile document at the byte cap and rejects one byte over a chunked boundary", async () => { + const base = JSON.stringify({ + code: 0, + data: { biz_code: 0, biz_data: { id: "user-cap", email: "cap@example.com" } }, + }); + const encoder = new TextEncoder(); + const atCap = base + " ".repeat(DSH_PROFILE_MAX_BYTES - base.length); + expect(encoder.encode(atCap).byteLength).toBe(DSH_PROFILE_MAX_BYTES); + + globalThis.fetch = async () => new Response(atCap, { status: 200 }); + const ok = await validateDshAccountToken("mock-token"); + expect(ok.valid).toBe(true); + expect(ok.accountId).toBe("user-cap"); + + const overCap = encoder.encode(atCap + " "); + const chunked = new ReadableStream({ + start(controller) { + controller.enqueue(overCap.subarray(0, DSH_PROFILE_MAX_BYTES)); + controller.enqueue(overCap.subarray(DSH_PROFILE_MAX_BYTES)); + }, + }); + globalThis.fetch = async () => new Response(chunked, { status: 200 }); + expect(await validateDshAccountToken("mock-token")).toEqual({ valid: false, error: "PROFILE_VALIDATION_FAILED" }); + }); + + test("rejects with the caller's abort reason while the profile body is still streaming", async () => { + const body = new ReadableStream({ start() { /* open stream, no data */ } }); + globalThis.fetch = async () => new Response(body, { status: 200 }); + const controller = new AbortController(); + const reason = new DOMException("operator cancelled", "AbortError"); + const pending = validateDshAccountToken("mock-token", controller.signal); + await new Promise(resolve => setTimeout(resolve, 10)); + controller.abort(reason); + await expect(pending).rejects.toBe(reason); + }); + + test("surfaces a stalled profile body as PROFILE_VALIDATION_FAILED instead of hanging", async () => { + const body = new ReadableStream({ start() { /* open stream, no data */ } }); + globalThis.fetch = async () => new Response(body, { status: 200 }); + expect(await validateDshAccountToken("mock-token")) + .toEqual({ valid: false, error: "PROFILE_VALIDATION_FAILED" }); + }, 30_000); }); describe("DSH Account Login Flow", () => { From 488b4302b68388d3a186570d7817b8aa1523948a Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 20 Oct 2026 05:50:50 +0900 Subject: [PATCH 04/22] test(dsh): drain server fixtures before removing homes (carry #7305) Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com> --- tests/providers/dsh-account.test.ts | 124 +++++++++++++++++++--------- 1 file changed, 86 insertions(+), 38 deletions(-) diff --git a/tests/providers/dsh-account.test.ts b/tests/providers/dsh-account.test.ts index eafd3b4de0..e58c307fab 100644 --- a/tests/providers/dsh-account.test.ts +++ b/tests/providers/dsh-account.test.ts @@ -16,7 +16,12 @@ import { import { saveConfig } from "../../src/config"; import { saveCredential, getAccountSet } from "../../src/oauth/store"; import { startServer } from "../../src/server"; -import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { stopServerListener } from "../../src/server/lifecycle"; +import { flushNativeMainStartupReleases } from "../../src/codex/native-profile-startup"; +import { flushConfigDirHardeningForTests } from "../../src/config/paths"; +import { removeTreeAfterBackgroundWork } from "../helpers/remove-tree"; +import { currentServerFixtureConfig } from "../helpers/server-auth-fixture"; +import { SERVER_BUDGET_MS } from "../helpers/test-budget"; import type { OcxConfig, OcxProviderConfig } from "../../src/types"; import { resolveAdapter, resolveWireProtocolOverride } from "../../src/server/adapter-resolve"; import { credentialPinnedProviderConfigError, modelAdapterRecordConfigError } from "../../src/config/provider-validation"; @@ -30,6 +35,29 @@ import { fetchProviderModels } from "../../src/codex/catalog/provider-models"; import { parseRequest } from "../../src/responses/parser"; import { createTestTranslatorBudget } from "../helpers/translator-budget"; +// Runner timeouts do not finish a case's finally block before teardown starts. +// Keep listener ownership outside the case so teardown can release its spend lease. +const trackedServers = new Set>(); + +function startTrackedServer(): ReturnType { + const server = startServer(0); + trackedServers.add(server); + return server; +} + +async function removeDshFixtureHomes(...homes: string[]): Promise { + const stopFailures: unknown[] = []; + for (const server of trackedServers) { + trackedServers.delete(server); + try { await stopServerListener(server); } catch (error) { stopFailures.push(error); } + } + if (stopFailures.length) throw new AggregateError(stopFailures, "DSH fixture server stop failed"); + await flushNativeMainStartupReleases(); + await flushConfigDirHardeningForTests(); + // Yield to in-process producers and reap Windows ACL children before removal. + for (const home of homes) await removeTreeAfterBackgroundWork(home); +} + describe("dsh-account provider registry entry", () => { const entry = getProviderRegistryEntry("dsh-account"); @@ -214,20 +242,24 @@ describe("dsh-account 401 server/router lifecycle", () => { }); afterEach(async () => { - globalThis.fetch = originalFetch; - if (originalHome !== undefined) process.env.OPENCODEX_HOME = originalHome; - else delete process.env.OPENCODEX_HOME; - if (originalDshHome !== undefined) process.env.DSH_HOME = originalDshHome; - else delete process.env.DSH_HOME; - removeTreeWithRetry(tmpHome); - removeTreeWithRetry(tmpDsh); - }); + try { + await removeDshFixtureHomes(tmpHome, tmpDsh); + } finally { + globalThis.fetch = originalFetch; + if (originalHome !== undefined) process.env.OPENCODEX_HOME = originalHome; + else delete process.env.OPENCODEX_HOME; + if (originalDshHome !== undefined) process.env.DSH_HOME = originalDshHome; + else delete process.env.DSH_HOME; + } + }, SERVER_BUDGET_MS); test("upstream 401 marks account needsReauth, blocks subsequent requests without fallback to deepseek API-key, and leaves credentials file untouched", async () => { // 1. Configure both dsh-account and deepseek (API-key) - saveConfig({ + saveConfig(currentServerFixtureConfig({ port: 0, hostname: "127.0.0.1", + // Provider retirement does not exercise native Codex synchronization. + clientIntegrations: { codex: false }, defaultProvider: "dsh-account", providers: { "dsh-account": { @@ -242,7 +274,7 @@ describe("dsh-account 401 server/router lifecycle", () => { authMode: "key", }, }, - } as OcxConfig); + } as OcxConfig)); // 2. Seed imported account into OpenCodeX auth store await saveCredential("dsh-account", { @@ -260,9 +292,11 @@ describe("dsh-account 401 server/router lifecycle", () => { let deepseekApiCalls = 0; globalThis.fetch = async (input, init) => { - const url = String(input); - if (url.includes("/anthropic/v1/messages")) { + const url = input instanceof Request ? input.url : String(input); + if (url === "https://api.deepseek.com/anthropic/v1/messages") { dshWireCalls += 1; + expect(new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined)).get("x-dsh-auth-token")) + .toBe("dsh_desktop_local_token_123"); // Upstream returns 401 token invalid return new Response(JSON.stringify({ type: "error", @@ -272,14 +306,18 @@ describe("dsh-account 401 server/router lifecycle", () => { headers: { "Content-Type": "application/json" }, }); } - if (url.includes("/v1/chat/completions")) { + if (url === "https://api.deepseek.com/v1/chat/completions") { deepseekApiCalls += 1; return new Response(JSON.stringify({ choices: [] }), { status: 200 }); } - return originalFetch(input, init); + const destination = new URL(url); + if (destination.protocol === "http:" && ["127.0.0.1", "localhost"].includes(destination.hostname)) { + return originalFetch(input, init); + } + throw new Error("Unmocked external request in DSH lifecycle fixture"); }; - const server = startServer(0); + const server = startTrackedServer(); try { // 3. Send initial request for dsh-account/deepseek-flash const resp1 = await fetch(new URL("/v1/messages", server.url), { @@ -336,9 +374,9 @@ describe("dsh-account 401 server/router lifecycle", () => { expect(currentAccount?.needsReauth).toBe(true); expect(currentAccount?.credential.access).toBe("dsh_desktop_local_token_123"); } finally { - await server.stop(true); + await stopServerListener(server); } - }); + }, SERVER_BUDGET_MS); // Real auth-store ACL writes and listener startup exceed 5s on Windows. }); describe("dsh-account wire pin keeps the account token on the DSH adapter", () => { @@ -351,12 +389,15 @@ describe("dsh-account wire pin keeps the account token on the DSH adapter", () = process.env.OPENCODEX_HOME = tmpHome; }); - afterEach(() => { - globalThis.fetch = originalFetch; - if (originalHome !== undefined) process.env.OPENCODEX_HOME = originalHome; - else delete process.env.OPENCODEX_HOME; - removeTreeWithRetry(tmpHome); - }); + afterEach(async () => { + try { + await removeDshFixtureHomes(tmpHome); + } finally { + globalThis.fetch = originalFetch; + if (originalHome !== undefined) process.env.OPENCODEX_HOME = originalHome; + else delete process.env.OPENCODEX_HOME; + } + }, SERVER_BUDGET_MS); test("config validation rejects a per-model wire override, even with a custom baseUrl", () => { for (const baseUrl of ["https://api.deepseek.com/anthropic", "https://evil.example/v1"]) { @@ -400,14 +441,15 @@ describe("dsh-account wire pin keeps the account token on the DSH adapter", () = "https://api.deepseek.com/v1", "https://evil.example/v1", ])("a hand-edited openai-chat row at %s is rejected at load and sends nothing", async (rowBaseUrl) => { - saveConfig({ + saveConfig(currentServerFixtureConfig({ port: 0, hostname: "127.0.0.1", + clientIntegrations: { codex: false }, defaultProvider: "dsh-account", providers: { "dsh-account": { adapter: "openai-chat", baseUrl: rowBaseUrl, authMode: "oauth" }, }, - } as OcxConfig); + } as OcxConfig)); await saveCredential("dsh-account", { access: "dsh_pinned_wire_token", refresh: "dsh_pinned_wire_token", @@ -429,7 +471,7 @@ describe("dsh-account wire pin keeps the account token on the DSH adapter", () = return originalFetch(input, init); }) as typeof fetch; - const server = startServer(0); + const server = startTrackedServer(); try { const resp = await fetch(new URL("/v1/messages", server.url), { method: "POST", @@ -440,9 +482,9 @@ describe("dsh-account wire pin keeps the account token on the DSH adapter", () = expect(loadConfig().providers["dsh-account"]).toBeUndefined(); expect(sends).toEqual([]); } finally { - await server.stop(true); + await stopServerListener(server); } - }); + }, SERVER_BUDGET_MS); // These two load-rejection cases also start a real listener. }); describe("dsh-account token stays out of discovery and retires on a quota 401", () => { @@ -455,11 +497,14 @@ describe("dsh-account token stays out of discovery and retires on a quota 401", process.env.OPENCODEX_HOME = tmpHome; }); - afterEach(() => { - globalThis.fetch = originalFetch; - if (originalHome !== undefined) process.env.OPENCODEX_HOME = originalHome; - else delete process.env.OPENCODEX_HOME; - removeTreeWithRetry(tmpHome); + afterEach(async () => { + try { + await removeDshFixtureHomes(tmpHome); + } finally { + globalThis.fetch = originalFetch; + if (originalHome !== undefined) process.env.OPENCODEX_HOME = originalHome; + else delete process.env.OPENCODEX_HOME; + } }); const seed = (access: string) => saveCredential("dsh-account", { @@ -511,10 +556,13 @@ describe("dsh-account row stays pinned for every helper that resolves its token" process.env.OPENCODEX_HOME = tmpHome; }); - afterEach(() => { - if (originalHome !== undefined) process.env.OPENCODEX_HOME = originalHome; - else delete process.env.OPENCODEX_HOME; - removeTreeWithRetry(tmpHome); + afterEach(async () => { + try { + await removeDshFixtureHomes(tmpHome); + } finally { + if (originalHome !== undefined) process.env.OPENCODEX_HOME = originalHome; + else delete process.env.OPENCODEX_HOME; + } }); test("validation accepts the canonical row and rejects an edited adapter or destination", () => { From 4237fd5a45fd6c0de61538c3d6eaf0d2f9c19d3a Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 20 Oct 2026 05:50:50 +0900 Subject: [PATCH 05/22] fix(claude-cli): keep local errors out of subscription cooldown (carry #7343) Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com> --- src/adapters/claude-cli/adapter.ts | 10 +- src/adapters/coding-agent/turn.ts | 18 +++- structure/providers/claude-cli-replay.md | 2 + .../claude-cli-usage-admission.test.ts | 93 +++++++++++++++++++ .../codebuddy-tool-bridge-turn.test.ts | 32 +++++++ 5 files changed, 145 insertions(+), 10 deletions(-) diff --git a/src/adapters/claude-cli/adapter.ts b/src/adapters/claude-cli/adapter.ts index 3ce81629dc..b8e60ba069 100644 --- a/src/adapters/claude-cli/adapter.ts +++ b/src/adapters/claude-cli/adapter.ts @@ -286,12 +286,10 @@ export function createClaudeCliAdapter(provider: OcxProviderConfig, deps: Claude provider, parsed, incoming, - emit: withClaudeLoginHint(event => { - if (event.type === "error") { - void Promise.resolve((deps.usageRefusal ?? recordClaudeUsageRefusal)(event.message)).catch(() => {}); - } - emit(event); - }), + emit: withClaudeLoginHint(emit), + onCliOutputError: message => { + void Promise.resolve().then(() => (deps.usageRefusal ?? recordClaudeUsageRefusal)(message)).catch(() => {}); + }, ...(toolBridge ? { toolBridge } : {}), conversationInput: buildStableClaudeConversationInput, buildArgs: (profile, req, prov) => buildArgs(profile as ClaudeCliProfile, req, prov, promptFile), diff --git a/src/adapters/coding-agent/turn.ts b/src/adapters/coding-agent/turn.ts index 11b14f439b..5b5faf966e 100644 --- a/src/adapters/coding-agent/turn.ts +++ b/src/adapters/coding-agent/turn.ts @@ -121,6 +121,8 @@ export interface CodingAgentTurnInput { parsed: OcxParsedRequest; incoming: IncomingMeta; emit: (event: AdapterEvent) => void; + /** Observes accepted CLI result/stderr failures, before client-side error decoration. */ + onCliOutputError?: (message: string) => void; /** Family-specific headless argument builder (tools disabled, model, reasoning, system prompt). */ buildArgs: (profile: CodingAgentProviderProfile, parsed: OcxParsedRequest, provider: OcxProviderConfig) => string[]; /** Family-specific scoped env builder (credential + region switch on top of baseScopedEnv). */ @@ -368,11 +370,18 @@ export async function runCodingAgentTurn(input: CodingAgentTurnInput): Promise { + const emitOnce = (event: AdapterEvent, cliOutputError?: string): void => { if (event.type === "done" || event.type === "error" || event.type === "incomplete") { if (terminalEmitted) return; terminalEmitted = true; } + if (event.type === "error" && cliOutputError !== undefined) { + try { + input.onCliOutputError?.(cliOutputError); + } catch { + // An observer failure must never drop the client's terminal event or turn it into a rejection. + } + } emit(event); }; @@ -648,7 +657,8 @@ export async function runCodingAgentTurn(input: CodingAgentTurnInput): Promise= 100` threshold, so an expired window of another family never forces a re-read. A limit the CLI itself reports with a parseable reset ("resets 3pm (Zone)") is remembered until that time. A probe re-reads the account before it commits: an account switch mid-probe discards the answer (`unknown`, nothing persisted), and the newest refusal is merged at commit time with no await between that read and the write, so a CLI-reported reset is never overwritten. The file has one writer per `OPENCODEX_HOME`, the same single-owner topology as the spend ledger. The check reads the CLI's own sign-in in memory (`detectClaudeCodeTokenAsync`: Keychain without blocking the event loop, or `/.credentials.json`), caches the resolved identity for one minute, and sends the access token only to Anthropic's usage endpoint through `src/providers/claude-cli-usage.ts`, the same reader the account-pool quota probe uses. `CLAUDE_CONFIG_DIR` is passed to the child so the CLI and the check read one profile. The cache file `/claude-usage-admission.json` stores only percentages, reset times and a hashed identity key. Every failure (no sign-in, malformed credential, unreachable endpoint) fails open as `unknown`, and a test process (`OCX_TEST_HOME_GUARD`) never resolves the real sign-in. If the executor's check throws or refuses, it releases the web-search sidecar's probe lease first; `tests/responses/responses-claude-preflight-lease.test.ts` and `tests/providers/claude-cli-usage-admission.test.ts` cover both. diff --git a/tests/providers/claude-cli-usage-admission.test.ts b/tests/providers/claude-cli-usage-admission.test.ts index 3424961bfe..ef4eb826f8 100644 --- a/tests/providers/claude-cli-usage-admission.test.ts +++ b/tests/providers/claude-cli-usage-admission.test.ts @@ -4,6 +4,7 @@ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "no import { tmpdir } from "node:os"; import { join } from "node:path"; import type { ChildProcess } from "node:child_process"; +import { Readable, Writable } from "node:stream"; import { buildChildEnv, createClaudeCliAdapter } from "../../src/adapters/claude-cli/adapter"; import { CLAUDE_CLI_PROFILE } from "../../src/adapters/claude-cli/profiles"; import { @@ -158,6 +159,98 @@ describe("claude-cli adapter admission", () => { const provider = { adapter: "claude-cli", baseUrl: CLAUDE_CLI_PROFILE.canonicalBaseUrl } as OcxProviderConfig; const parsed = { modelId: "claude-sonnet-5-5", stream: true, options: {}, context: { messages: [{ role: "user", content: "hello", timestamp: 0 }] } } as OcxParsedRequest; + const limit = "You've hit your limit · resets 3pm (UTC)"; + const now = Date.UTC(2026, 9, 8, 12, 0); + const child = (lines: unknown[], stderr = "", exitCode = 0, failure?: "spawn" | "stream" | "stdin") => { + const proc = new EventEmitter() as unknown as ChildProcess; + proc.stdout = failure === "stream" ? Readable.from((async function* () { throw new Error(limit); })()) + : Readable.from(lines.map(line => new TextEncoder().encode(JSON.stringify(line) + "\n"))); + proc.stderr = Readable.from(stderr ? [stderr] : []); + proc.stdin = new Writable({ write(_chunk, _encoding, done) { done(); } }); + if (failure === "stdin") proc.stdin.write = () => { throw new Error(limit); }; + proc.kill = () => true; + proc.exitCode = null; + setTimeout(() => { + if (failure === "spawn") proc.emit("error", new Error(limit)); + proc.exitCode = exitCode; + proc.emit("close", exitCode); + }, 3); + return proc; + }; + + for (const failure of ["spawn", "stream", "stdin"] as const) { + test(`local ${failure} errors do not persist a CLI refusal`, async () => { + const statePath = tempState(); + const writes: Promise[] = []; + const adapter = createClaudeCliAdapter(provider, { + usageAdmission: async () => ({ state: "available", checkedAt: now }), + usageRefusal: message => { writes.push(recordClaudeUsageRefusal(message, { now: () => now, identity, statePath })); }, + which: () => process.execPath, + spawn: () => child([], "", 0, failure), + killGraceMs: 10, + reapTimeoutMs: 50, + }); + const events: AdapterEvent[] = []; + await adapter.runTurn!(parsed, + { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }, event => events.push(event)); + await Promise.all(writes); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ type: "error", message: expect.stringContaining("hit your limit") }); + expect(existsSync(statePath)).toBe(false); + expect(writes).toHaveLength(0); + }); + } + + for (const source of ["result", "exit-stderr", "eof-stderr"] as const) { + test(`a CLI ${source} refusal still persists its reset`, async () => { + const statePath = tempState(); + const writes: Promise[] = []; + const adapter = createClaudeCliAdapter(provider, { + usageAdmission: async () => ({ state: "available", checkedAt: now }), + usageRefusal: message => { writes.push(recordClaudeUsageRefusal(message, { now: () => now, identity, statePath })); }, + which: () => process.execPath, + spawn: () => child(source === "result" ? [{ type: "result", is_error: true, result: limit }] : [], + source === "result" ? "" : limit, source === "exit-stderr" ? 1 : 0), + killGraceMs: 10, + reapTimeoutMs: 50, + }); + const events: AdapterEvent[] = []; + await adapter.runTurn!(parsed, { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }, event => events.push(event)); + await Promise.all(writes); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ type: "error", message: expect.stringContaining(limit) }); + expect(JSON.parse(readFileSync(statePath, "utf8")).refusalUntil).toBe(Date.UTC(2026, 9, 8, 15, 0)); + expect((await checkClaudeUsageAdmission(parsed.modelId, { now: () => now, identity, probe: async () => freshQuota, statePath })).state).toBe("exhausted"); + }); + } + + test("successful output and a bridge rejection do not promote stderr into a refusal", async () => { + for (const bridge of [false, true]) { + const statePath = tempState(); + const writes: Promise[] = []; + const adapter = createClaudeCliAdapter(provider, { + usageAdmission: async () => ({ state: "available", checkedAt: now }), + usageRefusal: message => { writes.push(recordClaudeUsageRefusal(message, { now: () => now, identity, statePath })); }, + which: () => process.execPath, + spawn: () => child(bridge + ? [{ type: "system", subtype: "init", mcp_servers: [] }, { type: "result", is_error: true, result: limit }] + : [{ type: "result", result: limit }], limit), + killGraceMs: 10, + reapTimeoutMs: 50, + }); + const events: AdapterEvent[] = []; + const request: OcxParsedRequest = bridge ? { ...parsed, context: { ...parsed.context, + tools: [{ name: "read", description: "Read", parameters: { type: "object" } }], + } } : parsed; + await adapter.runTurn!(request, { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }, event => events.push(event)); + await Promise.all(writes); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject(bridge ? { type: "error", code: "tool_bridge_init_mismatch" } : { type: "done" }); + expect(writes).toHaveLength(0); + expect(existsSync(statePath)).toBe(false); + } + }); + test("an exhausted subscription is refused with a 429 cooldown before any spawn", async () => { let spawned = 0; const adapter = createClaudeCliAdapter(provider, { diff --git a/tests/providers/codebuddy-tool-bridge-turn.test.ts b/tests/providers/codebuddy-tool-bridge-turn.test.ts index 8d3b7ed050..09e5059b31 100644 --- a/tests/providers/codebuddy-tool-bridge-turn.test.ts +++ b/tests/providers/codebuddy-tool-bridge-turn.test.ts @@ -9,6 +9,7 @@ import type { ChildProcess } from "node:child_process"; import { createCodeBuddyAdapter, type SpawnFn } from "../../src/adapters/codebuddy/adapter"; import { buildCodeBuddyToolBridge } from "../../src/adapters/codebuddy/tool-bridge"; import { CODEBUDDY_GLOBAL_PROFILE, clearCodeBuddyBinaryCache } from "../../src/adapters/codebuddy/profiles"; +import { runCodingAgentTurn } from "../../src/adapters/coding-agent/turn"; import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig, OcxTool } from "../../src/types"; import { createTestTranslatorBudget } from "../helpers/translator-budget"; @@ -971,4 +972,35 @@ describe("CodeBuddy capture-only tool bridge turn", () => { expect(charges).toBe(2); expect(translatorBudget.snapshot()).toMatchObject({ currentBytes: 0, activeCalls: 0 }); }); + + test("a cli-output-error observer that throws cannot drop the client's terminal error", async () => { + const stderrText = "stderr refusal text on process exit"; + const child = new EventEmitter() as FakeChild; + child.stdout = Readable.from([]); + child.stderr = Readable.from([enc.encode(stderrText)]); + child.stdin = new Writable({ write(_chunk, _enc, cb) { cb(); } }); + child.killed = false; + child.exitCode = null; + child.kill = () => { child.killed = true; return true; }; + setTimeout(() => { child.exitCode = 1; child.emit("close", 1); }, 3); + + const events: AdapterEvent[] = []; + const observed: string[] = []; + await runCodingAgentTurn({ + profiles: [CODEBUDDY_GLOBAL_PROFILE], + provider: provider(), + parsed: parsed(), + incoming: incoming(), + emit: e => events.push(e), + onCliOutputError: message => { observed.push(message); throw new Error("observer failure"); }, + buildArgs: () => [], + buildEnv: () => ({}), + deps: { spawn: () => child as unknown as ChildProcess, which: () => "/usr/bin/codebuddy", killGraceMs: 10, reapTimeoutMs: 50 }, + }); + expect(observed).toHaveLength(1); + expect(observed[0]).toContain(stderrText); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ type: "error", code: "process_exit_error", retryable: false }); + expect(events[0].type === "error" ? events[0].message : "").toContain(stderrText); + }); }); From 83704ae3b54c8fab52ab56e208c493e3510f10bf Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 20 Oct 2026 05:50:50 +0900 Subject: [PATCH 06/22] fix(claude): bound Desktop library reads and preflight writes (carry #7301) Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com> --- .../src/content/docs/guides/claude-code.md | 6 + src/claude/desktop-3p-library.ts | 46 +++- src/claude/desktop-3p.ts | 30 ++- src/claude/desktop-picker-profile.ts | 27 +- src/claude/desktop-remote-store-io.ts | 4 +- .../clients/claude-desktop-config-reads.md | 22 +- .../claude-desktop-picker-profile.test.ts | 170 +++++++++++- tests/clients/desktop-3p-removal.test.ts | 252 +++++++++++++++++- tests/clients/desktop-3p-write-files.test.ts | 48 +++- tests/clients/desktop-3p.test.ts | 71 ++++- 10 files changed, 639 insertions(+), 37 deletions(-) diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index eb1b9b9d14..4a815b3c6d 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -254,6 +254,12 @@ Claude Desktop can use OpenCodex in one of two mutually exclusive modes. Pick it **Connect → Claude Desktop → Connection mode** in the dashboard or with `ocx claude desktop apply --first-party|--gateway`. +> Desktop library profiles and `_meta.json` must be regular files of at most +> 4 MiB. OpenCodex also refuses generated profile or metadata output above that +> limit before replacing files. Back up and reduce oversized configuration +> manually; a size refusal does not truncate the existing data. Connection-owned +> stores retain their existing tighter limits. + ### Gateway (default) New installs use **gateway** by default. Its third-party profile, described below, switches the diff --git a/src/claude/desktop-3p-library.ts b/src/claude/desktop-3p-library.ts index 0e1ddbf4a4..9410cf707b 100644 --- a/src/claude/desktop-3p-library.ts +++ b/src/claude/desktop-3p-library.ts @@ -1,4 +1,4 @@ -import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, type BigIntStats } from "node:fs"; +import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, type BigIntStats } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; import { claudeDesktopConfigLibraryDir, resolveConfigLibraryDir } from "./desktop-3p-paths"; @@ -44,6 +44,20 @@ export interface Desktop3pMetadata { export const DESKTOP_PICKER_ENTRY_NAME = "opencodex-picker"; +export const MAX_DESKTOP_PROFILE_FILE_BYTES = 4 * 1024 * 1024; + +export function assertDesktopLibraryTextSize(text: string): void { + if (Buffer.byteLength(text, "utf8") > MAX_DESKTOP_PROFILE_FILE_BYTES) { + throw new Error("desktop_config_too_large"); + } +} + +export function serializeDesktopLibraryJson(value: unknown): string { + const text = JSON.stringify(value, null, 2) + "\n"; + assertDesktopLibraryTextSize(text); + return text; +} + /** A path that no longer names the observed file was swapped; a same-path failure keeps its own errno. */ function pathStillNamesExpectedFile(path: string, expected: BigIntStats): boolean { try { @@ -54,7 +68,13 @@ function pathStillNamesExpectedFile(path: string, expected: BigIntStats): boolea } /** Read regular config files only; a path swap must not turn an observation into a FIFO wait. */ -export function readDesktopLibraryBytes(path: string): Buffer { +export function readDesktopLibraryBytes( + path: string, + maxBytes: number = MAX_DESKTOP_PROFILE_FILE_BYTES, +): Buffer { + if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > MAX_DESKTOP_PROFILE_FILE_BYTES) { + throw new RangeError("invalid Desktop library read limit"); + } const expected = lstatSync(path, { bigint: true }); if (!expected.isFile()) throw new Error("desktop_config_not_regular"); // POSIX open of a FIFO can itself block, before fstat is possible. Windows has no POSIX @@ -73,7 +93,27 @@ export function readDesktopLibraryBytes(path: string): Buffer { const opened = fstatSync(fd, { bigint: true }); if (!opened.isFile() || opened.dev !== expected.dev || opened.ino !== expected.ino || opened.ctimeNs !== expected.ctimeNs) throw new Error("desktop_config_not_regular"); - return readFileSync(fd); + if (opened.size > BigInt(maxBytes)) throw new Error("desktop_config_too_large"); + // Allocate for the observed size plus one sentinel byte, not the full cap: callers scan many + // small profiles, and the cap would zero-fill megabytes per read. A file that fills the + // buffer grew after validation — keep reading within the bound so it classifies as + // desktop_config_too_large rather than silently truncating. + let bytes = Buffer.alloc(Number(opened.size) + 1); + let length = 0; + for (;;) { + const count = readSync(fd, bytes, length, Math.min(64 * 1024, bytes.length - length), null); + if (count === 0) break; + length += count; + if (length > maxBytes) throw new Error("desktop_config_too_large"); + if (length === bytes.length && bytes.length <= maxBytes) { + const grown = Buffer.alloc(Math.min(maxBytes + 1, bytes.length * 2)); + bytes.copy(grown, 0, 0, length); + bytes = grown; + } + } + if (length > maxBytes) throw new Error("desktop_config_too_large"); + if (!pathStillNamesExpectedFile(path, opened)) throw new Error("desktop_config_not_regular"); + return bytes.subarray(0, length); } finally { closeSync(fd); } diff --git a/src/claude/desktop-3p.ts b/src/claude/desktop-3p.ts index b816ae5b62..46ff2e7784 100644 --- a/src/claude/desktop-3p.ts +++ b/src/claude/desktop-3p.ts @@ -6,7 +6,7 @@ import { canonicalDirectory } from "./desktop-remote-store-io"; import { resolveDesktop3pConfigLibraryPath, parseMetadata, SAFE_DESKTOP_PROFILE_ID, isRecord, isOwnedDesktopEntry, isOwnedDesktopGatewayEntry, profilePath, readDesktopProfileSnapshot, readDesktopLibraryText, readDesktopLibraryBytes, - readOptionalDesktopLibraryBytes, + readOptionalDesktopLibraryBytes, assertDesktopLibraryTextSize, serializeDesktopLibraryJson, type Desktop3pConfigLibraryOptions, type Desktop3pMetadata, type Desktop3pMetadataEntry, } from "./desktop-3p-library"; export { resolveDesktop3pConfigLibraryPath, type Desktop3pConfigLibraryOptions } from "./desktop-3p-library"; @@ -642,13 +642,24 @@ function removeDesktop3pStandardPivotLocal( if (targetIds.length === 0) return { ok: true, changed: false, kind: "noop", libraryPath: inspected.libraryPath }; let metadataAfterPivot = metadata; + let standardPath: string | undefined; + let pivotMetadataJson: string | undefined; if (selectedOwnedGatewayActive) { const standardId = randomUUID(); - const standardPath = profilePath(inspected.libraryPath, standardId); - atomicWriteFile(standardPath, "{}\n"); + standardPath = profilePath(inspected.libraryPath, standardId); const standardEntry: Desktop3pMetadataEntry = { id: standardId, name: "opencodex-standard" }; metadataAfterPivot = { ...metadata, appliedId: standardId, entries: [...metadata.entries, standardEntry] }; - atomicWriteFile(metadataPath, JSON.stringify(metadataAfterPivot, null, 2) + "\n"); + pivotMetadataJson = serializeDesktopLibraryJson(metadataAfterPivot); + } + // Bound the final metadata before any unlink so a size refusal cannot strand + // a row pointing at an already-deleted profile. + const removalMetadataJson = serializeDesktopLibraryJson({ + ...metadataAfterPivot, + entries: metadataAfterPivot.entries.filter(entry => !targetIds.includes(entry.id)), + }); + if (standardPath !== undefined && pivotMetadataJson !== undefined) { + atomicWriteFile(standardPath, "{}\n"); + atomicWriteFile(metadataPath, pivotMetadataJson); } const residualPaths: string[] = []; @@ -674,10 +685,7 @@ function removeDesktop3pStandardPivotLocal( } // Do not leave a metadata row pointing at a deleted profile. For a foreign // selection this only removes proven opencodex residues; appliedId is kept. - atomicWriteFile( - metadataPath, - JSON.stringify({ ...metadataAfterPivot, entries: metadataAfterPivot.entries.filter(entry => !targetIds.includes(entry.id)) }, null, 2) + "\n", - ); + atomicWriteFile(metadataPath, removalMetadataJson); return { ok: true, changed: true, kind: "removed", libraryPath: inspected.libraryPath }; } catch (error) { return { ok: false, changed: false, kind: "write_failed", libraryPath: inspected.libraryPath, reason: desktopMutationFailureReason(error) }; @@ -831,7 +839,7 @@ function writeDesktop3pConfigWithGenerator( const generated = generate(); const previous = readDesktopProfileSnapshot(configPath); - const configJson = JSON.stringify({ ...previous.foreign, ...generated }, null, 2) + "\n"; + const configJson = serializeDesktopLibraryJson({ ...previous.foreign, ...generated }); const fingerprint = createHash("sha256").update(configJson).digest("hex").slice(0, 16); if (refreshOnly) { atomicWriteFileNoFollow(configPath, configJson, undefined, { @@ -849,9 +857,10 @@ function writeDesktop3pConfigWithGenerator( }); return { written: true, path: configPath, fingerprint }; } + const metadataJson = serializeDesktopLibraryJson({ ...metadata, appliedId: id, entries }); atomicReplaceDesktopConfig(configPath, configJson, previous.bytes); try { - atomicWriteFile(metadataPath, JSON.stringify({ ...metadata, appliedId: id, entries }, null, 2) + "\n"); + atomicWriteFile(metadataPath, metadataJson); } catch (metaError) { // Roll back from the verified bytes in memory: reopening the backup could meet a swapped // entry and leave the new profile beside the old metadata. A profile this call created is @@ -895,6 +904,7 @@ export function atomicReplaceDesktopConfig( previous: Buffer | undefined, writer: typeof atomicWriteFileNoFollow = atomicWriteFileNoFollow, ): { backupPath?: string } { + assertDesktopLibraryTextSize(content); const validateBeforeRename = () => assertDesktopProfileUnchanged(path, previous); validateBeforeRename(); const backupPath = `${path}.bak`; diff --git a/src/claude/desktop-picker-profile.ts b/src/claude/desktop-picker-profile.ts index b7c1430bbe..0165ec436e 100644 --- a/src/claude/desktop-picker-profile.ts +++ b/src/claude/desktop-picker-profile.ts @@ -19,6 +19,7 @@ import { readDesktopLibraryText, resolveDesktop3pConfigLibraryPath, SAFE_DESKTOP_PROFILE_ID, + serializeDesktopLibraryJson, type Desktop3pConfigLibraryOptions, type Desktop3pMetadata, type Desktop3pMetadataEntry, @@ -48,7 +49,7 @@ function metadataPath(libraryPath: string): string { } function metadataJson(metadata: Desktop3pMetadata): string { - return JSON.stringify(metadata, null, 2) + "\n"; + return serializeDesktopLibraryJson(metadata); } function isValidMetadata(metadata: Desktop3pMetadata): boolean { @@ -190,6 +191,10 @@ function applyDesktopPickerProfileLocked(options: { proxyPort: number } & Deskto const changed = !alreadySelected || !oldProfile.exists || oldProfile.content !== target; if (!changed) return { ok: true, changed: false, path: profile }; + const entries = existing + ? metadata.entries.map(entry => entry.id === entryId ? { ...entry, name: DESKTOP_PICKER_ENTRY_NAME } : entry) + : [...metadata.entries, { id: entryId, name: DESKTOP_PICKER_ENTRY_NAME }]; + const nextMetadataJson = metadataJson({ ...metadata, appliedId: entryId, entries }); mkdirSync(join(configDir, PICKER_DIRECTORY), { recursive: true, mode: 0o700 }); try { // Keep the same backup convention as the existing Desktop writer. It is removed with the @@ -199,10 +204,7 @@ function applyDesktopPickerProfileLocked(options: { proxyPort: number } & Deskto if (!alreadySelected) { atomicWriteFile(previousStatePath, JSON.stringify({ entryId, previousAppliedId }) + "\n"); } - const entries = existing - ? metadata.entries.map(entry => entry.id === entryId ? { ...entry, name: DESKTOP_PICKER_ENTRY_NAME } : entry) - : [...metadata.entries, { id: entryId, name: DESKTOP_PICKER_ENTRY_NAME }]; - atomicWriteFile(metaPath, metadataJson({ ...metadata, appliedId: entryId, entries })); + atomicWriteFile(metaPath, nextMetadataJson); } catch { try { restoreFile(metaPath, oldMeta); @@ -246,6 +248,8 @@ function removeDesktopPickerProfileLocked(options: DesktopPickerProfileOptions): try { state = readPickerState(statePath); } catch { return { ok: false, reason: "profile_state_unreadable" }; } let metadataAfterPivot = metadata; + let standardPath: string | undefined; + let pivotMetadataJson: string | undefined; if (selected) { const previous = state?.entryId === picker.id ? state.previousAppliedId : null; const previousExists = previous !== null && previous !== picker.id && metadata.entries.some(entry => entry.id === previous); @@ -253,16 +257,22 @@ function removeDesktopPickerProfileLocked(options: DesktopPickerProfileOptions): metadataAfterPivot = { ...metadata, appliedId: previous }; } else { const standardId = randomUUID(); - atomicWriteFile(profilePath(libraryPath, standardId), "{}\n"); + standardPath = profilePath(libraryPath, standardId); metadataAfterPivot = { ...metadata, appliedId: standardId, entries: [...metadata.entries, { id: standardId, name: "opencodex-standard" }], }; } - atomicWriteFile(metaPath, metadataJson(metadataAfterPivot)); + pivotMetadataJson = metadataJson(metadataAfterPivot); } + const entries = metadataAfterPivot.entries.filter(entry => entry.id !== picker.id); + const finalMetadataJson = metadataJson({ ...metadataAfterPivot, entries }); + + if (standardPath !== undefined) atomicWriteFile(standardPath, "{}\n"); + if (pivotMetadataJson !== undefined) atomicWriteFile(metaPath, pivotMetadataJson); + const residualPaths: string[] = []; for (const path of [profilePath(libraryPath, picker.id), `${profilePath(libraryPath, picker.id)}.bak`]) { try { unlinkIfPresent(path); } catch { /* report the path without exposing file contents */ } @@ -272,9 +282,8 @@ function removeDesktopPickerProfileLocked(options: DesktopPickerProfileOptions): return { ok: false, reason: "cleanup_incomplete", residualPaths }; } - const entries = metadataAfterPivot.entries.filter(entry => entry.id !== picker.id); try { - atomicWriteFile(metaPath, metadataJson({ ...metadataAfterPivot, entries })); + atomicWriteFile(metaPath, finalMetadataJson); try { unlinkIfPresent(statePath); } catch { /* residual is reported below */ } if (existsSync(statePath)) return { ok: false, reason: "cleanup_incomplete", residualPaths: [statePath] }; } catch { diff --git a/src/claude/desktop-remote-store-io.ts b/src/claude/desktop-remote-store-io.ts index 2039deceb8..97579222de 100644 --- a/src/claude/desktop-remote-store-io.ts +++ b/src/claude/desktop-remote-store-io.ts @@ -2,7 +2,7 @@ import { chmodSync, lstatSync, mkdirSync, realpathSync, unlinkSync } from "node: import { basename, dirname, join, resolve } from "node:path"; import { atomicWriteFile, getConfigDir } from "../config"; import { hardenSecretDir } from "../lib/windows-secret-acl"; -import { isRecord, readDesktopLibraryBytes, resolveDesktop3pConfigLibraryPath, SAFE_DESKTOP_PROFILE_ID, type Desktop3pMetadata } from "./desktop-3p-library"; +import { isRecord, MAX_DESKTOP_PROFILE_FILE_BYTES, readDesktopLibraryBytes, resolveDesktop3pConfigLibraryPath, SAFE_DESKTOP_PROFILE_ID, type Desktop3pMetadata } from "./desktop-3p-library"; import { DesktopStoreError, digest } from "./desktop-remote-store-state"; export const MAX_DESKTOP_METADATA_ENTRIES = 256; @@ -45,7 +45,7 @@ export class StoreIO { try { // The shared verified regular-file read keeps a swapped FIFO from parking this // open before the post-read identity recheck below can run. - const bytes = readDesktopLibraryBytes(path); + const bytes = readDesktopLibraryBytes(path, Math.min(maxBytes, MAX_DESKTOP_PROFILE_FILE_BYTES)); if (bytes.byteLength > maxBytes || identity(lstatSync(path)) !== identity(stat)) throw new DesktopStoreError("conflict"); const value: unknown = JSON.parse(bytes.toString("utf8")); if (!isRecord(value)) throw new DesktopStoreError("unsafe"); diff --git a/structure/clients/claude-desktop-config-reads.md b/structure/clients/claude-desktop-config-reads.md index 8c929ec864..db1cd7ab2a 100644 --- a/structure/clients/claude-desktop-config-reads.md +++ b/structure/clients/claude-desktop-config-reads.md @@ -8,8 +8,9 @@ profile reads — goes through the shared verified regular-file reader in `lstat` must already name a regular file; on POSIX the open is nonblocking and no-follow, so a path swapped to a FIFO cannot turn an observation into a blocking read and a swapped symlink is not followed; the opened descriptor must still be a regular file whose bigint device, inode and change -time match the path observation before its bytes are read, and it is always closed. Windows keeps -the same path-and-descriptor identity check without the POSIX-only open flags. A non-regular or +time match the path observation before its bytes are read, and it is always closed. The path is +checked again after reading. Windows keeps the same path-and-descriptor identity check without +the POSIX-only open flags. A non-regular or swapped path throws `desktop_config_not_regular`, including a swap that surfaces only at open (a symlink's `ELOOP`, a socket's `ENXIO`, a deletion's `ENOENT`): the failed open re-observes the path and reports the documented refusal whenever it no longer names the expected file, while a same-path @@ -36,5 +37,20 @@ reported with "profile rollback skipped". A failed rollback keeps the metadata e primary cause and reports that profile rollback also failed. An absent profile does not adopt a leftover backup from an earlier operation. -Tests: `tests/clients/desktop-3p-write-files.test.ts`, `tests/clients/desktop-3p-removal.test.ts`, `tests/clients/desktop-remote-store.test.ts`, and +The reader defaults to a 4 MiB profile and metadata limit (`MAX_DESKTOP_PROFILE_FILE_BYTES`). It +checks the opened size before allocation and reads no more than the bound plus one sentinel byte; +oversized input raises `desktop_config_too_large`. `StoreIO.read` in +`src/claude/desktop-remote-store-io.ts` passes its tighter limit to the same reader and retains its +private-file mode and post-read identity checks, reporting reader refusals as `unsafe`. + +`serializeDesktopLibraryJson` checks the UTF-8 byte length of the formatted JSON, including its +trailing newline, against the same 4 MiB limit. Gateway apply in `src/claude/desktop-3p.ts` +preflights the generated profile and metadata before replacing either; refresh preflights its +generated profile while leaving metadata unchanged. Picker apply in +`src/claude/desktop-picker-profile.ts` preflights metadata before its backup and profile writes. +Gateway and picker removal preflight the pivot and final metadata before creating a standard +profile, changing the selected row or unlinking profiles and backups. A serialization size refusal +therefore leaves the existing library files in place. + +Tests: `tests/clients/desktop-3p.test.ts`, `tests/clients/desktop-3p-write-files.test.ts`, `tests/clients/desktop-3p-removal.test.ts`, `tests/clients/desktop-remote-store.test.ts`, and `tests/claude-integration/claude-desktop-picker-profile.test.ts`. diff --git a/tests/claude-integration/claude-desktop-picker-profile.test.ts b/tests/claude-integration/claude-desktop-picker-profile.test.ts index 99b317e967..ff176d8a8f 100644 --- a/tests/claude-integration/claude-desktop-picker-profile.test.ts +++ b/tests/claude-integration/claude-desktop-picker-profile.test.ts @@ -1,10 +1,13 @@ +import * as pickerFilesystem from "node:fs"; import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { spawnSync } from "node:child_process"; -import { existsSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, unlinkSync, writeFileSync } from "node:fs"; import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import * as clientLifecycle from "../../src/client/lifecycle-lock"; import * as configIO from "../../src/config"; +import * as desktopLibrary from "../../src/claude/desktop-3p-library"; import { saveConfig } from "../../src/config"; import { inspectDesktopPickerProfile, @@ -16,7 +19,7 @@ import { removeDesktop3pStandardPivot, writeDesktop3pConfig, } from "../../src/claude/desktop-3p"; -import { isOwnedDesktopEntry, isOwnedDesktopGatewayEntry } from "../../src/claude/desktop-3p-library"; +import { isOwnedDesktopEntry, isOwnedDesktopGatewayEntry, MAX_DESKTOP_PROFILE_FILE_BYTES } from "../../src/claude/desktop-3p-library"; import { removeTreeWithRetry } from "../helpers/remove-tree"; let root: string; @@ -24,6 +27,8 @@ let library: string; let configDir: string; let previousHome: string | undefined; let previousLibrary: string | undefined; +let restoreLifecycleLock: (() => void) | undefined; +const realClientLifecycleSync = clientLifecycle.withClientLifecycleSync; function env(): NodeJS.ProcessEnv { return { ...process.env, OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR: library }; @@ -47,6 +52,13 @@ beforeEach(() => { mkdirSync(library, { recursive: true }); process.env.OPENCODEX_HOME = configDir; process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR = library; + // Keep real SQLite exclusion and lease checks, but isolate the user-scoped lock too. + const lifecycleLock = spyOn(clientLifecycle, "withClientLifecycleSync").mockImplementation( + (work, deps) => realClientLifecycleSync(work, { + ...deps, lockPath: deps?.lockPath ?? join(root, "locks", "lifecycle.sqlite"), + }), + ); + restoreLifecycleLock = () => lifecycleLock.mockRestore(); saveConfig({ port: 10100, defaultProvider: "test", @@ -56,6 +68,8 @@ beforeEach(() => { }); afterEach(() => { + restoreLifecycleLock?.(); + restoreLifecycleLock = undefined; if (previousHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousHome; if (previousLibrary === undefined) delete process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR; @@ -174,6 +188,68 @@ describe("Claude Desktop picker profile", () => { expect(inspectDesktop3pConfigLibrary({ env: env() })).toMatchObject({ kind: "standard", appliedId: picker }); }); + // Multi-MiB real-file fixtures plus ACL-hardened writes need extra Windows wall-clock budget. + test("oversized picker metadata refuses before profile and state writes", () => { + const id = "foreign-selected"; + writeFileSync(join(library, `${id}.json`), "{}\n"); + const metadata = { + appliedId: id, entries: [{ id, name: "Personal" }], + foreignRows: Array(500_000).fill("x"), + }; + const before = JSON.stringify(metadata); + expect(Buffer.byteLength(before)).toBeLessThan(MAX_DESKTOP_PROFILE_FILE_BYTES); + expect(Buffer.byteLength(JSON.stringify(metadata, null, 2))).toBeGreaterThan(MAX_DESKTOP_PROFILE_FILE_BYTES); + const path = join(library, "_meta.json"); + writeFileSync(path, before); + const names = readdirSync(library).sort(); + const serialize = spyOn(desktopLibrary, "serializeDesktopLibraryJson"); + try { + expect(applyDesktopPickerProfile({ proxyPort: 41234, env: env(), configDir })) + .toEqual({ ok: false, reason: "write_failed" }); + expect(serialize).toHaveBeenCalledTimes(1); + } finally { + serialize.mockRestore(); + } + expect(readFileSync(path, "utf8")).toBe(before); + expect(readdirSync(library).sort()).toEqual(names); + expect(existsSync(join(configDir, "claude-picker", "profile-state.json"))).toBe(false); + }, 20_000); + + test.each([ + ["selected with a previous row", true, true], + ["selected without a previous row", true, false], + ["unselected", false, true], + ] as const)("oversized removal metadata preserves all artifacts: %s", (_name, selected, previousExists) => { + const picker = "picker", previous = "foreign"; + const metadata = { + appliedId: selected ? picker : previous, + entries: [ + { id: picker, name: "opencodex-picker" }, + ...(previousExists ? [{ id: previous, name: "Personal" }] : []), + ], + foreignRows: Array(500_000).fill("x"), + }; + const compact = JSON.stringify(metadata); + expect(Buffer.byteLength(compact)).toBeLessThan(MAX_DESKTOP_PROFILE_FILE_BYTES); + expect(Buffer.byteLength(JSON.stringify(metadata, null, 2))).toBeGreaterThan(MAX_DESKTOP_PROFILE_FILE_BYTES); + const metaPath = join(library, "_meta.json"), profilePath = join(library, `${picker}.json`); + const backupPath = `${profilePath}.bak`, statePath = join(configDir, "claude-picker", "profile-state.json"); + mkdirSync(join(configDir, "claude-picker"), { recursive: true }); + writeFileSync(metaPath, compact); + writeFileSync(profilePath, '{"egressProxyUrl":"http://127.0.0.1:41234"}\n'); + writeFileSync(backupPath, '{"egressProxyUrl":"http://127.0.0.1:41233"}\n'); + writeFileSync(statePath, JSON.stringify({ entryId: picker, previousAppliedId: previous }) + "\n"); + if (previousExists) writeFileSync(join(library, `${previous}.json`), '{"foreign":true}\n'); + const paths = [metaPath, profilePath, backupPath, statePath]; + const snapshots = paths.map(path => readFileSync(path, "utf8")); + const names = readdirSync(library).sort(); + + expect(removeDesktopPickerProfile({ env: env(), configDir })) + .toEqual({ ok: false, reason: "desktop_config_too_large" }); + expect(paths.map(path => readFileSync(path, "utf8"))).toEqual(snapshots); + expect(readdirSync(library).sort()).toEqual(names); + }, 20_000); + test("a non-regular picker profile path inspects as unsafe instead of blocking", () => { const picker = "picker"; mkdirSync(join(library, `${picker}.json`)); @@ -192,3 +268,93 @@ describe("Claude Desktop picker profile", () => { expect(applyDesktopPickerProfile({ proxyPort: 41234, env: env(), configDir })).toEqual({ ok: false, reason: "write_failed" }); }); }); + +describe("picker boundary and recovery receipts", () => { + for (const operation of ["apply", "remove selected", "remove fallback", "remove unselected"] as const) for (const extra of [0, 1]) { + test(`${operation} preflights exact metadata output limit+${extra} and preserves extensions`, () => { + const picker = { id: "picker", name: "opencodex-picker", extension: { keep: [1, "picker"] } }; + const foreign = { id: "foreign", name: "Personal", extension: { keep: [2, "foreign"] } }; + const selected = operation === "remove selected" || operation === "remove fallback"; + const fallback = operation === "remove fallback"; + const metadata = { appliedId: selected ? picker.id : foreign.id, entries: fallback ? [picker] : [picker, foreign], foreignMeta: { keep: [true, "nested"] }, pad: "" }; + const standard = { id: "0".repeat(36), name: "opencodex-standard" }; + const projected = operation === "apply" ? { ...metadata, appliedId: picker.id } + : fallback ? { ...metadata, appliedId: standard.id, entries: [...metadata.entries, standard] } + : selected ? { ...metadata, appliedId: foreign.id } + : { ...metadata, entries: [foreign] }; + metadata.pad = "x".repeat(MAX_DESKTOP_PROFILE_FILE_BYTES + extra - Buffer.byteLength(JSON.stringify(projected, null, 2) + "\n")); + projected.pad = metadata.pad; + expect(Buffer.byteLength(JSON.stringify(projected, null, 2) + "\n")).toBe(MAX_DESKTOP_PROFILE_FILE_BYTES + extra); + const beforeMeta = JSON.stringify(metadata); expect(Buffer.byteLength(beforeMeta)).toBeLessThan(MAX_DESKTOP_PROFILE_FILE_BYTES); + writeFileSync(join(library, "_meta.json"), beforeMeta); + writeFileSync(join(library, "picker.json"), '{"egressProxyUrl":"http://127.0.0.1:41233"}\n'); + writeFileSync(join(library, "picker.json.bak"), "old backup"); + writeFileSync(join(library, "foreign.json"), '{"unowned":{"preserve":true}}\n'); + mkdirSync(join(configDir, "claude-picker"), { recursive: true }); + const statePath = join(configDir, "claude-picker", "profile-state.json"); + writeFileSync(statePath, JSON.stringify({ entryId: picker.id, previousAppliedId: foreign.id }) + "\n"); + const names = readdirSync(library).sort(), originals = names.map(n => readFileSync(join(library, n), "utf8")); + const beforeState = readFileSync(statePath, "utf8"); + const result = operation === "apply" + ? applyDesktopPickerProfile({ proxyPort: 41234, env: env(), configDir }) + : removeDesktopPickerProfile({ env: env(), configDir }); + if (extra) { + expect(result).toEqual({ ok: false, reason: operation === "apply" ? "write_failed" : "desktop_config_too_large" }); + expect(readdirSync(library).sort()).toEqual(names); + expect(names.map(n => readFileSync(join(library, n), "utf8"))).toEqual(originals); + expect(readFileSync(statePath, "utf8")).toBe(beforeState); + } else { + expect(result).toMatchObject({ ok: true, changed: true }); + const after = readJson(join(library, "_meta.json")); + expect(after.foreignMeta).toEqual(metadata.foreignMeta); + if (!fallback) expect(after.entries.find((e: any) => e.id === foreign.id)).toEqual(foreign); + expect(readFileSync(join(library, "foreign.json"), "utf8")).toBe('{"unowned":{"preserve":true}}\n'); + if (operation === "apply") { + expect(after.entries.find((e: any) => e.id === picker.id)).toEqual(picker); + expect(after.appliedId).toBe(picker.id); + } else { + expect(after.entries.some((e: any) => e.id === picker.id)).toBe(false); + expect(existsSync(join(library, "picker.json"))).toBe(false); expect(existsSync(statePath)).toBe(false); + if (fallback) expect(readJson(join(library, `${after.appliedId}.json`))).toEqual({}); + else expect(after.appliedId).toBe(foreign.id); + } + } + }, 20_000); + } + + for (const existing of [true, false]) { + test(`picker reports failure when metadata publication and ${existing ? "rollback restore" : "rollback cleanup"} fail`, () => { + const foreign = "foreign"; writeFileSync(join(library, `${foreign}.json`), '{"foreign":true}\n'); + writeMetadata({ appliedId: foreign, foreignMeta: { keep: true }, entries: [{ id: foreign, name: "Personal" }] }); + let profile: string | undefined; + if (existing) { + const first = applyDesktopPickerProfile({ proxyPort: 41233, env: env(), configDir }); + expect(first.ok).toBe(true); if (!first.ok) throw new Error("fixture setup failed"); profile = first.path; + } + const meta = join(library, "_meta.json"), beforeMeta = readFileSync(meta, "utf8"); + const realWrite = configIO.atomicWriteFile, rename = pickerFilesystem.renameSync, unlink = pickerFilesystem.unlinkSync; + let metadataFailed = false, recoveryFailed = false; + const writing = spyOn(configIO, "atomicWriteFile").mockImplementation((path, content, io, hooks) => { + if (path === meta) { metadataFailed = true; throw new Error("fixture metadata failure"); } + if (path.startsWith(library) && path.endsWith(".json") && path !== meta) profile = path; + return realWrite(path, content, io, hooks); + }); + const renaming = spyOn(pickerFilesystem, "renameSync").mockImplementation((source, destination) => { + if (existing && metadataFailed && destination === profile) { recoveryFailed = true; throw new Error("fixture restore failure"); } + return rename(source, destination); + }); + const removing = spyOn(pickerFilesystem, "unlinkSync").mockImplementation(path => { + if (!existing && metadataFailed && path === profile) { recoveryFailed = true; throw new Error("fixture cleanup failure"); } + return unlink(path); + }); + try { + expect(applyDesktopPickerProfile({ proxyPort: 41234, env: env(), configDir })).toEqual({ ok: false, reason: "write_failed" }); + expect(metadataFailed).toBe(true); expect(recoveryFailed).toBe(true); + expect(readFileSync(meta, "utf8")).toBe(beforeMeta); + // A failed rollback leaves a real residue; do not mistake the opaque failure for restoration. + expect(readJson(profile!).egressProxyUrl).toBe("http://127.0.0.1:41234"); + expect(readFileSync(join(library, `${foreign}.json`), "utf8")).toBe('{"foreign":true}\n'); + } finally { removing.mockRestore(); renaming.mockRestore(); writing.mockRestore(); } + }); + } +}); diff --git a/tests/clients/desktop-3p-removal.test.ts b/tests/clients/desktop-3p-removal.test.ts index 0168e60737..a846e65a03 100644 --- a/tests/clients/desktop-3p-removal.test.ts +++ b/tests/clients/desktop-3p-removal.test.ts @@ -2,7 +2,9 @@ import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; import { spawnSync } from "node:child_process"; import * as filesystem from "node:fs"; import { repoPath } from "../helpers/repo-root"; -import { readDesktopLibraryText, parseMetadata, readDesktopProfileForeignKeys } from "../../src/claude/desktop-3p-library"; +import { readDesktopLibraryText, readDesktopLibraryBytes, MAX_DESKTOP_PROFILE_FILE_BYTES, parseMetadata, readDesktopProfileForeignKeys } from "../../src/claude/desktop-3p-library"; +import { StoreIO } from "../../src/claude/desktop-remote-store-io"; +import { DesktopStoreError } from "../../src/claude/desktop-remote-store-state"; import { saveConfig, readConfigDiagnostics } from "../../src/config"; import type { OcxConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -281,3 +283,251 @@ test.skipIf(process.platform === "win32")("a regular-to-symlink replacement betw expect(filesystem.lstatSync(path).isSymbolicLink()).toBe(true); } finally { opening.mockRestore(); } }); + +test("Desktop library reads accept the byte boundary and refuse a larger file", () => { + const path = join(fixtureHome, "bounded.json"); + const source = "{}" + " ".repeat(MAX_DESKTOP_PROFILE_FILE_BYTES - 2); + writeFileSync(path, source); + expect(readDesktopLibraryText(path)).toBe(source); + writeFileSync(path, source + " "); + expect(() => readDesktopLibraryText(path)).toThrow("desktop_config_too_large"); + expect(readFileSync(path, "utf8")).toBe(source + " "); +}); + +test("Desktop library reads bound growth after descriptor validation", () => { + const path = join(fixtureHome, "growing.json"); + writeFileSync(path, "0"); + const read = filesystem.readSync; + let grew = false; + let consumed = 0; + const reading = spyOn(filesystem, "readSync").mockImplementation((fd, buffer, offset, length, position) => { + if (!grew) { + grew = true; + filesystem.appendFileSync(path, "0".repeat(65)); + } + const count = read(fd, buffer, offset, length, position); + consumed += count; + return count; + }); + try { + expect(() => readDesktopLibraryBytes(path, 64)).toThrow("desktop_config_too_large"); + expect(grew).toBe(true); + expect(consumed).toBe(65); + } finally { reading.mockRestore(); } +}); + +test("Desktop library reads refuse a path removed after descriptor validation", () => { + const path = join(fixtureHome, "removed-during-read.json"); + writeFileSync(path, "{}"); + const read = filesystem.readSync; + let removed = false; + const reading = spyOn(filesystem, "readSync").mockImplementation((fd, buffer, offset, length, position) => { + const count = read(fd, buffer, offset, length, position); + if (!removed) { removed = true; unlinkSync(path); } + return count; + }); + try { + expect(() => readDesktopLibraryBytes(path)).toThrow("desktop_config_not_regular"); + expect(removed).toBe(true); + } finally { reading.mockRestore(); } +}); + +// Both the selected gateway pivot and foreign-selected residue cleanup must preflight +// serialized metadata before creating a standard row or deleting any profile/backup. +test.each([true, false])("oversized removal metadata preserves all files with selected gateway=%s", selectedGateway => { + const library = join(fixtureHome, "oversized-removal"); + mkdirSync(library); + const owned = "owned-profile"; + const foreign = "foreign-profile"; + const metadata = { + appliedId: selectedGateway ? owned : foreign, + entries: [{ id: owned, name: "opencodex" }, { id: foreign, name: "Personal" }], + foreignRows: Array(500_000).fill("x"), + }; + const metadataBefore = JSON.stringify(metadata); + expect(Buffer.byteLength(metadataBefore)).toBeLessThan(MAX_DESKTOP_PROFILE_FILE_BYTES); + expect(Buffer.byteLength(JSON.stringify(metadata, null, 2) + "\n")).toBeGreaterThan(MAX_DESKTOP_PROFILE_FILE_BYTES); + const originals: Array<[string, string]> = [ + ["_meta.json", metadataBefore], + [`${owned}.json`, JSON.stringify({ + inferenceProvider: "gateway", inferenceCredentialKind: "static", + inferenceGatewayBaseUrl: "http://127.0.0.1:10100", inferenceGatewayApiKey: "not-a-secret", + })], + [`${owned}.json.bak`, '{"originalBackup":true}\n'], + [`${foreign}.json`, '{"foreign":true}\n'], + ]; + for (const [name, content] of originals) writeFileSync(join(library, name), content); + const options = { env: envFor(library), appliedFingerprint: appliedFingerprint(join(library, `${owned}.json`)) }; + expect(inspectDesktop3pConfigLibrary(options).kind).toBe(selectedGateway ? "gateway_ours" : "foreign"); + const namesBefore = filesystem.readdirSync(library).sort(); + const unlinked: string[] = []; + const result = removeDesktop3pStandardPivot({ + ...options, unlink: path => { unlinked.push(path); unlinkSync(path); }, + }); + expect(result).toMatchObject({ ok: false, changed: false, kind: "write_failed", reason: "desktop_mutation_failed" }); + expect(unlinked).toEqual([]); + expect(filesystem.readdirSync(library).sort()).toEqual(namesBefore); + for (const [name, content] of originals) expect(readFileSync(join(library, name), "utf8")).toBe(content); +}, 20_000); + +test("StoreIO limits a growing read to its tighter cap plus one sentinel byte", () => { + const path = join(fixtureHome, "store-growing.json"); + const maxBytes = 64; + const original = "{}\n"; + const appended = " ".repeat(maxBytes + 1); + writeFileSync(path, original); + const read = filesystem.readSync; + let grew = false; + let consumed = 0; + const reading = spyOn(filesystem, "readSync").mockImplementation((fd, buffer, offset, length, position) => { + if (!grew) { grew = true; filesystem.appendFileSync(path, appended); } + const count = read(fd, buffer, offset, length, position); + consumed += count; + return count; + }); + try { + let failure: unknown; + try { new StoreIO().read(path, maxBytes); } catch (error) { failure = error; } + expect(failure).toBeInstanceOf(DesktopStoreError); + expect(failure).toMatchObject({ reason: "unsafe" }); + expect(grew).toBe(true); + expect(consumed).toBe(maxBytes + 1); + } finally { reading.mockRestore(); } + expect(readFileSync(path, "utf8")).toBe(original + appended); +}); + +test("bounded reader accepts an empty regular file unchanged", () => { + const path = join(fixtureHome, "empty.json"); + writeFileSync(path, ""); + expect(readDesktopLibraryBytes(path, 64)).toEqual(Buffer.alloc(0)); + expect(readDesktopLibraryText(path)).toBe(""); + expect(filesystem.statSync(path).size).toBe(0); +}); + +test("bounded reader caps growth from an empty file at limit plus one", () => { + const path = join(fixtureHome, "empty-growing.json"); + writeFileSync(path, ""); + const read = filesystem.readSync; + let grew = false, consumed = 0; + const reading = spyOn(filesystem, "readSync").mockImplementation((fd, buffer, offset, length, position) => { + if (!grew) { grew = true; filesystem.appendFileSync(path, "x".repeat(80)); } + const count = read(fd, buffer, offset, length, position); consumed += count; return count; + }); + try { + expect(() => readDesktopLibraryBytes(path, 64)).toThrow("desktop_config_too_large"); + expect(grew).toBe(true); expect(consumed).toBe(65); + expect(readFileSync(path, "utf8")).toBe("x".repeat(80)); + } finally { reading.mockRestore(); } +}); + +for (const timing of ["before open", "after read"] as const) { + // Windows refuses rename-over-open with EPERM before the intended identity recheck. + test.skipIf(process.platform === "win32" && timing === "after read")(`bounded reader refuses a same-byte regular inode replacement ${timing}`, () => { + const path = join(fixtureHome, "inode.json"), replacement = join(fixtureHome, "other-inode.json"); + writeFileSync(path, "{}"); writeFileSync(replacement, "{}"); + const original = filesystem.statSync(path, { bigint: true }); + const next = filesystem.statSync(replacement, { bigint: true }); + expect(next.ino).not.toBe(original.ino); + const open = filesystem.openSync, read = filesystem.readSync; + let changed = false; + const swap = () => { if (!changed) { filesystem.renameSync(replacement, path); changed = true; } }; + const opening = spyOn(filesystem, "openSync").mockImplementation((target, flags, mode) => { + if (target === path && timing === "before open") swap(); + return open(target, flags, mode); + }); + const reading = spyOn(filesystem, "readSync").mockImplementation((fd, buffer, offset, length, position) => { + const count = read(fd, buffer, offset, length, position); + if (timing === "after read") swap(); + return count; + }); + try { + expect(() => readDesktopLibraryBytes(path)).toThrow("desktop_config_not_regular"); + expect(changed).toBe(true); expect(filesystem.statSync(path, { bigint: true }).ino).toBe(next.ino); + } finally { reading.mockRestore(); opening.mockRestore(); } + expect(readFileSync(path, "utf8")).toBe("{}"); + }); +} + +// Deterministic comparison coverage: inject only ctimeNs, avoiding filesystem clock granularity. +// This does not attest a real OS ctime transition or POSIX metadata behavior on Windows. +for (const timing of ["descriptor", "path recheck"] as const) { + test(`bounded reader refuses a ctime-only observation change at ${timing}`, () => { + const path = join(fixtureHome, "ctime.json"); writeFileSync(path, "{}"); + const original = filesystem.lstatSync(path, { bigint: true }); + const changed = { ...original, ctimeNs: original.ctimeNs + 1n, isFile: () => true }; + expect(changed.dev).toBe(original.dev); expect(changed.ino).toBe(original.ino); + expect(changed.size).toBe(original.size); + const fstat = filesystem.fstatSync, lstat = filesystem.lstatSync; + let pathCalls = 0, injected = false; + const descriptor = spyOn(filesystem, "fstatSync").mockImplementation(((fd: number, options: any) => { + if (timing === "descriptor") { injected = true; return changed; } + return fstat(fd, options); + }) as typeof filesystem.fstatSync); + const observing = spyOn(filesystem, "lstatSync").mockImplementation(((target: filesystem.PathLike, options: any) => { + if (target === path && ++pathCalls > 1 && timing === "path recheck") { injected = true; return changed; } + return lstat(target, options); + }) as typeof filesystem.lstatSync); + try { + expect(() => readDesktopLibraryBytes(path)).toThrow("desktop_config_not_regular"); + expect(injected).toBe(true); + } finally { observing.mockRestore(); descriptor.mockRestore(); } + expect(readFileSync(path, "utf8")).toBe("{}"); + expect(filesystem.statSync(path, { bigint: true }).ino).toBe(original.ino); + }); +} + +// Windows timestamp update/share semantics are not a POSIX ctime receipt. Keep the +// portable injected comparisons above; execute this real timestamp case on POSIX. +test.skipIf(process.platform === "win32")("bounded reader refuses a real ctime-only change after read on POSIX", () => { + const path = join(fixtureHome, "real-ctime.json"); writeFileSync(path, "{}"); + filesystem.utimesSync(path, 1, 1); + const before = filesystem.statSync(path, { bigint: true }), read = filesystem.readSync; + let changed = false; + const reading = spyOn(filesystem, "readSync").mockImplementation((fd, buffer, offset, length, position) => { + const count = read(fd, buffer, offset, length, position); + if (!changed) { filesystem.utimesSync(path, 1, 1); changed = true; } + return count; + }); + try { expect(() => readDesktopLibraryBytes(path)).toThrow("desktop_config_not_regular"); } + finally { reading.mockRestore(); } + const after = filesystem.statSync(path, { bigint: true }); + expect(changed).toBe(true); expect(after.ctimeNs).not.toBe(before.ctimeNs); + for (const key of ["dev", "ino", "size", "mode", "mtimeNs"] as const) expect(after[key]).toBe(before[key]); + expect(readFileSync(path, "utf8")).toBe("{}"); +}); + +for (const selectedGateway of [true, false]) for (const extra of [0, 1]) { + test(`standard removal preflights exact output limit+${extra}, selected gateway=${selectedGateway}`, () => { + const library = join(fixtureHome, "boundary-removal"); mkdirSync(library); + const foreign = { id: "foreign", name: "Personal", extension: { tags: ["preserve"] } }; + const owned = { id: "owned", name: "opencodex" }; + const metadata = { appliedId: selectedGateway ? owned.id : foreign.id, entries: [owned, foreign], foreignMeta: { nested: [1, "keep"] }, pad: "" }; + const standard = { id: "0".repeat(36), name: "opencodex-standard" }; + const expected = selectedGateway + ? { ...metadata, appliedId: standard.id, entries: [...metadata.entries, standard] } + : { ...metadata, entries: [foreign] }; + metadata.pad = "x".repeat(MAX_DESKTOP_PROFILE_FILE_BYTES + extra - Buffer.byteLength(JSON.stringify(expected, null, 2) + "\n")); + expected.pad = metadata.pad; + expect(Buffer.byteLength(JSON.stringify(expected, null, 2) + "\n")).toBe(MAX_DESKTOP_PROFILE_FILE_BYTES + extra); + const before = JSON.stringify(metadata); expect(Buffer.byteLength(before)).toBeLessThan(MAX_DESKTOP_PROFILE_FILE_BYTES); + writeFileSync(join(library, "_meta.json"), before); + writeFileSync(join(library, "owned.json"), '{"inferenceProvider":"gateway","inferenceCredentialKind":"static","inferenceGatewayBaseUrl":"http://127.0.0.1:10100","inferenceGatewayApiKey":"not-a-secret"}'); + writeFileSync(join(library, "owned.json.bak"), "backup"); writeFileSync(join(library, "foreign.json"), '{"foreign":true}\n'); + const names = filesystem.readdirSync(library).sort(), snapshots = names.map(n => readFileSync(join(library, n), "utf8")); + const unlinked: string[] = []; + const result = removeDesktop3pStandardPivot({ env: envFor(library), unlink: path => { unlinked.push(path); unlinkSync(path); } }); + if (extra) { + expect(result).toMatchObject({ ok: false, changed: false, kind: "write_failed" }); + expect(unlinked).toEqual([]); expect(filesystem.readdirSync(library).sort()).toEqual(names); + expect(names.map(n => readFileSync(join(library, n), "utf8"))).toEqual(snapshots); + } else { + expect(result).toMatchObject({ ok: true, changed: true, kind: "removed" }); + const after = JSON.parse(readFileSync(join(library, "_meta.json"), "utf8")); + expect(after.foreignMeta).toEqual(metadata.foreignMeta); expect(after.entries.find((e: any) => e.id === foreign.id)).toEqual(foreign); + expect(readFileSync(join(library, "foreign.json"), "utf8")).toBe('{"foreign":true}\n'); + expect(existsSync(join(library, "owned.json"))).toBe(false); + if (selectedGateway) expect(JSON.parse(readFileSync(join(library, `${after.appliedId}.json`), "utf8"))).toEqual({}); + else expect(after.appliedId).toBe(foreign.id); + } + }, 20_000); +} diff --git a/tests/clients/desktop-3p-write-files.test.ts b/tests/clients/desktop-3p-write-files.test.ts index 4406a4b416..1ef73396db 100644 --- a/tests/clients/desktop-3p-write-files.test.ts +++ b/tests/clients/desktop-3p-write-files.test.ts @@ -5,7 +5,7 @@ import { tmpdir } from "node:os"; import { basename, join } from "node:path"; import { saveConfig } from "../../src/config"; import { atomicReplaceDesktopConfig, writeDesktop3pConfig } from "../../src/claude/desktop-3p"; -import { parseMetadata, readDesktopProfileForeignKeys, readDesktopProfileSnapshot } from "../../src/claude/desktop-3p-library"; +import { MAX_DESKTOP_PROFILE_FILE_BYTES, parseMetadata, readDesktopProfileForeignKeys, readDesktopProfileSnapshot } from "../../src/claude/desktop-3p-library"; import * as desktopLibrary from "../../src/claude/desktop-3p-library"; import { isTestHomeGuardArmed } from "../../src/lib/test-home-guard"; import type { OcxConfig } from "../../src/types"; @@ -274,19 +274,19 @@ for (const scenario of ["backup", "rollback"] as const) { if (!isTestHomeGuardArmed()) throw new Error("guard not armed"); const profile = ${JSON.stringify(profile)}, meta = ${JSON.stringify(meta)}, fifo = ${JSON.stringify(fifo)}; const scenario = ${JSON.stringify(scenario)}; - const open = fs.openSync, read = fs.readFileSync, rename = fs.renameSync; + const open = fs.openSync, read = fs.readSync, rename = fs.renameSync; let profileFd, swapped = false; spyOn(fs, "openSync").mockImplementation((path, flags, mode) => { const fd = open(path, flags, mode); if (path === profile) profileFd = fd; return fd; }); - spyOn(fs, "readFileSync").mockImplementation((path, options) => { - const bytes = read(path, options); - if (scenario === "backup" && path === profileFd && !swapped) { + spyOn(fs, "readSync").mockImplementation((fd, buffer, offset, length, position) => { + const count = read(fd, buffer, offset, length, position); + if (scenario === "backup" && fd === profileFd && !swapped) { rename(fifo, profile); swapped = true; } - return bytes; + return count; }); spyOn(fs, "renameSync").mockImplementation((source, destination) => { if (scenario === "rollback" && destination === meta) throw new Error("fixture metadata failure"); @@ -322,3 +322,39 @@ for (const scenario of ["backup", "rollback"] as const) { } }, 20000); } + +test("oversized backup source refuses before backup or replacement writes", () => { + const path = join(library, "oversized.json"); + const original = Buffer.alloc(MAX_DESKTOP_PROFILE_FILE_BYTES + 1, 32); + fs.writeFileSync(path, original); + let writes = 0; + expect(() => atomicReplaceDesktopConfig(path, "{}\n", original, () => { writes++; })) + .toThrow("desktop_config_too_large"); + expect(writes).toBe(0); + expect(fs.readFileSync(path).equals(original)).toBe(true); + expect(fs.existsSync(path + ".bak")).toBe(false); +}); + +for (const kind of ["profile", "metadata"] as const) for (const extra of [0, 1]) { + test(`normal writer preflights exact ${kind} output limit+${extra}`, () => { + const first = writeProfile(); expect(first.written).toBe(true); + const meta = join(library, "_meta.json"), target = kind === "profile" ? first.path : meta; + const value = { ...JSON.parse(fs.readFileSync(target, "utf8")), foreignPad: "" }; + value.foreignPad = "x".repeat(MAX_DESKTOP_PROFILE_FILE_BYTES + extra - Buffer.byteLength(JSON.stringify(value, null, 2) + "\n")); + expect(Buffer.byteLength(JSON.stringify(value, null, 2) + "\n")).toBe(MAX_DESKTOP_PROFILE_FILE_BYTES + extra); + const input = JSON.stringify(value); expect(Buffer.byteLength(input)).toBeLessThan(MAX_DESKTOP_PROFILE_FILE_BYTES); + fs.writeFileSync(target, input); + const beforeProfile = fs.readFileSync(first.path), beforeMeta = fs.readFileSync(meta); + expect(fs.existsSync(first.path + ".bak")).toBe(false); + const result = writeProfile(); + if (extra) { + expect(result).toMatchObject({ written: false, reason: "desktop_config_too_large" }); + expect(fs.readFileSync(first.path)).toEqual(beforeProfile); expect(fs.readFileSync(meta)).toEqual(beforeMeta); + expect(fs.existsSync(first.path + ".bak")).toBe(false); + } else { + expect(result.written).toBe(true); expect(fs.statSync(target).size).toBe(MAX_DESKTOP_PROFILE_FILE_BYTES); + expect(JSON.parse(fs.readFileSync(target, "utf8")).foreignPad).toBe(value.foreignPad); + expect(fs.readFileSync(first.path + ".bak")).toEqual(beforeProfile); + } + }, 20_000); +} diff --git a/tests/clients/desktop-3p.test.ts b/tests/clients/desktop-3p.test.ts index f69ca3b095..4d8c3687b2 100644 --- a/tests/clients/desktop-3p.test.ts +++ b/tests/clients/desktop-3p.test.ts @@ -4,7 +4,7 @@ import { withClientLifecycleSync } from "../../src/client/lifecycle-lock"; import { applyRemoteDesktopStore } from "../../src/claude/desktop-remote-store"; import type { OcxConfig } from "../../src/types"; import { describe, expect, spyOn, test } from "bun:test"; -import { mkdirSync, mkdtempSync, realpathSync, readFileSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, realpathSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, posix, win32 } from "node:path"; import { @@ -24,6 +24,7 @@ import { writeRemoteDesktop3pConfig, type Desktop3pModelEntry, } from "../../src/claude/desktop-3p"; +import { assertDesktopLibraryTextSize, MAX_DESKTOP_PROFILE_FILE_BYTES } from "../../src/claude/desktop-3p-library"; import { moveDesktopRoute, reconcileDesktopProfile, setDesktopFamilyDefault } from "../../src/claude/desktop-profile"; import { resolveInboundModel } from "../../src/claude/inbound"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -677,3 +678,71 @@ test("Desktop FAST base validation preserves exact catalog IDs and clears stale expect(isUnresolvedDesktop3pAlias(unknown)).toBe(true); } finally { buildDesktop3pRegistry([], []); } }); + +// Multi-MiB real-file fixtures plus ACL-hardened writes need extra Windows wall-clock budget. +test("oversized Desktop replacement is refused before writing a backup", () => { + const dir = mkdtempSync(join(tmpdir(), "ocx-desktop-bound-")); + const path = join(dir, "profile.json"); + try { + writeFileSync(path, '{"original":true}'); + expect(() => atomicReplaceDesktopConfig(path, "x".repeat(MAX_DESKTOP_PROFILE_FILE_BYTES + 1), Buffer.from('{"original":true}'))) + .toThrow("desktop_config_too_large"); + expect(readFileSync(path, "utf8")).toBe('{"original":true}'); + expect(existsSync(path + ".bak")).toBe(false); + expect(() => assertDesktopLibraryTextSize("é".repeat(MAX_DESKTOP_PROFILE_FILE_BYTES / 2))).not.toThrow(); + expect(() => assertDesktopLibraryTextSize("é".repeat(MAX_DESKTOP_PROFILE_FILE_BYTES / 2 + 1))) + .toThrow("desktop_config_too_large"); + } finally { removeTreeWithRetry(dir); } +}, 20_000); + +test.each(["profile", "metadata"] as const)("oversized Desktop %s output refuses before file mutation", kind => { + const dir = mkdtempSync(join(tmpdir(), "ocx-desktop-write-bound-")); + const previous = process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR; + const previousHome = process.env.OPENCODEX_HOME; + process.env.OPENCODEX_HOME = join(dir, "ocx"); + process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR = dir; + try { + saveConfig({ + providers: { test: { adapter: "openai-chat", baseUrl: "http://127.0.0.1:1/v1", + allowPrivateNetwork: true, liveModels: false, models: ["fixture-model"] } }, + defaultProvider: "test", port: 4096, + } as OcxConfig); + const id = "owned-profile"; + const metadata = { + appliedId: id, entries: [{ id, name: "opencodex" }], + ...(kind === "metadata" ? { foreignRows: Array(500_000).fill("x") } : {}), + }; + const profile = { + inferenceProvider: "gateway", inferenceCredentialKind: "static", + inferenceGatewayBaseUrl: "http://127.0.0.1:1", inferenceGatewayApiKey: "old-key", + modelDiscoveryEnabled: false, inferenceModels: [], + ...(kind === "profile" ? { foreignPad: "x".repeat(MAX_DESKTOP_PROFILE_FILE_BYTES - 512) } : {}), + }; + const metadataPath = join(dir, "_meta.json"); + const profilePath = join(dir, `${id}.json`); + const beforeMeta = JSON.stringify(metadata); + const beforeProfile = JSON.stringify(profile); + expect(Buffer.byteLength(beforeMeta)).toBeLessThan(MAX_DESKTOP_PROFILE_FILE_BYTES); + expect(Buffer.byteLength(beforeProfile)).toBeLessThan(MAX_DESKTOP_PROFILE_FILE_BYTES); + if (kind === "metadata") { + expect(Buffer.byteLength(JSON.stringify(metadata, null, 2) + "\n")).toBeGreaterThan(MAX_DESKTOP_PROFILE_FILE_BYTES); + } + writeFileSync(metadataPath, beforeMeta); + writeFileSync(profilePath, beforeProfile); + const models = Array.from({ length: 100 }, (_, index) => ({ provider: "test", id: `fixture-${index}` })); + const result = writeDesktop3pConfig(4096, [], models, "new-key", "static", undefined, undefined, { + lockPath: join(dir, "locks", "desktop.sqlite"), + }); + expect(result.written).toBe(false); + expect(result.reason).toBe("desktop_config_too_large"); + expect(readFileSync(profilePath, "utf8")).toBe(beforeProfile); + expect(readFileSync(metadataPath, "utf8")).toBe(beforeMeta); + expect(existsSync(profilePath + ".bak")).toBe(false); + } finally { + if (previous === undefined) delete process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR; + else process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR = previous; + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + removeTreeWithRetry(dir); + } +}, 20_000); From b3a824eb2b08ba3f0fef13f2281c672c90e15765 Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 20 Oct 2026 05:50:51 +0900 Subject: [PATCH 07/22] fix(service): stop scheduler wrappers before Windows reinstall (carry #7313) Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com> --- scripts/test-layout/layout.json | 1 + src/service/orchestration.ts | 17 ++++ structure/ops/service-and-sidecars.md | 2 +- tests/fixtures/test-layout-expected.json | 1 + .../service-desktop-runtime-preflight.test.ts | 1 + .../service-install-wrapper-cleanup.test.ts | 94 +++++++++++++++++++ .../service-ownership-handover.test.ts | 1 + .../service/service-runtime-preflight.test.ts | 9 +- tests/service/service.test.ts | 7 +- 9 files changed, 127 insertions(+), 6 deletions(-) create mode 100644 tests/service/service-install-wrapper-cleanup.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index ccadc2d6d6..3d3a039ff9 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1690,6 +1690,7 @@ "service-auth-qualified-localhost.test.ts": "service", "service-child-ownership.test.ts": "service", "service-handled-signal-exit.test.ts": "service", "service-health-sentinel-linux.test.ts": "service", "service-health-sentinel-launch.test.ts": "service", "service-health-sentinel-policy.test.ts": "service", "service-health-sentinel-process.test.ts": "service", "service-health-sentinel-runner.test.ts": "service", + "service-install-wrapper-cleanup.test.ts": "service", "service-claim.test.ts": "service", "service-ownership-compatibility.test.ts": "service", "service-ownership-handover.test.ts": "service", diff --git a/src/service/orchestration.ts b/src/service/orchestration.ts index 6ca03ba137..6a10b2a832 100644 --- a/src/service/orchestration.ts +++ b/src/service/orchestration.ts @@ -238,6 +238,11 @@ export interface ServiceInstallPreparationDeps extends RuntimePreflightDeps { supervisionLatch?: SupervisionLatch; diagnose?: () => ServiceDiagnostic; managerOps?: (backend: ServiceBackend) => ServiceInstallCleanupOps | null; + /** + * Task Scheduler only: ends surviving launcher/wrapper processes of THIS + * installation. Scoped to canonical script/launcher tokens, never a bare PID. + */ + killServiceWrappers?: () => void; stopTrackedProxy?: () => Promise; platform?: NodeJS.Platform; } @@ -279,6 +284,18 @@ export async function prepareServiceInstall( if (!manager) throw new Error(`Background service manager is unavailable for ${backend}.`); if (manager.status() !== null) manager.stop(); } + if (platform === "win32") { + // `schtasks /end` ends the task instance but leaves the cmd `:loop` wrapper + // alive to respawn the proxy mid-install — the same survivor + // `ocx stop` kills via killWindowsServiceWrapperProcesses. Run it + // after every manager has been asked to stop and before the tracked-proxy + // kill so a child respawned in that gap is still caught below. It also runs + // when no manager stop happened: a registration deleted mid-uninstall can + // leave the detached wrapper looping. A manager stop that refused propagates + // above before this runs, so the respawner of a still-running service is + // never killed. + (deps.killServiceWrappers ?? killWindowsServiceWrapperProcesses)(); + } await (deps.stopTrackedProxy ?? stopTrackedProxyIfRunning)(); } diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index 5127a5452a..e82d02ff5f 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -95,7 +95,7 @@ Windows, claimed, supervised and uncertain targets remain ineligible for this up Before service install/repair or a Windows Codex shim mutation, the selected Bun executable must create and remove an exclusive nonce-named directory inside the config root: admission runs that exact lexical path with `-e`, no shell, a hidden window and a five-second timeout, and only a matching nonce acknowledgment admits. Failure refuses with `OCX_RUNTIME_PREFLIGHT_FAILED` (`spawn`, `timeout`, `create`, `remove` or `protocol`) and never discovers another runtime; the Node launcher may pick a validated PATH Bun before CLI startup when bundled Bun is unusable, and durable admission only probes that resulting selection. The frozen selection feeds scheduler rendering, the WinSW entry and the install-state writer. `installServiceSafely` refuses live Desktop supervision before selection or admission, then admits once before cleanup and uses internal commit functions; repair refuses Desktop before diagnosis, checks ownership and auth before admission, and admits before native repair or scheduler stop; direct public installers keep their own admission gate. -The fresh scheduler path captures root absence and freezes the runtime at entry, stages and registers its definition, removes staging and claims config ownership, then probes with `rootWasAbsent: false` immediately before `prepare()`; a refused probe rolls back the new registration before service-manager cleanup or asset/state publication. Healthy or disabled shims do not probe; refused automatic restore defers with guidance and startup continues. Non-Windows admission performs no spawn or filesystem work, selection stays pre-dotenv and `cliEntry` stays I/O-free. +The fresh scheduler path captures root absence and freezes the runtime at entry, stages and registers its definition, removes staging and claims config ownership, then probes with `rootWasAbsent: false` immediately before `prepare()`; a refused probe rolls back the new registration before service-manager cleanup or asset/state publication. Healthy or disabled shims do not probe; refused automatic restore defers with guidance and startup continues. Non-Windows admission performs no spawn or filesystem work, selection stays pre-dotenv and `cliEntry` stays I/O-free. That cleanup (`prepareServiceInstall`) ends every recorded, conflicting and requested manager, then — Windows only — issues the same best-effort launcher/wrapper termination `ocx stop` performs, scoped to this install's canonical script and launcher tokens (an `schtasks /end` survivor can respawn the proxy mid-install; this layer guarantees neither that the wrapper actually exited nor that the new instance comes up), then stops the tracked standalone listener; a refused or unverifiable manager stop aborts before that cleanup runs. An absent root reaching admission is created with an exclusive non-recursive mode-0700 mkdir under an existing parent and left empty for later ownership claiming; the preflight never deletes the config root (refusal, concurrent creation, contents and replacements are all preserved). Existing roots must be real directories, not files or symlinks/junctions, and probe cleanup is non-recursive. A standalone selection is admitted in-process only when its path equals `process.execPath` exactly (standalone is executable packaging, not Desktop ownership); that path has neither child isolation nor a timeout, so synchronous filesystem operations may block, and other standalone selections refuse. Recovery is an operator-selected trusted `OPENCODEX_BUN_PATH` before launching ocx, or an `npm install -g @bitkyc08/opencodex` reinstall followed by retry; no runtime discovery or probe memo is added. diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 1ae09d1c06..05a51e8fd4 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1160,6 +1160,7 @@ "server-xai-oauth-401-replay.test.ts": "server", "server-xai-responses-streaming.test.ts": "server", "service-auth-qualified-localhost.test.ts": "service", "service-child-ownership.test.ts": "service", "service-handled-signal-exit.test.ts": "service", "service-claim.test.ts": "service", + "service-install-wrapper-cleanup.test.ts": "service", "service-ownership-compatibility.test.ts": "service", "service-ownership-handover.test.ts": "service", "service-ownership-state.test.ts": "service", "ownership-mutation-lease-diagnostics.test.ts": "service", "service-probe-docker.test.ts": "service", "service-secrets.test.ts": "service", "service-sqlite-home.test.ts": "service", diff --git a/tests/service/service-desktop-runtime-preflight.test.ts b/tests/service/service-desktop-runtime-preflight.test.ts index b3065bf13c..e60e6f75c2 100644 --- a/tests/service/service-desktop-runtime-preflight.test.ts +++ b/tests/service/service-desktop-runtime-preflight.test.ts @@ -59,6 +59,7 @@ function fixture(supervised: boolean, passes: boolean) { ...admission, inspectSupervision, diagnose: () => { order.push("diagnose"); return diagnostic; }, managerOps: () => ({ status: () => "present", stop: () => { order.push("stop"); } }), + killServiceWrappers: () => { order.push("wrappers"); }, stopTrackedProxy: async () => { order.push("proxy"); }, }); if (action === "fresh install") return installFreshWindowsSchedulerSafely({ diff --git a/tests/service/service-install-wrapper-cleanup.test.ts b/tests/service/service-install-wrapper-cleanup.test.ts new file mode 100644 index 0000000000..0b4dab028f --- /dev/null +++ b/tests/service/service-install-wrapper-cleanup.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, test } from "bun:test"; +import { prepareServiceInstall } from "../../src/service"; + +describe("service install preparation wrapper cleanup", () => { + test("install preparation kills surviving scheduler wrappers before the tracked proxy stop", async () => { + // The defect: `schtasks /end` ends the task instance but the detached cmd + // `:loop` wrapper survives and respawns its child seconds later — landing + // after the one-shot tracked-proxy kill and holding the install port. Model + // the respawner: while the wrapper lives, a stopped proxy comes back. + const calls: string[] = []; + let wrapperAlive = true; + let respawned = false; + await prepareServiceInstall("scheduler", { + platform: "win32", + diagnose: () => ({ supported: true, installed: true, enabled: true, running: true, viable: true, startable: true, stale: false, conflict: false, backend: "scheduler", summary: "test" }), + managerOps: () => ({ + status: () => "present", + // `schtasks /end` ends the task instance; it cannot reach the detached wrapper. + stop: () => { calls.push("stop:scheduler"); }, + }), + killServiceWrappers: () => { calls.push("kill:wrappers"); wrapperAlive = false; }, + stopTrackedProxy: async () => { + calls.push("stop:standalone"); + if (wrapperAlive) respawned = true; + }, + }); + expect(calls).toEqual(["stop:scheduler", "kill:wrappers", "stop:standalone"]); + expect(respawned).toBe(false); + }); + + test("install preparation kills stray scheduler wrappers even when no manager stop runs", async () => { + // A registration deleted mid-uninstall leaves no task for `schtasks /end`, but + // the detached wrapper may still be looping: it must not respawn the old proxy + // onto the port the fresh install is about to claim. + let killed = false; + const calls: string[] = []; + await prepareServiceInstall("scheduler", { + platform: "win32", + diagnose: () => ({ supported: true, installed: false, enabled: false, running: false, viable: false, startable: false, stale: false, conflict: false, backend: null, summary: "test" }), + managerOps: () => ({ + status: () => null, + stop: () => { calls.push("stop:scheduler"); throw new Error("no task to stop"); }, + }), + killServiceWrappers: () => { calls.push("kill:wrappers"); killed = true; }, + stopTrackedProxy: async () => { calls.push("stop:standalone"); }, + }); + expect(killed).toBe(true); + expect(calls).toEqual(["kill:wrappers", "stop:standalone"]); + }); + + test("a manager that refuses to stop aborts install preparation before the wrapper kill", async () => { + // Propagating the refusal is the fail-closed contract — and killing the wrapper + // of a service whose task is still running would break that surviving service. + const calls: string[] = []; + await expect(prepareServiceInstall("scheduler", { + platform: "win32", + diagnose: () => ({ supported: true, installed: true, enabled: true, running: true, viable: true, startable: true, stale: false, conflict: false, backend: "scheduler", summary: "test" }), + managerOps: () => ({ + status: () => "present", + stop: () => { calls.push("stop:scheduler"); throw new Error("end refused"); }, + }), + killServiceWrappers: () => { calls.push("kill:wrappers"); }, + stopTrackedProxy: async () => { calls.push("stop:standalone"); }, + })).rejects.toThrow("end refused"); + expect(calls).toEqual(["stop:scheduler"]); + }); + + test("non-Windows install preparation never invokes the scheduler wrapper kill", async () => { + let killed = 0; + await prepareServiceInstall("scheduler", { + platform: "linux", + managerOps: () => ({ status: () => "present", stop: () => {} }), + killServiceWrappers: () => { killed++; }, + stopTrackedProxy: async () => {}, + }); + expect(killed).toBe(0); + }); + + test("live Desktop supervision refuses install preparation before the wrapper kill", async () => { + // The pre-mutation supervision refusal verified here stays ahead of every + // cleanup step: a desktop-owned runtime keeps its respawner until the + // operator quits Desktop and retries. + const calls: string[] = []; + await expect(prepareServiceInstall("scheduler", { + platform: "win32", + inspectSupervision: () => ({ kind: "desktop", runtimePid: 4242, supervisorPid: 4200, app: "opencodex-desktop", proxy: "ocx" }), + diagnose: () => { calls.push("diagnose"); return { supported: true, installed: true, enabled: true, running: true, viable: true, startable: true, stale: false, conflict: false, backend: "scheduler", summary: "test" }; }, + managerOps: () => { calls.push("manager"); return { status: () => "present", stop: () => {} }; }, + killServiceWrappers: () => { calls.push("kill:wrappers"); }, + stopTrackedProxy: async () => { calls.push("stop:standalone"); }, + })).rejects.toThrow(/OpenCodex Desktop supervises the running proxy/); + expect(calls).toEqual([]); + }); +}); diff --git a/tests/service/service-ownership-handover.test.ts b/tests/service/service-ownership-handover.test.ts index 80c66ae76b..94715cb508 100644 --- a/tests/service/service-ownership-handover.test.ts +++ b/tests/service/service-ownership-handover.test.ts @@ -214,6 +214,7 @@ describe("live Desktop service command guards", () => { inspectSupervision: () => evidence, diagnose: () => { touched.push("diagnose"); return INSTALLED; }, managerOps: () => { touched.push("manager"); return { status: () => "running", stop: () => { touched.push("stop"); } }; }, + killServiceWrappers: () => { touched.push("wrappers"); }, stopTrackedProxy: async () => { touched.push("tracked"); }, }; await expect(prepareServiceInstall("scheduler", deps)).rejects.toThrow(desktopServiceRefusal(evidence, true)!); diff --git a/tests/service/service-runtime-preflight.test.ts b/tests/service/service-runtime-preflight.test.ts index 7ea60dc517..6452f0f506 100644 --- a/tests/service/service-runtime-preflight.test.ts +++ b/tests/service/service-runtime-preflight.test.ts @@ -45,6 +45,7 @@ describe("service selected-runtime admission", () => { await expect(installServiceSafely("scheduler", () => { effects.push("install"); }, { ...deps, diagnose: () => { effects.push("diagnose"); return diagnostic; }, managerOps: () => ({ status: () => "present", stop: () => { effects.push("stop"); } }), + killServiceWrappers: () => {}, stopTrackedProxy: async () => { effects.push("proxy"); }, })).rejects.toMatchObject({ code: "OCX_RUNTIME_PREFLIGHT_FAILED", reason }); const home = join(temporary(), "absent-home"); @@ -90,10 +91,11 @@ describe("service selected-runtime admission", () => { assertRuntimeWritable: admitted => { order.push("preflight"); expect(admitted).toEqual(runtime); selected.path = "unprobed.exe"; }, diagnose: () => diagnostic, managerOps: () => ({ status: () => "present", stop: () => { order.push("stop"); } }), + killServiceWrappers: () => { order.push("wrappers"); }, stopTrackedProxy: async () => { order.push("proxy"); }, }); expect(selections).toBe(1); - expect(order).toEqual(["preflight", "stop", "proxy", "install"]); + expect(order).toEqual(["preflight", "stop", "wrappers", "proxy", "install"]); }); for (const backend of ["scheduler", "native"] as const) { @@ -138,6 +140,7 @@ describe("service selected-runtime admission", () => { const result = installServiceSafely(backend, ops.install, { ...admission, diagnose: () => ({ ...diagnostic, backend }), managerOps: () => ({ status: () => "present", stop: () => { order.push("stop"); } }), + killServiceWrappers: () => { order.push("wrappers"); }, stopTrackedProxy: async () => { order.push("proxy"); }, }); if (refuses) { @@ -146,8 +149,8 @@ describe("service selected-runtime admission", () => { } else { await result; expect(order).toEqual(backend === "scheduler" - ? ["preflight", "stop", "proxy", "ownership", "remove-native", "installer-stop", "assets", "/create", "/run", "state"] - : ["preflight", "stop", "proxy", "account", "native-config", "/query", "winsw", "state"]); + ? ["preflight", "stop", "wrappers", "proxy", "ownership", "remove-native", "installer-stop", "assets", "/create", "/run", "state"] + : ["preflight", "stop", "wrappers", "proxy", "account", "native-config", "/query", "winsw", "state"]); expect(JSON.parse(readFileSync(join(home, "state.json"), "utf8"))).toMatchObject({ backend, bunPath: runtime.path }); } expect(probes).toBe(1); expect(selections).toBe(1); diff --git a/tests/service/service.test.ts b/tests/service/service.test.ts index 89f743de3c..e7075cfb36 100644 --- a/tests/service/service.test.ts +++ b/tests/service/service.test.ts @@ -2293,12 +2293,13 @@ describe("service lifecycle cleanup ordering", () => { platform: "win32", diagnose: () => ({ supported: true, installed: true, enabled: true, running: true, viable: true, startable: true, stale: false, conflict: false, backend: "scheduler", summary: "test" }), managerOps, + killServiceWrappers: () => { calls.push("kill:wrappers"); }, stopTrackedProxy: async () => { calls.push("stop:standalone"); }, }); expect(calls).toEqual([ "status:scheduler", "stop:scheduler", "status:native", "stop:native", - "stop:standalone", "install:native", + "kill:wrappers", "stop:standalone", "install:native", ]); }); @@ -2406,6 +2407,7 @@ describe("service lifecycle cleanup ordering", () => { if (failure === "stop") throw new Error("stop failed"); }, }), + killServiceWrappers: () => {}, stopTrackedProxy: async () => { if (failure === "standalone") throw new Error("standalone failed"); }, @@ -2421,9 +2423,10 @@ describe("service lifecycle cleanup ordering", () => { platform: "win32", diagnose: () => ({ supported: true, installed: true, enabled: true, running: true, viable: false, startable: false, stale: false, conflict: true, backend: "scheduler", summary: "test" }), managerOps: backend => ({ status: () => "present", stop: () => { stopped.push(backend); } }), + killServiceWrappers: () => { stopped.push("wrappers"); }, stopTrackedProxy: async () => {}, }); - expect(stopped).toEqual(["scheduler", "native"]); + expect(stopped).toEqual(["scheduler", "native", "wrappers"]); }); test("direct service stop kills the tracked proxy before restoring native Codex", async () => { From c0f0510fdb3d9dfa125cddacfeb045370a54014c Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 20 Oct 2026 05:50:51 +0900 Subject: [PATCH 08/22] fix(startup): require RunAtLoad before Desktop relaunch (carry #7277) Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com> --- src/cli/ensure-desktop-cold-start.ts | 4 ++-- structure/runtime.md | 2 +- .../cli/cli-ensure-desktop-cold-start.test.ts | 24 +++++++++++++++++-- 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/src/cli/ensure-desktop-cold-start.ts b/src/cli/ensure-desktop-cold-start.ts index 60b5f37131..a1784fae63 100644 --- a/src/cli/ensure-desktop-cold-start.ts +++ b/src/cli/ensure-desktop-cold-start.ts @@ -241,12 +241,12 @@ function consentedDesktopBundle(home: string, installId: string, run: Execute): const plistPath = realpathSync(join(home, "Library", "LaunchAgents", `${DESKTOP_PLIST_LABEL}.plist`)); const registration = JSON.parse( run("/usr/bin/plutil", ["-convert", "json", "-o", "-", plistPath], 3_000), - ) as { Label?: unknown; ProgramArguments?: unknown; Program?: unknown }; + ) as { Label?: unknown; RunAtLoad?: unknown; ProgramArguments?: unknown; Program?: unknown }; const args = registration.ProgramArguments; // The shapes auto-launch writes: [program] or [program, "--autostart"]; see the migration // rules in desktop/src-tauri/src/login_registration.rs, which classify anything else Leave. // This is a macOS plist path, regardless of the host running an injected launch probe. - if (registration.Label !== DESKTOP_PLIST_LABEL || !Array.isArray(args) + if (registration.Label !== DESKTOP_PLIST_LABEL || registration.RunAtLoad !== true || !Array.isArray(args) || (args.length !== 1 && args.length !== 2) || typeof args[0] !== "string" || !posix.isAbsolute(args[0]) || (args.length === 2 && args[1] !== "--autostart") || (registration.Program !== undefined && registration.Program !== args[0])) return null; diff --git a/structure/runtime.md b/structure/runtime.md index 3585af3cc6..114e7254f4 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -538,7 +538,7 @@ Codex compaction uses a request-local model override for the configured triggers state, beside the install provenance. The claim carries an `owner` (`cli` or `desktop`), an opaque `installId` naming the owning installation rather than the user or the machine, and a `consentGeneration`. An absent claim means the CLI install that registered the service owns -the runtime, which is what every record written before the field existed says. `src/service/desktop-startup.ts` separates the durable macOS, Linux and Windows desktop claim from startup viability. Windows Desktop identity is read from `%APPDATA%\com.opencodex.desktop\install-id` and checked against live parentage; Windows Start at Login is unverified and Desktop never receives startup protection credit there. Independent [live Desktop supervision](desktop-shell.md#runtime-ownership-from-the-apps-side) can also grant startup protection without a claim when that app's login registration is verified; it never becomes ownership. On Linux, auto-launch 0.5.0 writes the login entry under HOME/.config/autostart, so a relocated XDG_CONFIG_HOME (outside that search path) is uncredited. Conditional entries and ambiguous Exec syntax are uncredited; the resolved desktop basename must match and its resolved ocx stays in the same directory. Both complete evidence reads must agree on ownership, install-id, entry contents, executable targets, PID and parent relationship; changes fail closed. AppImage registrations are uncredited because they name the outer image rather than the mounted executable. `src/server/startup-health-cache.ts` launches source and compiled probes with `selfLaunchArgv`, revokes stale protection, and preserves desktop recovery guidance. An enabled, identity-matching macOS LaunchAgent registration remains viable when `launchctl print` specifically proves the job is absent in the current session. `src/service/desktop-startup.ts` reports `registered-not-loaded` for next login; wrong plist paths/arguments/label, disabled override, unknown failures and supervisor races still refuse credit. `src/codex/autostart-health.ts` distinguishes this protected registration in restart safety. `src/service/linux-linger.ts` uses bounded `loginctl show-user` to report already-enabled linger, advise activation only when disabled, or explicitly report unavailable diagnosis. `src/cli/ensure-desktop-cold-start.ts` launches a claimed Desktop only on macOS. Its LaunchAgent executable path must be POSIX-absolute, including when the launch probe is exercised on another host; Windows drive paths do not satisfy that plist contract. Launches on Linux and Windows refuse without invoking a command. +the runtime, which is what every record written before the field existed says. `src/service/desktop-startup.ts` separates the durable macOS, Linux and Windows desktop claim from startup viability. Windows Desktop identity is read from `%APPDATA%\com.opencodex.desktop\install-id` and checked against live parentage; Windows Start at Login is unverified and Desktop never receives startup protection credit there. Independent [live Desktop supervision](desktop-shell.md#runtime-ownership-from-the-apps-side) can also grant startup protection without a claim when that app's login registration is verified; it never becomes ownership. On Linux, auto-launch 0.5.0 writes the login entry under HOME/.config/autostart, so a relocated XDG_CONFIG_HOME (outside that search path) is uncredited. Conditional entries and ambiguous Exec syntax are uncredited; the resolved desktop basename must match and its resolved ocx stays in the same directory. Both complete evidence reads must agree on ownership, install-id, entry contents, executable targets, PID and parent relationship; changes fail closed. AppImage registrations are uncredited because they name the outer image rather than the mounted executable. `src/server/startup-health-cache.ts` launches source and compiled probes with `selfLaunchArgv`, revokes stale protection, and preserves desktop recovery guidance. An enabled, identity-matching macOS LaunchAgent registration remains viable when `launchctl print` specifically proves the job is absent in the current session. `src/service/desktop-startup.ts` reports `registered-not-loaded` for next login; wrong plist paths/arguments/label, disabled override, unknown failures and supervisor races still refuse credit. `src/codex/autostart-health.ts` distinguishes this protected registration in restart safety. `src/service/linux-linger.ts` uses bounded `loginctl show-user` to report already-enabled linger, advise activation only when disabled, or explicitly report unavailable diagnosis. `src/cli/ensure-desktop-cold-start.ts` launches a claimed Desktop only on macOS. Its LaunchAgent requires `RunAtLoad: true` and a POSIX-absolute executable path, including when the launch probe is exercised on another host; Windows drive paths do not satisfy that plist contract. Both `[program, "--autostart"]` and the legacy `[program]` arguments remain accepted. When this caller attempts the relaunch, a false or missing `RunAtLoad` returns manual-open guidance without waiting for readiness or falling back to a CLI proxy; an already-running Desktop still follows the wait path. Launches on Linux and Windows refuse without invoking a command. Every write goes through `swapServiceInstallState`. Guarded tests use only their selected home; see [test state isolation](ops/test-sandbox-cleanup.md). Production retains both paths. With a custom home, the default-home record is the authority every writer can derive and the active-home record is a compatibility diff --git a/tests/cli/cli-ensure-desktop-cold-start.test.ts b/tests/cli/cli-ensure-desktop-cold-start.test.ts index 5b1ec4ce80..5b9f58fbf1 100644 --- a/tests/cli/cli-ensure-desktop-cold-start.test.ts +++ b/tests/cli/cli-ensure-desktop-cold-start.test.ts @@ -169,7 +169,7 @@ describe("Desktop process detection by executable identity", () => { function fakeDesktopHome(options: { installId?: string; args?: unknown; bareArgs?: boolean; label?: string; identifier?: string; plist?: "present" | "absent"; executable?: "present" | "absent" | "directory"; bundleName?: string; - bundleExecutable?: unknown; program?: unknown; relativeProgram?: boolean; + bundleExecutable?: unknown; program?: unknown; relativeProgram?: boolean; runAtLoad?: unknown; } = {}) { // Keep the bundle on cwd's drive: Windows relative() returns an absolute path across drives. const fixtureRoot = join(process.cwd(), ".tmp"); @@ -190,7 +190,7 @@ describe("Desktop process detection by executable identity", () => { // A root-relative POSIX spelling also reaches this same-drive fixture on Windows. const absoluteProgram = process.platform === "win32" ? executable.slice(2).replaceAll("\\", "/") : executable; const program = options.relativeProgram ? relative(process.cwd(), executable) : absoluteProgram; - const registration = { Label: options.label ?? "OpenCodex", RunAtLoad: true, + const registration = { Label: options.label ?? "OpenCodex", RunAtLoad: "runAtLoad" in options ? options.runAtLoad : true, ProgramArguments: options.bareArgs ? [program] : (options.args ?? [program, "--autostart"]), ...("program" in options ? { Program: options.program } : {}) }; if (options.plist !== "absent") writeFileSync(join(home, "Library", "LaunchAgents", "OpenCodex.plist"), JSON.stringify(registration)); @@ -223,6 +223,26 @@ describe("Desktop process detection by executable identity", () => { } finally { removeTreeWithRetry(fixture.home); } }); + test("a false or missing RunAtLoad refuses launch without waiting or falling back to a CLI proxy", async () => { + for (const runAtLoad of [false, undefined]) { + const fixture = fakeDesktopHome({ installId: "install-a", runAtLoad }); + try { + expect(launch(fixture)).toBe(false); + const calls = fresh(); + const dependencies = deps({ ownership: "desktop", process: "absent" }, calls); + dependencies.launchDesktop = () => launch(fixture); + const result = await runDesktopColdStart(dependencies); + let cliFallbacks = 0; + if (result.kind === "cli") cliFallbacks++; + expect(result.kind === "failed" && result.message).toContain("Open OpenCodex manually"); + expect(calls.waits).toEqual([]); + expect(calls.released).toBe(1); + expect(cliFallbacks).toBe(0); + expect(fixture.seen.filter(call => call[0] === "/usr/bin/open")).toHaveLength(0); + } finally { removeTreeWithRetry(fixture.home); } + } + }); + test("a non-app directory refuses the launch without reading its Info.plist or waiting for a proxy", async () => { for (const bundleName of ["OpenCodex", "OpenCodex.app.backup"]) { const fixture = fakeDesktopHome({ installId: "install-a", bundleName }); From 6c773f8f990d36be96cf67f27da84bb02129fe28 Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 20 Oct 2026 05:50:51 +0900 Subject: [PATCH 09/22] fix(codex): strip OpenSSL startup variables from updater child env (carry #7342) Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com> --- src/codex/cli-update-plan.ts | 21 ++++++-- structure/codex-cli-update.md | 3 +- .../codex-cli-update-plan.test.ts | 49 +++++++++++++++++++ 3 files changed, 69 insertions(+), 4 deletions(-) diff --git a/src/codex/cli-update-plan.ts b/src/codex/cli-update-plan.ts index 190a108800..f87c2f5de6 100644 --- a/src/codex/cli-update-plan.ts +++ b/src/codex/cli-update-plan.ts @@ -213,9 +213,15 @@ function npmTarget(args: readonly string[], env?: NodeJS.ProcessEnv): NpmTarget * reverse: they are output paths, so ambient values would write Node artifacts to * arbitrary external directories on exit. ELF/Darwin loader controls (LD_* and * DYLD_*) are also removed: they can load code or redirect loader output before - * npm runs. This is a denylist for process-startup/output channels, not a claim - * that every ambient variable is allowlisted. Ordinary OS environment, proxy - * settings and the supported NODE_EXTRA_CA_CERTS extension remain available. + * npm runs. OpenSSL's startup inputs are removed with them: OPENSSL_CONF names + * a configuration file the crypto library reads at startup, OPENSSL_CONF_INCLUDE + * resolves relative includes inside that configuration, and OPENSSL_MODULES / + * OPENSSL_ENGINES name the directories its provider and engine search loads + * shared modules from. None of these is input the updater child needs, so + * ambient values stay out of the spawned npm. This is a denylist for + * process-startup/output channels, not a claim that every ambient variable is + * allowlisted. Ordinary OS environment, proxy settings and the supported + * NODE_EXTRA_CA_CERTS extension remain available. */ const NPM_ENV_DROP_KEYS: ReadonlySet = new Set([ "node_options", @@ -227,6 +233,15 @@ const NPM_ENV_DROP_KEYS: ReadonlySet = new Set([ // escaping the owned temp root regardless of the isolated --cache. "node_v8_coverage", "node_redirect_warnings", + // OpenSSL startup inputs: OPENSSL_CONF points the library at an ambient + // startup configuration file (OPENSSL_CONF_INCLUDE resolves that config's + // relative includes); OPENSSL_MODULES and OPENSSL_ENGINES point its + // provider/engine search at ambient directories that supply loadable + // shared modules. + "openssl_conf", + "openssl_conf_include", + "openssl_modules", + "openssl_engines", ]); function codexCliUpdateNpmEnv(base: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { diff --git a/structure/codex-cli-update.md b/structure/codex-cli-update.md index 57b0bcf175..bae97d671a 100644 --- a/structure/codex-cli-update.md +++ b/structure/codex-cli-update.md @@ -11,7 +11,8 @@ The read-only CLI update plan binds npm executable selection to the inspected la Registry integrity requires a canonical base64 SHA-512 digest of exactly 64 bytes; malformed or truncated tokens refuse the target. Selection observation uses the runtime resolver with provenance's bounded no-follow, nonblocking state reader and local executable-path policy. Windows returns before invoking the resolver; its deferred inspection performs no candidate or configuration I/O. Its child environment drops npm configuration overrides, named Node startup/output controls, -and the `LD_*`/`DYLD_*` loader families; proxy settings and `NODE_EXTRA_CA_CERTS` remain supported. +the `LD_*`/`DYLD_*` loader families and the OpenSSL startup configuration and +provider/engine search variables; proxy settings and `NODE_EXTRA_CA_CERTS` remain supported. Distinct npm config files, cache and logs stay under the owned temporary root with best-effort cleanup; a cleanup error does not replace the computed plan/refusal. The POSIX update scan includes all readable users by default, retaining an explicitly injected UID scope. diff --git a/tests/codex-integration/codex-cli-update-plan.test.ts b/tests/codex-integration/codex-cli-update-plan.test.ts index f3e4c2eac9..6d3b86fccc 100644 --- a/tests/codex-integration/codex-cli-update-plan.test.ts +++ b/tests/codex-integration/codex-cli-update-plan.test.ts @@ -564,6 +564,55 @@ describe("registry configuration isolation", () => { } }); + test.each([ + "OPENSSL_CONF", "OPENSSL_CONF_INCLUDE", "OPENSSL_MODULES", "OPENSSL_ENGINES", + "openssl_conf", "openssl_modules", + ])("ambient OpenSSL startup control %s cannot reach the spawned npm", key => { + const previous = process.env[key]; + process.env[key] = join(tmpdir(), "ocx-ambient-openssl.cnf"); + try { + // Capture only: no child process, native provider or network is invoked. + const { calls, spawn } = capturingSpawn(RESOLVE_OUTPUTS); + expect(resolveCodexCliUpdateTarget("latest", spawn).kind).toBe("resolved"); + expect(calls.length).toBe(3); + for (const call of calls) { + const env = call.options.env as NodeJS.ProcessEnv; + expect(Object.keys(env).some(name => name.toLowerCase() === key.toLowerCase())).toBe(false); + } + } finally { + if (previous === undefined) delete process.env[key]; + else process.env[key] = previous; + } + }); + + test("supported proxy and CA channels still reach the spawned npm", () => { + const sentinels = { + HTTP_PROXY: "http://127.0.0.1:9/ocx-proxy-sentinel", + NO_PROXY: "registry.npmjs.org.invalid,127.0.0.1", + NODE_EXTRA_CA_CERTS: join(tmpdir(), "ocx-corporate-ca.pem"), + }; + const previous = Object.fromEntries(Object.keys(sentinels).map(key => [key, process.env[key]])); + try { + Object.assign(process.env, sentinels); + const { calls, spawn } = capturingSpawn(RESOLVE_OUTPUTS); + expect(resolveCodexCliUpdateTarget("latest", spawn).kind).toBe("resolved"); + for (const call of calls) { + const env = call.options.env as NodeJS.ProcessEnv; + for (const [key, value] of Object.entries(sentinels)) { + // Match case-insensitively: the child env is a plain object, so a + // Windows-normalized casing must not make a supported channel look absent. + const entry = Object.entries(env).find(([name]) => name.toLowerCase() === key.toLowerCase()); + expect(entry?.[1]).toBe(value); + } + } + } finally { + for (const key of Object.keys(sentinels)) { + if (previous[key] === undefined) delete process.env[key]; + else process.env[key] = previous[key]; + } + } + }); + test("real Node cannot write output sentinels outside the owned npm root", () => { const fixture = fs.realpathSync.native(mkdtempSync(join(tmpdir(), "ocx-update-node-output-"))); const sentinels = { From c00446c663f6b4a44e3d43fc1b300a54e310d62c Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 20 Oct 2026 05:57:15 +0900 Subject: [PATCH 10/22] fix(codex): keep the service token out of bypassed shim launches All three Codex shims loaded the service API token into OPENCODEX_API_AUTH_TOKEN before checking OCX_SHIM_BYPASS, so a bypassed native passthrough launch handed the proxy data-plane token to the real Codex and its descendants. The POSIX, CMD and PowerShell shims now skip the token load under bypass; the Unix revision marker moves to 4 so installed wrappers are refreshed. loadServiceTokenFromFile reads the inherited OCX_API_TOKEN_FILE through a no-follow, nonblocking descriptor, accepts only a regular file of at most 4 KiB and returns only a single-line printable token. --- scripts/test-layout/layout.json | 1 + src/codex/shim-templates.ts | 12 ++- src/lib/service-secrets.ts | 32 +++++++- .../codex-shim-bypass-token.test.ts | 76 +++++++++++++++++++ tests/fixtures/test-layout-expected.json | 1 + tests/service/service-secrets.test.ts | 37 +++++++++ 6 files changed, 153 insertions(+), 6 deletions(-) create mode 100644 tests/codex-integration/codex-shim-bypass-token.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 3d3a039ff9..2c9ace566e 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -711,6 +711,7 @@ "codex-systemd-env-probe.test.ts": "codex-integration", "codex-shim-autorestore.test.ts": "codex-integration", "codex-shim-destroyed-probe.test.ts": "codex-integration", + "codex-shim-bypass-token.test.ts": "codex-integration", "codex-shim-ensure-failure.test.ts": "codex-integration", "codex-shim-readiness.test.ts": "codex-integration", "codex-shim-standalone.test.ts": "codex-integration", diff --git a/src/codex/shim-templates.ts b/src/codex/shim-templates.ts index 82d67869e2..f17e45ad2e 100644 --- a/src/codex/shim-templates.ts +++ b/src/codex/shim-templates.ts @@ -5,7 +5,9 @@ import { selfLaunchArgv } from "../lib/self-launch-argv"; import { windowsEnvIndirectBatchValue } from "../lib/win-paths"; const SHIM_MARKER = "opencodex codex autostart shim"; -const UNIX_SHIM_REVISION_MARKER = "opencodex unix codex shim revision 3"; +// Revision 4: OCX_SHIM_BYPASS skips loading the service API token. Bumping the marker +// makes installed revision-3 wrappers obsolete so the existing refresh path rewrites them. +const UNIX_SHIM_REVISION_MARKER = "opencodex unix codex shim revision 4"; const CODEX_SHIM_REENTRY_EXIT_CODE = 126; const CODEX_SHIM_REENTRY_DIAGNOSTIC = "opencodex: saved Codex launcher resolved back to the autostart shim; run ocx codex-shim uninstall and reinstall Codex before enabling codexAutoStart."; @@ -122,7 +124,9 @@ esac # redispatch reaches depth 2 and is rejected before it can form an infinite chain. OCX_SHIM_ACTIVE_PID=$$ export OCX_SHIM_ACTIVE_PID OCX_SHIM_ACTIVE_DEPTH -if [ -z "$OPENCODEX_API_AUTH_TOKEN" ] && [ -f ${shQuote(tokenFile)} ]; then +# A bypassed launch is native passthrough: it never talks to this proxy, so it must not +# hand the proxy's data-plane token to the real Codex and everything Codex spawns. +if [ -z "$OCX_SHIM_BYPASS" ] && [ -z "$OPENCODEX_API_AUTH_TOKEN" ] && [ -f ${shQuote(tokenFile)} ]; then OPENCODEX_API_AUTH_TOKEN="$(cat ${shQuote(tokenFile)})" export OPENCODEX_API_AUTH_TOKEN fi @@ -195,8 +199,8 @@ ${windowsBatchSet("OCX_REAL_CODEX", realCodexPath)}\r ${windowsBatchSet("OCX_BUN", bunPath)}\r ${cliArgs.length ? windowsBatchSet("OCX_CLI", cliArgs[0]!) : ""}\r ${windowsBatchSet("OCX_API_TOKEN_FILE", serviceApiTokenFilePath())}\r -if "%OPENCODEX_API_AUTH_TOKEN%"=="" if exist "%OCX_API_TOKEN_FILE%" set /p OPENCODEX_API_AUTH_TOKEN=<"%OCX_API_TOKEN_FILE%"\r if not "%OCX_SHIM_BYPASS%"=="" goto run_codex\r +if "%OPENCODEX_API_AUTH_TOKEN%"=="" if exist "%OCX_API_TOKEN_FILE%" set /p OPENCODEX_API_AUTH_TOKEN=<"%OCX_API_TOKEN_FILE%"\r goto scan_codex_args\r :scan_codex_args\r if "%~1"=="" goto ensure_ocx\r @@ -243,7 +247,7 @@ export function buildWindowsPowerShellCodexShim(realCodexPath: string, bunPath: $hadApiAuthToken = Test-Path Env:\\OPENCODEX_API_AUTH_TOKEN $priorApiAuthToken = $env:OPENCODEX_API_AUTH_TOKEN try { -if (-not $env:OPENCODEX_API_AUTH_TOKEN -and (Test-Path -LiteralPath ${psString(tokenFile)})) { +if (-not $env:OCX_SHIM_BYPASS -and -not $env:OPENCODEX_API_AUTH_TOKEN -and (Test-Path -LiteralPath ${psString(tokenFile)})) { $env:OPENCODEX_API_AUTH_TOKEN = (Get-Content -Raw -LiteralPath ${psString(tokenFile)}).Trim() } $internalCommands = @(${internalCommands}) diff --git a/src/lib/service-secrets.ts b/src/lib/service-secrets.ts index 97ffe111dc..65a414a6d6 100644 --- a/src/lib/service-secrets.ts +++ b/src/lib/service-secrets.ts @@ -268,19 +268,47 @@ export function removeServiceApiTokenFileIfOwned( * token file into the environment). Pure: returns the token or null — the CALLER * assigns it to process.env.OPENCODEX_API_AUTH_TOKEN. Loads only when the env token * is empty and OCX_API_TOKEN_FILE names a readable file. + * + * OCX_API_TOKEN_FILE is inherited from the environment, so the path is untrusted: the read + * goes through one no-follow, nonblocking descriptor, accepts only a regular file of at most + * MAX_SERVICE_API_TOKEN_BYTES, and returns only a single-line printable token. Anything else + * (a FIFO, device, symlink, oversized or multi-line file) loads nothing. */ export function loadServiceTokenFromFile(env: Record): string | null { if (env.OPENCODEX_API_AUTH_TOKEN?.trim()) return null; const file = env.OCX_API_TOKEN_FILE?.trim(); if (!file) return null; + const flags = process.platform === "win32" + ? constants.O_RDONLY + : constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK; + let fd: number | undefined; try { - const token = readFileSync(file, "utf8").trim(); - return token || null; + if (process.platform === "win32" && lstatSync(file).isSymbolicLink()) return null; + fd = openSync(file, flags); + const stat = fstatSync(fd); + if (!stat.isFile() || stat.size > MAX_SERVICE_API_TOKEN_BYTES) return null; + const buffer = Buffer.alloc(MAX_SERVICE_API_TOKEN_BYTES + 1); + let length = 0; + while (length < buffer.length) { + const count = readSync(fd, buffer, length, buffer.length - length, null); + if (count === 0) break; + length += count; + } + if (length > MAX_SERVICE_API_TOKEN_BYTES) return null; + const token = buffer.subarray(0, length).toString("utf8").trim(); + return SERVICE_TOKEN_FILE_SHAPE.test(token) ? token : null; } catch { return null; + } finally { + if (fd !== undefined) { + try { closeSync(fd); } catch { /* already closed */ } + } } } +/** One line of printable, non-space ASCII: never JSON with newlines, binary, or a second line. */ +const SERVICE_TOKEN_FILE_SHAPE = /^[\x21-\x7e]{1,4096}$/; + /** * The data-plane token a boot should export, or null when the environment already has one * (or there is nothing to export). diff --git a/tests/codex-integration/codex-shim-bypass-token.test.ts b/tests/codex-integration/codex-shim-bypass-token.test.ts new file mode 100644 index 0000000000..419b0b1d21 --- /dev/null +++ b/tests/codex-integration/codex-shim-bypass-token.test.ts @@ -0,0 +1,76 @@ +/** + * A bypassed shim launch (`OCX_SHIM_BYPASS`, native passthrough and the install probe) must not + * load the service API token into `OPENCODEX_API_AUTH_TOKEN`: the real Codex and every process it + * spawns would inherit the proxy's data-plane credential for a session that never talks to it. + * + * The POSIX case runs the real generated script; CMD and PowerShell cannot execute on every CI + * host, so their cases check that the bypass gate precedes or guards the token load in the text. + */ +import { describe, expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { chmodSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { buildUnixCodexShim, buildWindowsCodexShim, buildWindowsPowerShellCodexShim } from "../../src/codex/shim-templates"; +import { isolateCodexShimEnvironment } from "../helpers/codex-shim-install-fixture"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { INTERNAL_DEADLINE_MS } from "../helpers/test-budget"; + +isolateCodexShimEnvironment(); + +const FIXTURE_TOKEN = "ocx_fixture_token_not_a_secret"; + +function runUnixShim(bypass: string): string { + const dir = mkdtempSync(join(tmpdir(), "ocx-shim-bypass-token-")); + try { + const realCodex = join(dir, "codex-real"); + writeFileSync(realCodex, '#!/bin/sh\nprintf "token=%s\\n" "${OPENCODEX_API_AUTH_TOKEN:-unset}"\n'); + chmodSync(realCodex, 0o755); + const fakeBun = join(dir, "fake-bun"); + writeFileSync(fakeBun, "#!/bin/sh\nexit 0\n"); + chmodSync(fakeBun, 0o755); + const tokenFile = join(dir, "service-api-token"); + writeFileSync(tokenFile, FIXTURE_TOKEN); + const wrapper = join(dir, "codex"); + writeFileSync(wrapper, buildUnixCodexShim(realCodex, fakeBun, join(dir, "cli.ts"), "process", tokenFile)); + chmodSync(wrapper, 0o755); + const env: NodeJS.ProcessEnv = { ...process.env, OCX_SHIM_BYPASS: bypass }; + delete env.OPENCODEX_API_AUTH_TOKEN; + delete env.OCX_SHIM_ACTIVE_PID; + delete env.OCX_SHIM_ACTIVE_DEPTH; + delete env.OCX_SHIM_PROBE_ACTIVE; + delete env.OCX_SHIM_PROBE; + const result = spawnSync("/bin/sh", [wrapper, "exec", "hello"], { encoding: "utf8", env, timeout: INTERNAL_DEADLINE_MS }); + expect(result.status).toBe(0); + return result.stdout.trim(); + } finally { + removeTreeWithRetry(dir); + } +} + +describe("Codex shim bypass never injects the service token", () => { + test.skipIf(process.platform === "win32")("POSIX shim: bypass launches Codex without the token", () => { + expect(runUnixShim("1")).toBe("token=unset"); + }); + + test.skipIf(process.platform === "win32")("POSIX shim: a routed launch still loads the token (control)", () => { + expect(runUnixShim("")).toBe(`token=${FIXTURE_TOKEN}`); + }); + + test("CMD shim: the bypass jump precedes the token read", () => { + const cmd = buildWindowsCodexShim("C:\\codex-real.exe", "C:\\bun.exe", "C:\\cli.ts", "process"); + const bypass = cmd.indexOf('if not "%OCX_SHIM_BYPASS%"=="" goto run_codex'); + const tokenRead = cmd.indexOf("set /p OPENCODEX_API_AUTH_TOKEN="); + expect(bypass).toBeGreaterThan(-1); + expect(tokenRead).toBeGreaterThan(bypass); + expect(cmd.match(/set \/p OPENCODEX_API_AUTH_TOKEN=/g)).toHaveLength(1); + }); + + test("PowerShell shim: the token load is gated on bypass being unset", () => { + const ps = buildWindowsPowerShellCodexShim("C:\\codex-real.ps1", "C:\\bun.exe", "C:\\cli.ts", "process"); + const assignments = ps.split("\n").filter(line => line.includes("$env:OPENCODEX_API_AUTH_TOKEN = (Get-Content")); + expect(assignments).toHaveLength(1); + const guard = ps.slice(0, ps.indexOf(assignments[0]!)).split("\n").reverse().find(line => line.startsWith("if (")); + expect(guard).toContain("-not $env:OCX_SHIM_BYPASS"); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 05a51e8fd4..039bef12b6 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -566,6 +566,7 @@ "codex-service-manager-probe-hardening.test.ts": "codex-integration", "codex-service-manager-probe.test.ts": "codex-integration", "codex-systemd-env-probe.test.ts": "codex-integration", "codex-shim-autorestore.test.ts": "codex-integration", "codex-shim-destroyed-probe.test.ts": "codex-integration", + "codex-shim-bypass-token.test.ts": "codex-integration", "codex-shim-ensure-failure.test.ts": "codex-integration", "codex-shim-readiness.test.ts": "codex-integration", "codex-shim-standalone.test.ts": "codex-integration", "codex-shim.test.ts": "codex-integration", "codex-signin-lockout.test.ts": "codex-integration", "codex-spark-visibility.test.ts": "codex-integration", diff --git a/tests/service/service-secrets.test.ts b/tests/service/service-secrets.test.ts index 7196649016..f53e2cf2b1 100644 --- a/tests/service/service-secrets.test.ts +++ b/tests/service/service-secrets.test.ts @@ -19,6 +19,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { hardenReusedServiceApiToken, + loadServiceTokenFromFile, readServiceApiTokenState, readTokenBackupState, removeOrphanTokenBackup, @@ -349,3 +350,39 @@ describe("service API token ownership", () => { expect(readTokenBackupState()).toMatchObject({ kind: "present", token: "ocx_data_original" }); }); }); + +describe("inherited OCX_API_TOKEN_FILE reads are bounded", () => { + const load = (file: string) => loadServiceTokenFromFile({ OCX_API_TOKEN_FILE: file }); + + test("loads one trimmed single-line token and nothing else", () => { + const file = join(home, "token"); + writeFileSync(file, " ocx_data_token \n"); + expect(load(file)).toBe("ocx_data_token"); + writeFileSync(file, "x".repeat(4096)); + expect(load(file)).toBe("x".repeat(4096)); + for (const content of ["", " \n", "first\nsecond\n", "has space", "{\n \"key\": 1\n}", "tok\u0000en", "t\u00f6ken"]) { + writeFileSync(file, content); + expect(load(file)).toBeNull(); + } + }); + + test("refuses an oversized file without returning a prefix", () => { + const file = join(home, "big"); + writeFileSync(file, "x".repeat(4097)); + expect(load(file)).toBeNull(); + }); + + test("refuses directories, symlinks and FIFOs", () => { + expect(load(home)).toBeNull(); + if (process.platform === "win32") return; + const target = join(home, "real"); + writeFileSync(target, "ocx_data_token"); + const link = join(home, "link"); + symlinkSync(target, link); + expect(load(link)).toBeNull(); + const fifo = join(home, "fifo"); + execFileSync("mkfifo", [fifo]); + // A FIFO with no writer would block a plain read forever; the nonblocking open must refuse it. + expect(load(fifo)).toBeNull(); + }); +}); From fdf2caf331ae20ec8da4da570d9933b5ed301829 Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 20 Oct 2026 05:57:33 +0900 Subject: [PATCH 11/22] test(claude): wait for a real ctime transition before requiring rejection Re-applying the same utimes moves ctime only after the kernel clock ticks, so the real ctime case from #7301 often observed no change and the reader correctly returned {}. Repeat the call until ctime has advanced (bounded), assert that precondition, then require desktop_config_not_regular. Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com> --- tests/clients/desktop-3p-removal.test.ts | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/tests/clients/desktop-3p-removal.test.ts b/tests/clients/desktop-3p-removal.test.ts index a846e65a03..2b008dfe3b 100644 --- a/tests/clients/desktop-3p-removal.test.ts +++ b/tests/clients/desktop-3p-removal.test.ts @@ -478,6 +478,10 @@ for (const timing of ["descriptor", "path recheck"] as const) { // Windows timestamp update/share semantics are not a POSIX ctime receipt. Keep the // portable injected comparisons above; execute this real timestamp case on POSIX. +// Re-applying the same atime/mtime moves ctime only when the kernel clock has ticked since the +// last metadata change; on coarse-tick filesystems one immediate call often leaves it unchanged. +// Repeat the identical utimes until ctime has really advanced, so the reader always faces a real +// ctime-only transition and never a no-op that would rightly read back "{}". test.skipIf(process.platform === "win32")("bounded reader refuses a real ctime-only change after read on POSIX", () => { const path = join(fixtureHome, "real-ctime.json"); writeFileSync(path, "{}"); filesystem.utimesSync(path, 1, 1); @@ -485,13 +489,23 @@ test.skipIf(process.platform === "win32")("bounded reader refuses a real ctime-o let changed = false; const reading = spyOn(filesystem, "readSync").mockImplementation((fd, buffer, offset, length, position) => { const count = read(fd, buffer, offset, length, position); - if (!changed) { filesystem.utimesSync(path, 1, 1); changed = true; } + if (!changed) { + const deadline = Date.now() + 5_000; + do filesystem.utimesSync(path, 1, 1); + while (filesystem.statSync(path, { bigint: true }).ctimeNs === before.ctimeNs && Date.now() < deadline); + changed = filesystem.statSync(path, { bigint: true }).ctimeNs !== before.ctimeNs; + } return count; }); - try { expect(() => readDesktopLibraryBytes(path)).toThrow("desktop_config_not_regular"); } + let outcome: unknown; + try { outcome = readDesktopLibraryBytes(path); } catch (error) { outcome = error; } finally { reading.mockRestore(); } + // Assert the precondition first: a rejection is only owed once ctime really moved. + expect(changed).toBe(true); + expect(outcome).toBeInstanceOf(Error); + expect((outcome as Error).message).toContain("desktop_config_not_regular"); const after = filesystem.statSync(path, { bigint: true }); - expect(changed).toBe(true); expect(after.ctimeNs).not.toBe(before.ctimeNs); + expect(after.ctimeNs).not.toBe(before.ctimeNs); for (const key of ["dev", "ino", "size", "mode", "mtimeNs"] as const) expect(after[key]).toBe(before[key]); expect(readFileSync(path, "utf8")).toBe("{}"); }); From bb0477671054be66d0a9522614b26659efdc38eb Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 20 Oct 2026 05:59:36 +0900 Subject: [PATCH 12/22] fix(google-antigravity): admit a probed project_id fallback in account import (carry #7385) An Antigravity account whose Cloud Code Assist project is not auto-discovered was rejected with missing_project even when the operator knows its project. A Cockpit record may now carry a bounded project_id (Google Cloud project grammar, 6-30 chars, exact match). Discovery keeps precedence; the fallback is admitted only after the account quota probe succeeds with that account own access token against that project. Probe refusal, probe errors and cancellation write nothing. No shared default project. Co-authored-by: yobushka <51312852+yobushka@users.noreply.github.com> --- .../src/content/docs/guides/providers.md | 2 + scripts/test-layout/layout.json | 1 + .../google-antigravity-adapter.ts | 40 ++++++- src/oauth/account-import/parser.ts | 17 ++- src/oauth/account-import/types.ts | 7 ++ structure/INDEX.md | 3 +- structure/manifest.json | 7 ++ .../providers/antigravity-account-import.md | 36 ++++++ tests/fixtures/test-layout-expected.json | 1 + .../account-import-project-fallback.test.ts | 111 ++++++++++++++++++ 10 files changed, 220 insertions(+), 5 deletions(-) create mode 100644 structure/providers/antigravity-account-import.md create mode 100644 tests/server/account-import-project-fallback.test.ts diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index fb3a73ed01..1be5ba6b7e 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -478,6 +478,8 @@ cat accounts.json | ocx account import google-antigravity --format cockpit-tools Inline JSON and extra positional arguments are rejected. Keep exported files private and delete or store them securely after import. +Each record may carry an optional `project_id` for an account whose Cloud Code Assist project is not discovered automatically. It must be a Google Cloud project id (6–30 lowercase letters, digits or hyphens). OpenCodex uses it only when discovery finds no project, and only after a quota check with that account's own token succeeds for it; otherwise the record fails with `missing_project` and nothing is saved. A discovered project always takes precedence. + ### OAuth reliability opencodex coordinates token refresh and Codex pool routing so concurrent requests do not race the diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 2c9ace566e..33401e365a 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -142,6 +142,7 @@ "tool-envelope-echo-whole-line.test.ts": "adapters", "abort-race.test.ts": "adapters", "account-import.test.ts": "server", + "account-import-project-fallback.test.ts": "server", "account-pool-management-api.test.ts": "server", "link-listener-admission.test.ts": "server", "link-management-relay.test.ts": "server", "link-listener-lifecycle.test.ts": "server", "link-listener-mutation.test.ts": "server", diff --git a/src/oauth/account-import/google-antigravity-adapter.ts b/src/oauth/account-import/google-antigravity-adapter.ts index 4c62e4c022..14127833ca 100644 --- a/src/oauth/account-import/google-antigravity-adapter.ts +++ b/src/oauth/account-import/google-antigravity-adapter.ts @@ -1,6 +1,7 @@ import { validateAntigravityImportCredential } from "../google-antigravity"; import { upsertCredentialByIdentity } from "../store"; import type { OAuthCredentials } from "../types"; +import { probeAntigravityUsageQuota } from "../../providers/quota/antigravity"; import { ACCOUNT_IMPORT_FORMAT, ACCOUNT_IMPORT_MAX_EMAIL_LENGTH, @@ -14,6 +15,26 @@ import { export interface AntigravityImportAdapterDeps { validate(refreshToken: string, signal?: AbortSignal): Promise; upsert(credential: OAuthCredentials): Promise<"inserted" | "updated">; + /** + * Prove that the freshly validated account token can use an operator-supplied project. + * Without this owner a fallback project is never admitted. + */ + verifyProject?(accessToken: string, projectId: string, signal?: AbortSignal): Promise; +} + +/** + * The account-bound admission probe for a fallback project: the same Cloud Code Assist quota + * probe the account pool runs, authenticated with this account's own access token against the + * named project. Only a parsed quota answer admits it; denial, rate limiting, transport failure + * or an unusable body all keep the record out. + */ +export async function verifyAntigravityImportProject(accessToken: string, projectId: string, signal?: AbortSignal): Promise { + if (signal?.aborted) return false; + try { + return (await probeAntigravityUsageQuota(accessToken, projectId)).kind === "available"; + } catch { + return false; + } } function safeProviderEmail(value: unknown): string | null { @@ -32,6 +53,7 @@ export function createAntigravityAccountImportAdapter( deps: AntigravityImportAdapterDeps = { validate: validateAntigravityImportCredential, upsert: credential => upsertCredentialByIdentity(ACCOUNT_IMPORT_PROVIDER, credential), + verifyProject: verifyAntigravityImportProject, }, ): AccountImportAdapter { return { @@ -52,15 +74,29 @@ export function createAntigravityAccountImportAdapter( const providerEmail = safeProviderEmail(credential.email); if (!providerEmail) return { status: "failed", code: "credential_rejected" }; if (providerEmail !== record.email) return { status: "failed", code: "identity_mismatch" }; - if (typeof credential.projectId !== "string" || credential.projectId.length === 0) { - return { status: "failed", code: "missing_project" }; + // Automatic discovery wins: a discovered project is used as-is and the record's project_id + // is ignored. The record's project is a fallback only, admitted after an account-bound probe. + let projectId = typeof credential.projectId === "string" && credential.projectId.length > 0 + ? credential.projectId + : undefined; + if (!projectId && record.projectId && deps.verifyProject) { + let admitted = false; + try { + admitted = await deps.verifyProject(credential.access, record.projectId, signal); + } catch { + admitted = false; + } + throwIfAccountImportAborted(signal); + if (admitted) projectId = record.projectId; } + if (!projectId) return { status: "failed", code: "missing_project" }; throwIfAccountImportAborted(signal); try { const disposition = await deps.upsert({ ...credential, email: providerEmail, + projectId, source: "credential-file", }); return disposition === "updated" diff --git a/src/oauth/account-import/parser.ts b/src/oauth/account-import/parser.ts index 7023ab79f4..6ec9e67cfb 100644 --- a/src/oauth/account-import/parser.ts +++ b/src/oauth/account-import/parser.ts @@ -1,13 +1,17 @@ import { ACCOUNT_IMPORT_MAX_BYTES, ACCOUNT_IMPORT_MAX_EMAIL_LENGTH, + ACCOUNT_IMPORT_MAX_PROJECT_ID_LENGTH, ACCOUNT_IMPORT_MAX_RECORDS, ACCOUNT_IMPORT_MAX_REFRESH_TOKEN_BYTES, type ParsedCockpitRecord, } from "./types"; const encoder = new TextEncoder(); -const ALLOWED_RECORD_KEYS = new Set(["email", "refresh_token", "tags", "notes"]); +const ALLOWED_RECORD_KEYS = new Set(["email", "refresh_token", "project_id", "tags", "notes"]); +// Google Cloud project id grammar: a lowercase letter, then lowercase letters, digits or hyphens, +// not ending in a hyphen. Exact match only — no trimming, so whitespace or controls are rejected. +const PROJECT_ID_SHAPE = /^[a-z][a-z0-9-]*[a-z0-9]$/; export type CockpitDocumentParseResult = | { ok: true; records: ParsedCockpitRecord[] } @@ -50,10 +54,18 @@ function validMetadata(record: Record): boolean { return record.notes === undefined || typeof record.notes === "string"; } +function validProjectId(value: unknown): value is string | undefined { + if (value === undefined) return true; + return typeof value === "string" + && value.length >= 6 + && value.length <= ACCOUNT_IMPORT_MAX_PROJECT_ID_LENGTH + && PROJECT_ID_SHAPE.test(value); +} + function parseRecord(value: unknown, index: number): ParsedCockpitRecord { if (!isPlainRecord(value)) return { index, code: "invalid_record" }; if (Object.keys(value).some(key => !ALLOWED_RECORD_KEYS.has(key))) return { index, code: "invalid_record" }; - if (!validEmail(value.email) || !validRefreshToken(value.refresh_token) || !validMetadata(value)) { + if (!validEmail(value.email) || !validRefreshToken(value.refresh_token) || !validProjectId(value.project_id) || !validMetadata(value)) { return { index, code: "invalid_record" }; } return { @@ -61,6 +73,7 @@ function parseRecord(value: unknown, index: number): ParsedCockpitRecord { record: { email: value.email.toLowerCase(), refreshToken: value.refresh_token, + ...(typeof value.project_id === "string" ? { projectId: value.project_id } : {}), }, }; } diff --git a/src/oauth/account-import/types.ts b/src/oauth/account-import/types.ts index 5330bd5bb6..4f367f2942 100644 --- a/src/oauth/account-import/types.ts +++ b/src/oauth/account-import/types.ts @@ -11,6 +11,8 @@ export const ACCOUNT_IMPORT_MAX_REQUEST_BYTES = ACCOUNT_IMPORT_MAX_BYTES + 1024; export const ACCOUNT_IMPORT_DEADLINE_MS = 600_000; export const ACCOUNT_IMPORT_MAX_RECORDS = 25; export const ACCOUNT_IMPORT_MAX_EMAIL_LENGTH = 254; +/** Google Cloud project ids are 6–30 characters; anything longer cannot name a project. */ +export const ACCOUNT_IMPORT_MAX_PROJECT_ID_LENGTH = 30; export const ACCOUNT_IMPORT_MAX_REFRESH_TOKEN_BYTES = 16 * 1024; export type AccountImportStatus = "imported" | "updated" | "failed" | "unsupported"; @@ -65,6 +67,11 @@ export function throwIfAccountImportAborted(signal?: AbortSignal): void { export interface CockpitAccountRecord { email: string; refreshToken: string; + /** + * Operator-supplied Cloud Code Assist project, used only when automatic discovery finds none + * and only after the account's own token proves it can use that project. + */ + projectId?: string; } export type ParsedCockpitRecord = diff --git a/structure/INDEX.md b/structure/INDEX.md index 2757c8495e..8304164d31 100644 --- a/structure/INDEX.md +++ b/structure/INDEX.md @@ -76,6 +76,7 @@ Per-vendor contracts and the adapter authority that constructs them. | [`providers/anthropic-account-thresholds.md`](providers/anthropic-account-thresholds.md) | Account-owned usage thresholds, inheritance, routing boundaries and durable policy changes. | | [`providers/meta-muse.md`](providers/meta-muse.md) | CLI credential import, approval lifecycle, explicit recovery and account-free login diagnostics. | | [`providers/oauth-reconciliation.md`](providers/oauth-reconciliation.md) | Operator limits and per-model declarations across startup reconciliation and OAuth upsert. | +| [`providers/antigravity-account-import.md`](providers/antigravity-account-import.md) | Cockpit Tools import schema, identity check, probe-gated project_id fallback and no-write failure paths. | | [`providers/claude-cli-replay.md`](providers/claude-cli-replay.md) | Claude CLI stable replay, canonical tool schemas, capture-only client tool ownership and subscription usage admission. | | [`providers-and-adapters.md`](providers-and-adapters.md) | Provider and adapter selection, the adapter inventory, live model discovery, and the hosted-search continuation bridge. | | [`providers/jev-decision.md`](providers/jev-decision.md) | JEV Combo decision methods (TypeSafe, self-hosted System One rows, opencodex models), the decision request path, dashboard surfaces, and the content-free statistics projection. | @@ -225,7 +226,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/lib/windows-owner-acl.ts` | [`remote-link.md`](remote-link.md) | | `src/link/` | [`remote-messaging.md`](remote-messaging.md)
[`remote-link.md`](remote-link.md) | | `src/messaging/` | [`remote-messaging.md`](remote-messaging.md)
[`local-messaging.md`](local-messaging.md) | -| `src/oauth/` | [`providers/generic-account-pool.md`](providers/generic-account-pool.md)
[`runtime.md`](runtime.md)
[`transports/inventory.md`](transports/inventory.md)
[`providers/anthropic-account-thresholds.md`](providers/anthropic-account-thresholds.md)
[`providers/oauth-reconciliation.md`](providers/oauth-reconciliation.md)
[`providers-and-adapters.md`](providers-and-adapters.md)
[`providers/anthropic-account-pool.md`](providers/anthropic-account-pool.md)
[`providers/oauth-code-display-login.md`](providers/oauth-code-display-login.md)
[`providers/xai-grok.md`](providers/xai-grok.md) | +| `src/oauth/` | [`providers/generic-account-pool.md`](providers/generic-account-pool.md)
[`runtime.md`](runtime.md)
[`transports/inventory.md`](transports/inventory.md)
[`providers/anthropic-account-thresholds.md`](providers/anthropic-account-thresholds.md)
[`providers/oauth-reconciliation.md`](providers/oauth-reconciliation.md)
[`providers/antigravity-account-import.md`](providers/antigravity-account-import.md)
[`providers-and-adapters.md`](providers-and-adapters.md)
[`providers/anthropic-account-pool.md`](providers/anthropic-account-pool.md)
[`providers/oauth-code-display-login.md`](providers/oauth-code-display-login.md)
[`providers/xai-grok.md`](providers/xai-grok.md) | | `src/oauth/account-auto-pause-policy.ts` | [`account-auto-pause.md`](account-auto-pause.md) | | `src/oauth/account-auto-pause.ts` | [`account-auto-pause.md`](account-auto-pause.md) | | `src/oauth/api-key-pool-preserve.ts` | [`account-auto-pause.md`](account-auto-pause.md) | diff --git a/structure/manifest.json b/structure/manifest.json index 3941a82bf2..5c142eb155 100644 --- a/structure/manifest.json +++ b/structure/manifest.json @@ -443,6 +443,13 @@ "scope": "Operator limits and per-model declarations across startup reconciliation and OAuth upsert.", "documents": ["src/oauth/"] }, + { + "path": "providers/antigravity-account-import.md", + "tier": 4, + "title": "Antigravity Account Import", + "scope": "Cockpit Tools import schema, identity check, probe-gated project_id fallback and no-write failure paths.", + "documents": ["src/oauth/"] + }, { "path": "providers/claude-cli-replay.md", "tier": 4, diff --git a/structure/providers/antigravity-account-import.md b/structure/providers/antigravity-account-import.md new file mode 100644 index 0000000000..b7419eef67 --- /dev/null +++ b/structure/providers/antigravity-account-import.md @@ -0,0 +1,36 @@ +# Antigravity account import + +Owner of the Cockpit Tools import for `google-antigravity` (`src/oauth/account-import/`): the +document schema, per-record admission and what is persisted. The CLI and dashboard entry points +call `importAccounts` in `src/oauth/account-import/service.ts`; the public workflow lives in the +providers guide. + +- **Document.** `parseCockpitAccountDocument` (`src/oauth/account-import/parser.ts`) accepts a + JSON array of 1–25 records, at most 256 KiB serialized. A record allows only `email`, + `refresh_token`, `project_id`, `tags` and `notes`; any other key, a duplicate email, or a + field outside its bound marks that record `invalid_record` without validating it. +- **`project_id` schema.** Optional. When present it must be a string of 6–30 characters matching + the Google Cloud project grammar (a lowercase letter, then lowercase letters, digits or hyphens, + not ending in a hyphen), compared exactly with no trimming. Wrong types, blanks, padding, + controls, domain-scoped ids and oversize values are `invalid_record`. +- **Identity.** `validateAntigravityImportCredential` refreshes the token and reads the email + from Google's userinfo endpoint; a mismatch with the record's email is `identity_mismatch`, so + one token can never land in another account's slot. +- **Project precedence.** The project discovered for the refreshed token (`loadCodeAssist`, then + `onboardUser`) always wins, and a record's `project_id` is then ignored. Only when discovery finds + none does `project_id` become a candidate, and it is admitted only after + `verifyAntigravityImportProject` (`src/oauth/account-import/google-antigravity-adapter.ts`) runs + the account quota probe (`probeAntigravityUsageQuota`) with that account's own access token + against that project and gets a parsed quota answer. Denial, rate limiting, transport failure, an + unusable body or a thrown probe all end as `missing_project`. There is no shared default project. +- **No write on failure.** Every failed record and a cancelled request persist nothing. Cancellation + is checked after the probe as well, so a probe that admits after the request was cancelled still + returns `import_cancelled` without a write. +- **Persistence.** An admitted record is upserted by identity with the resolved `projectId` and + `source: "credential-file"`. A later token refresh that discovers no project keeps the stored + one (`merged` in `src/oauth/index.ts`). +- **Output.** Results carry only fixed codes and counts; no token, email or upstream text. + +Coverage: `tests/server/account-import.test.ts` and +`tests/server/account-import-project-fallback.test.ts` (fakes for validation, probe and upsert; +no live calls). diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 039bef12b6..5bb89ff08e 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -199,6 +199,7 @@ "responses-devin-401-replay.test.ts": "responses", "responses-oauth-refresh-capacity.test.ts": "responses", "responses-grok-devin-preflight.test.ts": "responses", "abort-idle-deadline.test.ts": "lib", "tool-envelope-echo-whole-line.test.ts": "adapters", "abort-race.test.ts": "adapters", "account-import.test.ts": "server", + "account-import-project-fallback.test.ts": "server", "account-pool-management-api.test.ts": "server", "link-listener-admission.test.ts": "server", "link-management-relay.test.ts": "server", "link-listener-lifecycle.test.ts": "server", diff --git a/tests/server/account-import-project-fallback.test.ts b/tests/server/account-import-project-fallback.test.ts new file mode 100644 index 0000000000..1f0ec1920a --- /dev/null +++ b/tests/server/account-import-project-fallback.test.ts @@ -0,0 +1,111 @@ +/** + * #7382 / #7385: an operator-supplied Cockpit `project_id` is a fallback for an Antigravity account + * whose Cloud Code Assist project is not auto-discovered. It is admitted only through a bounded + * schema and an account-bound probe with the freshly validated token, discovery always wins, and + * every failure or cancellation path persists nothing. All provider calls are fakes. + */ +import { describe, expect, test } from "bun:test"; +import { createAntigravityAccountImportAdapter, type AntigravityImportAdapterDeps } from "../../src/oauth/account-import/google-antigravity-adapter"; +import { parseCockpitAccountDocument } from "../../src/oauth/account-import/parser"; +import { importAccounts } from "../../src/oauth/account-import/service"; +import { ACCOUNT_IMPORT_FORMAT, ACCOUNT_IMPORT_PROVIDER, type ValidatedAntigravityCredential } from "../../src/oauth/account-import/types"; +import type { OAuthCredentials } from "../../src/oauth/types"; + +const CANARY = "cockpit-canary-refresh-token-DO-NOT-LEAK"; + +function credential(overrides: Partial = {}): ValidatedAntigravityCredential { + return { access: "access-for-user", refresh: CANARY, expires: Date.now() + 60_000, email: "user@example.com", ...overrides }; +} + +function harness(deps: Partial & { discovered?: string }) { + const writes: OAuthCredentials[] = []; + const probes: Array<{ accessToken: string; projectId: string }> = []; + const adapter = createAntigravityAccountImportAdapter({ + validate: deps.validate ?? (async () => credential(deps.discovered ? { projectId: deps.discovered } : {})), + upsert: async value => { writes.push(value); return "inserted"; }, + ...("verifyProject" in deps ? { verifyProject: deps.verifyProject } : { + verifyProject: async (accessToken: string, projectId: string) => { probes.push({ accessToken, projectId }); return true; }, + }), + }); + const run = (document: unknown, signal?: AbortSignal) => importAccounts( + { provider: ACCOUNT_IMPORT_PROVIDER, format: ACCOUNT_IMPORT_FORMAT, document, signal }, + { resolveAdapter: () => ({ ok: true, adapter }) }, + ); + return { writes, probes, run }; +} + +const doc = (projectId?: unknown) => [{ email: "user@example.com", refresh_token: "refresh-safe", ...(projectId === undefined ? {} : { project_id: projectId }) }]; + +describe("Cockpit project_id schema", () => { + test("accepts a bounded Google Cloud project id and carries it to the record", () => { + expect(parseCockpitAccountDocument(doc("aicode-consumers"))).toEqual({ + ok: true, + records: [{ index: 0, record: { email: "user@example.com", refreshToken: "refresh-safe", projectId: "aicode-consumers" } }], + }); + expect(parseCockpitAccountDocument(doc("a".repeat(30))).ok).toBe(true); + }); + + test("rejects wrong types, blanks, padding, oversize and malformed ids as invalid records", () => { + for (const bad of [42, null, true, ["aicode-consumers"], { id: "aicode-consumers" }, "", " ", " aicode-consumers", + "aicode-consumers\n", "a".repeat(31), "short", "Aicode-consumers", "1aicode", "aicode-", "aicode_consumers", "org.com:aicode"]) { + const parsed = parseCockpitAccountDocument(doc(bad)); + expect(parsed).toEqual({ ok: true, records: [{ index: 0, code: "invalid_record" }] }); + } + }); +}); + +describe("Antigravity import project fallback", () => { + test("admits a probed fallback project and persists it through the import service", async () => { + const h = harness({}); + const result = await h.run(doc("aicode-consumers")); + expect(result).toMatchObject({ ok: true, result: { importedCount: 1, failedCount: 0 } }); + expect(h.probes).toEqual([{ accessToken: "access-for-user", projectId: "aicode-consumers" }]); + expect(h.writes).toHaveLength(1); + expect(h.writes[0]).toMatchObject({ projectId: "aicode-consumers", email: "user@example.com", source: "credential-file" }); + expect(JSON.stringify(result)).not.toContain(CANARY); + }); + + test("automatic discovery takes precedence and never probes the fallback", async () => { + const h = harness({ discovered: "discovered-project" }); + expect(await h.run(doc("aicode-consumers"))).toMatchObject({ ok: true, result: { importedCount: 1 } }); + expect(h.probes).toEqual([]); + expect(h.writes[0]?.projectId).toBe("discovered-project"); + }); + + test("a refused or throwing probe fails missing_project and writes nothing", async () => { + for (const verifyProject of [async () => false, async () => { throw new Error(`probe echoed ${CANARY}`); }]) { + const h = harness({ verifyProject }); + const result = await h.run(doc("aicode-consumers")); + expect(result).toMatchObject({ ok: true, result: { failedCount: 1, results: [{ index: 0, status: "failed", code: "missing_project" }] } }); + expect(h.writes).toEqual([]); + expect(JSON.stringify(result)).not.toContain(CANARY); + } + }); + + test("without a fallback or without a probe owner, discovery failure stays missing_project", async () => { + const noFallback = harness({}); + expect(await noFallback.run(doc())).toMatchObject({ ok: true, result: { results: [{ code: "missing_project" }] } }); + expect(noFallback.probes).toEqual([]); + expect(noFallback.writes).toEqual([]); + + const noOwner = harness({ verifyProject: undefined }); + expect(await noOwner.run(doc("aicode-consumers"))).toMatchObject({ ok: true, result: { results: [{ code: "missing_project" }] } }); + expect(noOwner.writes).toEqual([]); + }); + + test("an invalid project_id is rejected before validation, probing or writing", async () => { + let validations = 0; + const h = harness({ validate: async () => { validations += 1; return credential(); } }); + expect(await h.run(doc(" aicode-consumers"))).toMatchObject({ ok: true, result: { results: [{ code: "invalid_record" }] } }); + expect(validations).toBe(0); + expect(h.probes).toEqual([]); + expect(h.writes).toEqual([]); + }); + + test("cancellation during the probe returns import_cancelled and writes nothing, even if the probe admits", async () => { + const controller = new AbortController(); + const h = harness({ verifyProject: async () => { controller.abort(); return true; } }); + expect(await h.run(doc("aicode-consumers"), controller.signal)).toEqual({ ok: false, status: 408, code: "import_cancelled" }); + expect(h.writes).toEqual([]); + }); +}); From 03ef0d95d590d392f71adcf33bbb3ed6eb0cd3a5 Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 20 Oct 2026 06:04:34 +0900 Subject: [PATCH 13/22] fix(quota): name a closed reason when an OAuth account quota probe fails Anthropic per-account quota rows reported quotaUnavailable with no reason, and Antigravity reported account_unavailable for any credential-side throw. The Anthropic usage read now reports a closed code for an HTTP status class, an unusable body or a thrown dispatch, and a probe that cannot obtain a credential reports account_unavailable without dispatching. Antigravity distinguishes a credential refresh deadline (timeout). Codes reuse the existing closed QUOTA_FAILURE_CODES set, carry no upstream text and never change account health. --- .../content/docs/reference/management-api.md | 6 ++ scripts/test-layout/layout.json | 1 + src/providers/claude-cli-usage.ts | 25 ++++-- src/providers/quota.ts | 6 +- .../quota/anthropic-account-quota.ts | 24 +++-- src/providers/quota/failure-codes.ts | 18 ++++ src/providers/quota/vendor-probes-oauth.ts | 15 +++- structure/providers/anthropic-account-pool.md | 8 ++ structure/transports/inventory.md | 2 +- tests/fixtures/test-layout-expected.json | 1 + ...ider-account-quota-failure-reasons.test.ts | 89 +++++++++++++++++++ 11 files changed, 179 insertions(+), 16 deletions(-) create mode 100644 src/providers/quota/failure-codes.ts create mode 100644 tests/providers/provider-account-quota-failure-reasons.test.ts diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index 5325a41989..fd71559980 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -682,6 +682,12 @@ When Antigravity's quota-summary endpoint has no usable response, quota reports per-model Cloud Code Assist usage windows when the model-discovery response provides them. These measurements are diagnostic; an absent model window does not establish that the model is unavailable. +With `quota=1`, an Anthropic or Antigravity account row whose probe returned no quota carries +`quotaUnavailable: true` and, when the cause is known, a closed `quotaFailure` code: +`account_unavailable` (sign the account in again), `access_denied`, `rate_limited`, `timeout`, +`transport_error`, `upstream_error` or `response_unusable` (retry later), among others. The code +never includes upstream text, and a quota failure never changes the row's `health`. + The provider context-cap response includes `caps` (active limits) and `values` (last selected values, retained while disabled). Enabling a provider without `value` restores its selection, or uses the global `contextCapValue` on first enable. This also applies to OpenAI: the switch diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 33401e365a..63c273862f 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1412,6 +1412,7 @@ "provider-account-quota-persistence.test.ts": "providers", "provider-account-quota-routes.test.ts": "server", "provider-account-quota.test.ts": "providers", + "provider-account-quota-failure-reasons.test.ts": "providers", "provider-api-keys.test.ts": "providers", "provider-capacity.test.ts": "providers", "provider-config-batch-management.test.ts": "providers", diff --git a/src/providers/claude-cli-usage.ts b/src/providers/claude-cli-usage.ts index c005d31b80..4ea1a6ff74 100644 --- a/src/providers/claude-cli-usage.ts +++ b/src/providers/claude-cli-usage.ts @@ -4,7 +4,8 @@ import { bindRawDecisionWindows } from "./quota-decision-publication"; import { decisionQuotaSinkActive } from "../lib/decision-quota-sink"; import { CLAUDE_CLI_USER_AGENT } from "./claude-cli-identity"; import { asRecord, normalizePercent, normalizeResetAt, readQuotaJson, REQUEST_TIMEOUT_MS } from "./quota-wire"; -import type { ProviderQuota, ProviderQuotaWindow } from "./quota-types"; +import type { ProviderQuota, ProviderQuotaWindow, QuotaFailureCode } from "./quota-types"; +import { httpQuotaFailure } from "./quota/failure-codes"; const hasQuotaRows = (q: ProviderQuota) => q.fiveHourPercent !== undefined || q.weeklyPercent !== undefined || Boolean(q.customWindows?.length); function parseClaudeBucket(value: unknown): { percent?: number; resetAt?: number } | null { @@ -46,6 +47,16 @@ function parseClaudeLimit(value: unknown): ProviderQuotaWindow | null { /** Read the Anthropic OAuth usage endpoint for an access token and return the parsed quota, or null on a non-OK or malformed response. */ export async function readAnthropicUsageQuota(accessToken: string): Promise { + return (await probeAnthropicUsageQuota(accessToken)).quota; +} + +/** A usage read plus, when it yields no quota, the closed reason why. A thrown fetch still throws. */ +export interface AnthropicUsageProbe { + quota: ProviderQuota | null; + failure?: QuotaFailureCode; +} + +export async function probeAnthropicUsageQuota(accessToken: string): Promise { const response = await fetch("https://api.anthropic.com/api/oauth/usage", { headers: { Accept: "application/json, text/plain, */*", @@ -56,9 +67,12 @@ export async function readAnthropicUsageQuota(accessToken: string): Promise { + if (!hasQuotaRows(quota)) return { quota: null, failure: "response_unusable" }; + return { quota: markAnthropicFamilyEnumeration(quota, Array.isArray(body.limits) && body.limits.every(raw => { const kind = asRecord(raw)?.kind; return kind === "session" || kind === "weekly_all" || kind === "weekly_scoped" && parseClaudeLimit(raw) !== null; - })) : null; + })) }; } diff --git a/src/providers/quota.ts b/src/providers/quota.ts index 488d8ce7fa..4aed3b754f 100644 --- a/src/providers/quota.ts +++ b/src/providers/quota.ts @@ -1,6 +1,7 @@ import { listCodexAuthAccountsSnapshot } from "../codex/auth-api"; import { isAnthropicInstanceId, configuredAnthropicInstance } from "./anthropic-instance"; import { fetchAnthropicAccountQuota } from "./quota/anthropic-account-quota"; +import { thrownQuotaFailure } from "./quota/failure-codes"; import { resolveEnvValue } from "../config"; import { getAccountCredential, getAccountSet } from "../oauth/store"; import { apiKeyPoolEntryId } from "./api-keys"; @@ -515,7 +516,10 @@ async function fetchAccountQuota( } return entry; } catch (error) { - if (provider === "google-antigravity") quotaFailure = "account_unavailable"; + // The probe itself reports HTTP and transport failures as results; what throws here is the + // credential side (missing/paused account, failed refresh, no project). A refresh that hit + // its deadline is a timeout to retry, not an account the operator must re-sign. + if (provider === "google-antigravity") quotaFailure = thrownQuotaFailure(error) === "timeout" ? "timeout" : "account_unavailable"; return unavailable(); } })().finally(() => { diff --git a/src/providers/quota/anthropic-account-quota.ts b/src/providers/quota/anthropic-account-quota.ts index befa055c68..718e80e53e 100644 --- a/src/providers/quota/anthropic-account-quota.ts +++ b/src/providers/quota/anthropic-account-quota.ts @@ -4,7 +4,9 @@ import { getAccountSet } from "../../oauth/store"; import { captureConfigGeneration, sweepExpiredOnWrite } from "../../lib/state-store-sweeper"; import { configuredAnthropicInstance, type AnthropicInstanceId } from "../anthropic-instance"; import { ACCOUNT_QUOTA_TTL_MS } from "../quota-wire"; -import { fetchAnthropicUsageQuotaForInstance } from "./vendor-probes-oauth"; +import { fetchAnthropicUsageProbeForInstance } from "./vendor-probes-oauth"; +import { thrownQuotaFailure } from "./failure-codes"; +import type { QuotaFailureCode } from "../quota-types"; import { AnthropicQuotaProbeOwnershipError, anthropicCooldownRecoveryFor, type AnthropicQuotaRecoveryResult } from "./anthropic-cooldown-recovery"; import { accountCacheKey, accountQuotaCache, accountQuotaInflight, captureProviderAccountQuotaEpoch, @@ -49,7 +51,10 @@ export async function fetchAnthropicAccountQuota( } let token: string; try { token = await getTokenForAccountQuotaProbe(instance, accountId); } - catch { return unavailable(); } + catch { + // Missing, signed-out or unrefreshable credential: a reauthentication matter, never a quota fact. + return { ...unavailable(), quotaFailure: "account_unavailable", quotaFailureIsCurrent: ownerCurrent }; + } if (!ownerCurrent()) return unavailable(); const flightKey = recovery.anthropicCredentialQuotaFlightKey(key, accountId); const flightCurrent = () => ownerCurrent() @@ -65,30 +70,37 @@ export async function fetchAnthropicAccountQuota( // in-flight observations, as the pre-instance A path did, so joined callers and later cached // reads agree and windows that expired during the shared probe are normalized away. /** Cache the settled probe result for an account and publish its decision evidence only while the flight is still current for its credential and generation. */ - const publish = (result: AnthropicQuotaRecoveryResult | null): AccountQuotaCacheEntry => { + const publish = (result: AnthropicQuotaRecoveryResult | null, failure?: QuotaFailureCode): AccountQuotaCacheEntry => { const previous = accountQuotaCache.get(key); const retained = previous?.isCurrent?.() === false ? undefined : previous; const isCurrent = result?.isCurrent ?? flightCurrent; const entry: AccountQuotaCacheEntry = { ts: Date.now(), quota: normalizeAnthropicQuota(result?.quota ?? retained?.quota, Date.now()), ...(!result ? { unavailable: true as const } : {}), isCurrent, + ...(!result && failure ? { quotaFailure: failure, quotaFailureIsCurrent: isCurrent } : {}), }; if (isCurrent() && mayCommitAccountQuotaKey(key, writerGeneration)) { accountQuotaCache.set(key, entry); result?.publishDecisionQuota(); sweepExpiredOnWrite(entry.ts); } return entry; }; const probe = (async (): Promise => { + // The closed reason of the last usage read in this flight: an HTTP status class, an unusable + // body, or a thrown dispatch. Ownership and cooldown refusals never set it. + let failure: QuotaFailureCode | undefined; try { const result = await recovery.probeAnthropicQuotaWithRecovery(accountId, token, fresh => { if (!flightCurrent()) throw new AnthropicQuotaProbeOwnershipError("quota flight changed"); recovery.assertAnthropicQuotaSendAllowed(accountId, token); - return fetchAnthropicUsageQuotaForInstance(instance, token, fresh || freshDispatch); + return fetchAnthropicUsageProbeForInstance(instance, token, fresh || freshDispatch).then( + read => { failure = read.failure; return read.quota; }, + error => { failure = thrownQuotaFailure(error); throw error; }, + ); }, () => ownerCurrent() && mayCommitAccountQuotaKey(key, writerGeneration)); if (result && !result.isCurrent()) return unavailable(); if (!result && !flightCurrent()) return unavailable(); - return publish(result); + return publish(result, failure); } catch (error) { if (error instanceof AnthropicQuotaProbeOwnershipError || !flightCurrent()) return unavailable(); - return publish(null); + return publish(null, failure); } })().finally(() => { if (accountQuotaInflight.get(flightKey) === probe) accountQuotaInflight.delete(flightKey); }); accountQuotaInflight.set(flightKey, probe); diff --git a/src/providers/quota/failure-codes.ts b/src/providers/quota/failure-codes.ts new file mode 100644 index 0000000000..b6da380437 --- /dev/null +++ b/src/providers/quota/failure-codes.ts @@ -0,0 +1,18 @@ +import type { QuotaFailureCode } from "../quota-types"; + +/** + * Closed quota-probe diagnoses derived from an HTTP status class or a thrown dispatch. They carry + * no upstream text, URL, header or credential: only one of `QUOTA_FAILURE_CODES`. + */ +export function httpQuotaFailure(status: number): QuotaFailureCode { + if (status >= 300 && status < 400) return "redirect_blocked"; + if (status === 401 || status === 403) return "access_denied"; + if (status === 429) return "rate_limited"; + return "upstream_error"; +} + +/** A thrown fetch: the request deadline is a timeout, anything else a transport failure. */ +export function thrownQuotaFailure(error: unknown): QuotaFailureCode { + const name = error && typeof error === "object" ? (error as { name?: unknown }).name : undefined; + return name === "TimeoutError" ? "timeout" : "transport_error"; +} diff --git a/src/providers/quota/vendor-probes-oauth.ts b/src/providers/quota/vendor-probes-oauth.ts index 994bbf3319..fdfbbffd22 100644 --- a/src/providers/quota/vendor-probes-oauth.ts +++ b/src/providers/quota/vendor-probes-oauth.ts @@ -8,7 +8,7 @@ import { captureOAuthAccountSelection, credentialGeneration, getAccountCredentia import { hydrateKiroAccountState, persistKiroAccountState } from "../kiro-account-state-disk"; import { kiroProbeCurrent, kiroProbeIdentity } from "./kiro-account-probe"; import { fetchMuseKeyQuotaSnapshot } from "../muse-key-quota"; -import { readAnthropicUsageQuota } from "../claude-cli-usage"; +import { probeAnthropicUsageQuota, type AnthropicUsageProbe } from "../claude-cli-usage"; import { XAI_GROK_CLIENT_VERSION, XAI_GROK_COMPATIBILITY } from "../xai-transport"; import { commitKiroAccountUsageState, @@ -227,7 +227,7 @@ export async function fetchXaiQuota(provider: string, context: { accessToken: st } } -const anthropicUsageInflight = new Map>(); +const anthropicUsageInflight = new Map>(); /** * Anthropic per-credential usage. @@ -256,6 +256,15 @@ export async function fetchAnthropicUsageQuotaForInstance( accessToken: string, requireFreshDispatch = false, ): Promise { + return (await fetchAnthropicUsageProbeForInstance(instance, accessToken, requireFreshDispatch)).quota; +} + +/** The same shared flight as `fetchAnthropicUsageQuotaForInstance`, keeping the closed failure reason. */ +export async function fetchAnthropicUsageProbeForInstance( + instance: AnthropicInstanceId, + accessToken: string, + requireFreshDispatch = false, +): Promise { // Recovery evidence must be requested after the claimed cooldown. Joining an older request // can return after the 429 while still describing provider state from before that refusal. const credentialIdentity = getAccountSet(instance)?.accounts.filter(row => row.credential.access === accessToken) @@ -264,7 +273,7 @@ export async function fetchAnthropicUsageQuotaForInstance( const joinable = requireFreshDispatch ? undefined : anthropicUsageInflight.get(key); if (joinable) return joinable; - const probe = readAnthropicUsageQuota(accessToken).finally(() => { + const probe = probeAnthropicUsageQuota(accessToken).finally(() => { if (anthropicUsageInflight.get(key) === probe) anthropicUsageInflight.delete(key); }); anthropicUsageInflight.set(key, probe); diff --git a/structure/providers/anthropic-account-pool.md b/structure/providers/anthropic-account-pool.md index 5f7318fdf4..adc84b66ec 100644 --- a/structure/providers/anthropic-account-pool.md +++ b/structure/providers/anthropic-account-pool.md @@ -173,6 +173,14 @@ quota epoch, configured row, account incarnation and login identity are unchange `src/providers/quota/account-cache.ts` are per instance, so clearing Pool 2 quota never invalidates the primary pool. +A probe that yields no quota names one closed `quotaFailure` code from +`src/providers/quota/failure-codes.ts`: `account_unavailable` when no usable credential could +be obtained (no request is sent), the HTTP status class (`access_denied`, `rate_limited`, +`upstream_error`, `redirect_blocked`), `response_unusable` for an unparsable or empty body, +and `timeout` or `transport_error` for a thrown dispatch. Ownership and cooldown refusals set +no code. The code is published only while the flight is current, never carries upstream text, +and never changes account health or `needsReauth`. + `src/providers/anthropic-reset-grant-ledger.ts` names the journal by instance: `anthropic-reset-grant-ledger.json` keeps the primary pool's existing bytes and records, and Pool 2 uses `anthropic2-reset-grant-ledger.json`. diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index 635b56ea68..b972f5eba1 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -272,7 +272,7 @@ Pool quota producers and account commands follow the [bounded raw-observation co `src/providers/quota/antigravity.ts` retries a quota-summary 403 once with `User-Agent: antigravity/1.0`, releasing the first response body and preserving the bearer, project and pinned accounting endpoint. A 401 is not retried; redirects remain blocked, and a repeated 403 remains unavailable with `access_denied`. Other retry failures retain the models fallback, whose User-Agent remains the IDE fingerprint, as do discovery and inference. -Antigravity account quota probes expose only a closed `quotaFailure` category when the read is unavailable. Typed transport failures, rejected destinations, redirects, denied access, rate limits and unusable bodies are distinguished; successful fallback clears the earlier failure. The last attempted endpoint determines the diagnosis. A 401/403 category does not change account health, entitlement or routing eligibility. +Antigravity account quota probes expose only a closed `quotaFailure` category when the read is unavailable. Typed transport failures, rejected destinations, redirects, denied access, rate limits and unusable bodies are distinguished; successful fallback clears the earlier failure. The last attempted endpoint determines the diagnosis. When the credential side throws before the probe (missing account, failed refresh, no project) the category is `account_unavailable`, or `timeout` when that failure was a request deadline. A 401/403 category does not change account health, entitlement or routing eligibility. `src/providers/quota.ts` binds diagnoses to the probed credential/project and rechecks before cache reads and API projection. Reauthentication invalidates an old diagnosis independently of last-good quota bars. Private digests, callbacks and upstream error values are not serialized. The CLI and current/all-account dashboard views consume the same closed code; unknown codes and local management-read failures retain generic unavailable text. Codes are transient, never persisted quota evidence. Authenticated TUN field acceptance remains separate from deterministic transport coverage. diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 5bb89ff08e..0b581f39eb 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -999,6 +999,7 @@ "process-state.test.ts": "service", "project-config-warnings.test.ts": "codex-integration", "provider-account-quota-persistence.test.ts": "providers", "provider-account-quota-routes.test.ts": "server", "provider-account-quota.test.ts": "providers", "provider-api-keys.test.ts": "providers", + "provider-account-quota-failure-reasons.test.ts": "providers", "provider-capacity.test.ts": "providers", "provider-config-batch-management.test.ts": "providers", "provider-config-validation.test.ts": "providers", "provider-completion-test-authority.test.ts": "providers", "provider-completion-test-ingress.test.ts": "providers", "provider-completion-test.test.ts": "providers", "provider-connection-test.test.ts": "providers", "provider-cost-overlay-config.test.ts": "providers", "provider-discovery-log-suppression.test.ts": "providers", diff --git a/tests/providers/provider-account-quota-failure-reasons.test.ts b/tests/providers/provider-account-quota-failure-reasons.test.ts new file mode 100644 index 0000000000..748eb11e1d --- /dev/null +++ b/tests/providers/provider-account-quota-failure-reasons.test.ts @@ -0,0 +1,89 @@ +/** + * #7380: a per-account quota probe that yields no quota names a closed, redacted reason + * (`quotaFailure`) so an operator can tell re-sign-in from retry. Credential health is never + * touched here. Every upstream answer is a synthetic fetch stub; no live provider call is made. + */ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { getAccountSet, markAccountNeedsReauth, saveCredential } from "../../src/oauth/store"; +import { clearAccountQuotaCache, clearProviderQuotaCache, fetchProviderAccountQuotas, resetProviderQuotaReconcileStateForTests } from "../../src/providers/quota"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const originalFetch = globalThis.fetch; +const previousHome = process.env.OPENCODEX_HOME; +let home = ""; +const ACCESS = "diagnostic-access-token-DO-NOT-LEAK"; +const UPSTREAM_TEXT = "upstream-body-text-DO-NOT-LEAK"; + +beforeEach(async () => { + home = mkdtempSync(join(tmpdir(), "ocx-quota-failure-")); + process.env.OPENCODEX_HOME = home; + clearAccountQuotaCache(); + clearProviderQuotaCache(); + await saveCredential("anthropic", { access: ACCESS, refresh: "diagnostic-refresh", expires: Date.now() + 3_600_000, accountId: "acct-diag", email: "diag@example.com" }); +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + removeTreeWithRetry(home); + clearAccountQuotaCache(); + clearProviderQuotaCache(); + resetProviderQuotaReconcileStateForTests(); +}); + +async function probeWith(reply: () => Promise) { + let calls = 0; + globalThis.fetch = (async () => { calls += 1; return reply(); }) as unknown as typeof fetch; + const rows = await fetchProviderAccountQuotas("anthropic", true); + expect(rows).toHaveLength(1); + const serialized = JSON.stringify(rows); + for (const secret of [ACCESS, "diagnostic-refresh", UPSTREAM_TEXT, "quotaFailureIsCurrent"]) expect(serialized).not.toContain(secret); + return { row: rows[0]!, calls }; +} + +describe("Anthropic per-account quota failure reasons", () => { + for (const [status, failure] of [[401, "access_denied"], [403, "access_denied"], [429, "rate_limited"], [500, "upstream_error"], [503, "upstream_error"]] as const) { + test(`HTTP ${status} reports ${failure} without upstream text`, async () => { + const { row } = await probeWith(async () => new Response(UPSTREAM_TEXT, { status })); + expect(row).toMatchObject({ unavailable: true, quotaFailure: failure }); + }); + } + + test("an unusable or empty usage body reports response_unusable", async () => { + for (const body of ["not json", "{}", JSON.stringify({ five_hour: { note: UPSTREAM_TEXT } })]) { + clearAccountQuotaCache(); + const { row } = await probeWith(async () => new Response(body, { status: 200 })); + expect(row).toMatchObject({ unavailable: true, quotaFailure: "response_unusable" }); + } + }); + + test("a request deadline reports timeout and any other thrown dispatch reports transport_error", async () => { + const timedOut = await probeWith(async () => { throw new DOMException(UPSTREAM_TEXT, "TimeoutError"); }); + expect(timedOut.row).toMatchObject({ unavailable: true, quotaFailure: "timeout" }); + clearAccountQuotaCache(); + const refused = await probeWith(async () => { throw new TypeError(`connect refused ${UPSTREAM_TEXT}`); }); + expect(refused.row).toMatchObject({ unavailable: true, quotaFailure: "transport_error" }); + }); + + test("a signed-out account reports account_unavailable without dispatching, and health is left alone", async () => { + const account = getAccountSet("anthropic")!.accounts[0]!; + await markAccountNeedsReauth("anthropic", account.id, true); + const { row, calls } = await probeWith(async () => new Response("{}", { status: 200 })); + expect(calls).toBe(0); + expect(row).toMatchObject({ unavailable: true, quotaFailure: "account_unavailable" }); + }); + + test("a usable answer carries no failure reason, and a transient failure never marks the account for sign-in", async () => { + const failed = await probeWith(async () => new Response(UPSTREAM_TEXT, { status: 503 })); + expect(failed.row.quotaFailure).toBe("upstream_error"); + expect(getAccountSet("anthropic")!.accounts[0]!.needsReauth).toBeFalsy(); + const ok = await probeWith(async () => new Response(JSON.stringify({ five_hour: { utilization: 12 } }), { status: 200 })); + expect(ok.row.unavailable).toBeUndefined(); + expect(ok.row).not.toHaveProperty("quotaFailure"); + expect(ok.row.quota?.fiveHourPercent).toBe(12); + }); +}); From a3026aa6010c0c66b0155696a8b62c949e554104 Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 20 Oct 2026 06:12:18 +0900 Subject: [PATCH 14/22] test(claude): match the symlink-resolved rollback target in the picker restore case The atomic writer renames onto the symlink-resolved target, so on macOS (/var -> /private/var) the rollback rename never matched the fixture path and the restore-failure injection did not fire. Compare both spellings. Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com> --- .../claude-desktop-picker-profile.test.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/claude-integration/claude-desktop-picker-profile.test.ts b/tests/claude-integration/claude-desktop-picker-profile.test.ts index ff176d8a8f..2ac24ba78e 100644 --- a/tests/claude-integration/claude-desktop-picker-profile.test.ts +++ b/tests/claude-integration/claude-desktop-picker-profile.test.ts @@ -1,10 +1,10 @@ import * as pickerFilesystem from "node:fs"; import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { spawnSync } from "node:child_process"; -import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, unlinkSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync, unlinkSync, writeFileSync } from "node:fs"; import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { basename, dirname, join } from "node:path"; import * as clientLifecycle from "../../src/client/lifecycle-lock"; import * as configIO from "../../src/config"; import * as desktopLibrary from "../../src/claude/desktop-3p-library"; @@ -340,7 +340,11 @@ describe("picker boundary and recovery receipts", () => { return realWrite(path, content, io, hooks); }); const renaming = spyOn(pickerFilesystem, "renameSync").mockImplementation((source, destination) => { - if (existing && metadataFailed && destination === profile) { recoveryFailed = true; throw new Error("fixture restore failure"); } + // The atomic writer renames onto the symlink-resolved target, so on macOS (/var -> /private/var) + // the destination is the canonical spelling of the profile path; compare both spellings. + const restoring = profile !== undefined + && (destination === profile || destination === join(realpathSync(dirname(profile)), basename(profile))); + if (existing && metadataFailed && restoring) { recoveryFailed = true; throw new Error("fixture restore failure"); } return rename(source, destination); }); const removing = spyOn(pickerFilesystem, "unlinkSync").mockImplementation(path => { From 48aad0a0a91f41419332d630f1111bf752d5f682 Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 20 Oct 2026 06:14:04 +0900 Subject: [PATCH 15/22] fix(google-antigravity): cancel the import project probe with the import request probeAntigravityUsageQuota accepts an optional caller signal that cancels every request of the probe and stops before the models fallback; the import verifier forwards the import signal and never admits after cancellation. --- .../google-antigravity-adapter.ts | 2 +- src/providers/quota/antigravity.ts | 8 +++-- .../account-import-project-fallback.test.ts | 36 ++++++++++++++++++- 3 files changed, 42 insertions(+), 4 deletions(-) diff --git a/src/oauth/account-import/google-antigravity-adapter.ts b/src/oauth/account-import/google-antigravity-adapter.ts index 14127833ca..83eab11144 100644 --- a/src/oauth/account-import/google-antigravity-adapter.ts +++ b/src/oauth/account-import/google-antigravity-adapter.ts @@ -31,7 +31,7 @@ export interface AntigravityImportAdapterDeps { export async function verifyAntigravityImportProject(accessToken: string, projectId: string, signal?: AbortSignal): Promise { if (signal?.aborted) return false; try { - return (await probeAntigravityUsageQuota(accessToken, projectId)).kind === "available"; + return (await probeAntigravityUsageQuota(accessToken, projectId, signal)).kind === "available" && !signal?.aborted; } catch { return false; } diff --git a/src/providers/quota/antigravity.ts b/src/providers/quota/antigravity.ts index 9ae43e9a67..e24f5f0d6d 100644 --- a/src/providers/quota/antigravity.ts +++ b/src/providers/quota/antigravity.ts @@ -306,13 +306,16 @@ function antigravityUnavailableFailure( return fallbackFailure; } -export async function probeAntigravityUsageQuota(accessToken: string, projectId: string): Promise { +export async function probeAntigravityUsageQuota(accessToken: string, projectId: string, signal?: AbortSignal): Promise { + // An optional caller signal (account import) cancels every request of this probe, including the + // 403 retry and the models fallback, alongside the per-request deadline. + const requestSignal = () => signal ? AbortSignal.any([signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)]) : AbortSignal.timeout(REQUEST_TIMEOUT_MS); const fetchQuota = (url: string, userAgent = antigravityUserAgent()) => providerOutboundPost("google-antigravity", { baseUrl: ANTIGRAVITY_ACCOUNT_QUOTA_BASE }, url, { headers: { Accept: "application/json", "Content-Type": "application/json", "User-Agent": userAgent, Authorization: `Bearer ${accessToken}`, }, - body: JSON.stringify({ project: projectId }), signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + body: JSON.stringify({ project: projectId }), signal: requestSignal(), }, antigravityOutboundDependencies); let summaryFailure: QuotaFailureCode | undefined; try { @@ -332,6 +335,7 @@ export async function probeAntigravityUsageQuota(accessToken: string, projectId: // Existing behavior: summary transport/parse failure may recover through the models probe. summaryFailure = quotaTransportFailure(error); } + if (signal?.aborted) return unavailableAntigravityQuota(summaryFailure ?? "transport_error"); try { const response = await fetchQuota(ANTIGRAVITY_QUOTA_MODELS_URL); if (await providerRedirectError(response, ANTIGRAVITY_QUOTA_MODELS_URL)) { diff --git a/tests/server/account-import-project-fallback.test.ts b/tests/server/account-import-project-fallback.test.ts index 1f0ec1920a..e5b8056cda 100644 --- a/tests/server/account-import-project-fallback.test.ts +++ b/tests/server/account-import-project-fallback.test.ts @@ -5,7 +5,8 @@ * every failure or cancellation path persists nothing. All provider calls are fakes. */ import { describe, expect, test } from "bun:test"; -import { createAntigravityAccountImportAdapter, type AntigravityImportAdapterDeps } from "../../src/oauth/account-import/google-antigravity-adapter"; +import { createAntigravityAccountImportAdapter, verifyAntigravityImportProject, type AntigravityImportAdapterDeps } from "../../src/oauth/account-import/google-antigravity-adapter"; +import { setAntigravityAccountQuotaTransportForTests } from "../../src/providers/quota/antigravity"; import { parseCockpitAccountDocument } from "../../src/oauth/account-import/parser"; import { importAccounts } from "../../src/oauth/account-import/service"; import { ACCOUNT_IMPORT_FORMAT, ACCOUNT_IMPORT_PROVIDER, type ValidatedAntigravityCredential } from "../../src/oauth/account-import/types"; @@ -109,3 +110,36 @@ describe("Antigravity import project fallback", () => { expect(h.writes).toEqual([]); }); }); + +describe("real project verifier honors import cancellation", () => { + const resolveAddresses = async () => ({ hostname: "daily-cloudcode-pa.googleapis.com", addresses: [{ address: "142.250.0.1", family: 4 as const }], privateNetwork: false }); + + test("an already-cancelled import sends no probe request", async () => { + const urls: string[] = []; + setAntigravityAccountQuotaTransportForTests({ resolveAddresses, pinnedPost: async url => { urls.push(url); return new Response("{}", { status: 200 }); } }); + try { + const controller = new AbortController(); + controller.abort(); + expect(await verifyAntigravityImportProject("access-for-user", "aicode-consumers", controller.signal)).toBe(false); + expect(urls).toEqual([]); + } finally { setAntigravityAccountQuotaTransportForTests(null); } + }); + + test("cancelling mid-probe aborts the request and skips the retry and models fallback", async () => { + const urls: string[] = []; + const controller = new AbortController(); + setAntigravityAccountQuotaTransportForTests({ + resolveAddresses, + pinnedPost: async (url, _pinned, _body, signal) => { + urls.push(url); + controller.abort(); + if (signal?.aborted) throw signal.reason; + return new Response("{}", { status: 200 }); + }, + }); + try { + expect(await verifyAntigravityImportProject("access-for-user", "aicode-consumers", controller.signal)).toBe(false); + expect(urls).toHaveLength(1); + } finally { setAntigravityAccountQuotaTransportForTests(null); } + }); +}); From 7de0445dd5b5ac82764751a11db500d4d8c30e67 Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 20 Oct 2026 06:17:36 +0900 Subject: [PATCH 16/22] fix(codex): upgrade installed Windows shims that load the token before bypass CMD and PowerShell wrappers now carry a Windows revision marker. A marked wrapper without it (installed before the bypass-token fix) is obsolete: installCodexShim and autoRestoreCodexShim rewrite it in place over the same backing launcher instead of reporting it healthy or already installed, and diagnostics report it unhealthy. On Windows the service token reader also binds the opened descriptor to the path by identity, so a link swapped in after lstat is refused. --- scripts/test-layout/layout.json | 1 + src/codex/shim-diagnostics.ts | 3 +- src/codex/shim-fingerprint.ts | 13 ++- src/codex/shim-templates.ts | 9 +- src/codex/shim.ts | 21 ++++- src/lib/service-secrets.ts | 13 ++- structure/runtime.md | 2 +- .../codex-shim-windows-revision.test.ts | 90 +++++++++++++++++++ tests/fixtures/test-layout-expected.json | 1 + 9 files changed, 142 insertions(+), 11 deletions(-) create mode 100644 tests/codex-integration/codex-shim-windows-revision.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 63c273862f..7d14a8507b 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -713,6 +713,7 @@ "codex-shim-autorestore.test.ts": "codex-integration", "codex-shim-destroyed-probe.test.ts": "codex-integration", "codex-shim-bypass-token.test.ts": "codex-integration", + "codex-shim-windows-revision.test.ts": "codex-integration", "codex-shim-ensure-failure.test.ts": "codex-integration", "codex-shim-readiness.test.ts": "codex-integration", "codex-shim-standalone.test.ts": "codex-integration", diff --git a/src/codex/shim-diagnostics.ts b/src/codex/shim-diagnostics.ts index 8de15bec15..30b2a92799 100644 --- a/src/codex/shim-diagnostics.ts +++ b/src/codex/shim-diagnostics.ts @@ -1,5 +1,5 @@ import { existsSync, lstatSync, readFileSync } from "node:fs"; -import { SHIM_MARKER, UNIX_SHIM_REVISION_MARKER } from "./shim-templates"; +import { SHIM_MARKER, UNIX_SHIM_REVISION_MARKER, WINDOWS_SHIM_REVISION_MARKER } from "./shim-templates"; import { readStateResult, stateFiles, statePath } from "./shim-state-file"; import { overlayDiagnostic } from "./shim-overlay"; @@ -16,6 +16,7 @@ function isHealthyShim(path: string, platform: NodeJS.Platform): boolean { const content = readFileSync(path, "utf8"); if (content.length < 180 || !content.includes(SHIM_MARKER) || !content.includes("ensure")) return false; if (platform !== "win32" && !content.includes(UNIX_SHIM_REVISION_MARKER)) return false; + if (platform === "win32" && !content.includes(WINDOWS_SHIM_REVISION_MARKER) && !content.includes(UNIX_SHIM_REVISION_MARKER)) return false; if (platform !== "win32" && (lstatSync(path).mode & 0o111) === 0) return false; return true; } catch { diff --git a/src/codex/shim-fingerprint.ts b/src/codex/shim-fingerprint.ts index aee6d4bfef..51fbb5cf04 100644 --- a/src/codex/shim-fingerprint.ts +++ b/src/codex/shim-fingerprint.ts @@ -13,7 +13,7 @@ import { unlinkSync, } from "node:fs"; import { posix, win32 } from "node:path"; -import { SHIM_MARKER, UNIX_SHIM_REVISION_MARKER } from "./shim-templates"; +import { SHIM_MARKER, UNIX_SHIM_REVISION_MARKER, WINDOWS_SHIM_REVISION_MARKER } from "./shim-templates"; import type { ShimFileState } from "./shim-state-file"; const CODEX_SHIM_PROBE_BYTES = 16 * 1024; @@ -188,6 +188,15 @@ function isCurrentUnixShimProbe(probe: StableShimPathProbe): boolean { return probe.prefix.includes(UNIX_SHIM_REVISION_MARKER); } +/** A Windows wrapper is current when it carries its flavor's revision (CMD/PowerShell, or Git-Bash's Unix one). */ +function isCurrentWindowsShimProbe(probe: StableShimPathProbe): boolean { + return probe.prefix.includes(WINDOWS_SHIM_REVISION_MARKER) || probe.prefix.includes(UNIX_SHIM_REVISION_MARKER); +} + +function isCurrentShimProbe(probe: StableShimPathProbe, platform: NodeJS.Platform): boolean { + return platform === "win32" ? isCurrentWindowsShimProbe(probe) : isCurrentUnixShimProbe(probe); +} + function hasUsableBackingPath(file: ShimFileState): boolean { return [existsSync(file.backupPath) ? file.backupPath : undefined, file.realPath] .some(path => { @@ -225,4 +234,4 @@ export function isVersionManagerOwnedCodexPath( } export type { ShimPathFingerprint, StableShimPathProbe }; -export { statFingerprint, sameFingerprint, sameFingerprintAfterRename, stableShimPathProbe, sameStableShimPathProbe, shimPathFingerprint, restoreWithoutReplacing, isHealthyShimProbe, isCurrentUnixShimProbe, hasUsableBackingPath }; +export { statFingerprint, sameFingerprint, sameFingerprintAfterRename, stableShimPathProbe, sameStableShimPathProbe, shimPathFingerprint, restoreWithoutReplacing, isHealthyShimProbe, isCurrentUnixShimProbe, isCurrentShimProbe, hasUsableBackingPath }; diff --git a/src/codex/shim-templates.ts b/src/codex/shim-templates.ts index f17e45ad2e..6815201ffc 100644 --- a/src/codex/shim-templates.ts +++ b/src/codex/shim-templates.ts @@ -8,6 +8,11 @@ const SHIM_MARKER = "opencodex codex autostart shim"; // Revision 4: OCX_SHIM_BYPASS skips loading the service API token. Bumping the marker // makes installed revision-3 wrappers obsolete so the existing refresh path rewrites them. const UNIX_SHIM_REVISION_MARKER = "opencodex unix codex shim revision 4"; +// Windows CMD and PowerShell wrappers carry their own revision. Revision 1 is the first one that +// skips the service token under OCX_SHIM_BYPASS; a marked wrapper without it is obsolete and +// install/auto-restore rewrite it in place. (The extensionless Git-Bash wrapper is the Unix +// template and carries the Unix revision.) +const WINDOWS_SHIM_REVISION_MARKER = "opencodex windows codex shim revision 1"; const CODEX_SHIM_REENTRY_EXIT_CODE = 126; const CODEX_SHIM_REENTRY_DIAGNOSTIC = "opencodex: saved Codex launcher resolved back to the autostart shim; run ocx codex-shim uninstall and reinstall Codex before enabling codexAutoStart."; @@ -194,6 +199,7 @@ export function buildWindowsCodexShim(realCodexPath: string, bunPath: string, cl const cliArgs = selfLaunchArgv([], { sourceEntrypoint: cliPath, isStandaloneExecutable: bunRuntimeSource === "standalone" }); return `@echo off\r rem ${SHIM_MARKER}\r +rem ${WINDOWS_SHIM_REVISION_MARKER}\r setlocal\r ${windowsBatchSet("OCX_REAL_CODEX", realCodexPath)}\r ${windowsBatchSet("OCX_BUN", bunPath)}\r @@ -244,6 +250,7 @@ export function buildWindowsPowerShellCodexShim(realCodexPath: string, bunPath: .map(arg => `${psString(arg)} `).join(""); return `#!/usr/bin/env pwsh # ${SHIM_MARKER} +# ${WINDOWS_SHIM_REVISION_MARKER} $hadApiAuthToken = Test-Path Env:\\OPENCODEX_API_AUTH_TOKEN $priorApiAuthToken = $env:OPENCODEX_API_AUTH_TOKEN try { @@ -298,4 +305,4 @@ function gitBashPath(path: string): string { return path.replace(/\\/g, "/"); } -export { SHIM_MARKER, UNIX_SHIM_REVISION_MARKER, CODEX_SHIM_REENTRY_EXIT_CODE, CODEX_SHIM_REENTRY_DIAGNOSTIC, shQuote, windowsBatchSet, psString, gitBashPath }; +export { SHIM_MARKER, UNIX_SHIM_REVISION_MARKER, WINDOWS_SHIM_REVISION_MARKER, CODEX_SHIM_REENTRY_EXIT_CODE, CODEX_SHIM_REENTRY_DIAGNOSTIC, shQuote, windowsBatchSet, psString, gitBashPath }; diff --git a/src/codex/shim.ts b/src/codex/shim.ts index b3f999466b..3d2aaa0118 100644 --- a/src/codex/shim.ts +++ b/src/codex/shim.ts @@ -26,6 +26,7 @@ import { } from "./shim-templates"; import { hasUsableBackingPath, + isCurrentShimProbe, isCurrentUnixShimProbe, isHealthyShimProbe, isVersionManagerOwnedCodexPath, @@ -509,6 +510,15 @@ function refreshShimFile(file: ShimFileState, runtime: DurableBunRuntime): boole writeShim(file.wrapperPath, file.realPath ?? file.backupPath, runtime); return true; } + // An owned Windows wrapper from an older template revision (e.g. one that loads the service + // token before honoring OCX_SHIM_BYPASS) is rewritten in place over the same backing launcher. + if (process.platform === "win32" && existsSync(file.wrapperPath) && isShim(file.wrapperPath) && hasUsableBackingPath(file)) { + const probe = stableShimPathProbe(file.wrapperPath); + if (probe && probe.prefix.includes(SHIM_MARKER) && !isCurrentShimProbe(probe, "win32")) { + writeShim(file.wrapperPath, file.realPath ?? file.backupPath, runtime); + return true; + } + } return false; } @@ -884,7 +894,8 @@ function installCodexShimInternal(options: InstallCodexShimInternalOptions): { i if (filesBefore.length > 0 && filesBefore.every(file => { if (file.preserveOnly) return existsSync(file.backupPath) && !existsSync(file.originalPath); const probe = stableShimPathProbe(file.wrapperPath); - return hasUsableBackingPath(file) && probe !== null && isHealthyShimProbe(probe, process.platform); + return hasUsableBackingPath(file) && probe !== null && isHealthyShimProbe(probe, process.platform) + && (process.platform !== "win32" || isCurrentShimProbe(probe, "win32")); })) { return { installed: false, message: "Codex autostart shim is already installed." }; } @@ -1125,7 +1136,7 @@ export function autoRestoreCodexShim(options: RuntimePreflightDeps & { if (!isHealthyShimProbe(probe, state.platform)) { return { status: "ineligible", message: destroyedShimMessage(file) }; } - if (state.platform !== "win32" && !isCurrentUnixShimProbe(probe)) { + if (!isCurrentShimProbe(probe, state.platform)) { obsoleteShimProbes.set(file.wrapperPath, probe); continue; } @@ -1166,9 +1177,13 @@ export function autoRestoreCodexShim(options: RuntimePreflightDeps & { if (obsoleteShimProbes.size > 0) { for (const [path, firstProbe] of obsoleteShimProbes) { const secondProbe = stableShimPathProbe(path); - if (!secondProbe || isCurrentUnixShimProbe(secondProbe) + if (!secondProbe || isCurrentShimProbe(secondProbe, state.platform) || !sameStableShimPathProbe(firstProbe, secondProbe)) return { status: "deferred" }; } + if (state.platform === "win32") { + const refreshed = installCodexShimInternal({ runtime, runtimeAdmitted: true, allowFreshInstall: false }); + return refreshed.installed ? { status: "restored", message: refreshed.message } : { status: "deferred" }; + } const result = refreshObsoleteUnixShims(files); return result.installed ? { status: "restored", message: result.message } diff --git a/src/lib/service-secrets.ts b/src/lib/service-secrets.ts index 65a414a6d6..9717bf4e34 100644 --- a/src/lib/service-secrets.ts +++ b/src/lib/service-secrets.ts @@ -283,10 +283,17 @@ export function loadServiceTokenFromFile(env: Record : constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK; let fd: number | undefined; try { - if (process.platform === "win32" && lstatSync(file).isSymbolicLink()) return null; + // Windows has no O_NOFOLLOW: refuse a link up front, then bind the opened descriptor to the + // entry by identity so a link swapped in between lstat and open is still refused. + const entry = process.platform === "win32" ? lstatSync(file, { bigint: true }) : undefined; + if (entry?.isSymbolicLink()) return null; fd = openSync(file, flags); - const stat = fstatSync(fd); - if (!stat.isFile() || stat.size > MAX_SERVICE_API_TOKEN_BYTES) return null; + const stat = fstatSync(fd, { bigint: true }); + if (!stat.isFile() || stat.size > BigInt(MAX_SERVICE_API_TOKEN_BYTES)) return null; + if (entry) { + const after = lstatSync(file, { bigint: true }); + if (after.isSymbolicLink() || after.dev !== stat.dev || after.ino !== stat.ino || entry.ino !== stat.ino || entry.dev !== stat.dev) return null; + } const buffer = Buffer.alloc(MAX_SERVICE_API_TOKEN_BYTES + 1); let length = 0; while (length < buffer.length) { diff --git a/structure/runtime.md b/structure/runtime.md index 114e7254f4..7eb2493860 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -125,7 +125,7 @@ does not perform OAuth, and runtime credential resolution rereads the owned sour | `src/types.ts` | Shared config, parsed request, adapter, and event types. | | `src/reasoning-effort.ts` | Codex reasoning-level definitions (`low`/`medium`/`high`/`xhigh`), per-model effort mapping, and catalog effort sanitization. | | `src/cli/tray-proxy.ts` | Proxy restart coordination, bounded replacement observation and the [guarded CLI update restart contract](ops/service-and-sidecars.md#guarded-cli-update-restart). | -| `src/codex/shim.ts` | Codex autostart facade: dispatches Unix private PATH overlays to `src/codex/shim-overlay.ts` and retains Windows in-place script wrapping. `src/codex/shim-migration.ts` owns explicit legacy Unix restoration. Legacy Unix shim migration rejects symlink, foreign-owned, and group/world-writable state files. The writable-mode refusal prints a shell-quoted `chmod 600` hint; it clears only that mode refusal, leaving file-type and ownership checks intact. `tests/codex-integration/codex-shim-migration.test.ts` executes the hint through `/bin/sh` with a space and apostrophe in the state path, then verifies mode `0600` and schema-2 migration. `src/codex/shim-diagnostics.ts` owns diagnosis/status, distinguishes runnable from active overlays and states the unhealthy verdict for a damaged in-place (Windows) wrapper. `src/codex/shim-templates.ts` uses `src/lib/self-launch-argv.ts` to invoke standalone executables with `ensure` directly in Unix, CMD and PowerShell wrappers, without a virtual source entrypoint; management subcommands bypass startup even after value-taking global flags. Discovery resolves fnm’s temporary `fnm_multishells` directory to the durable installation directory through `src/codex/shim-path-resolution.ts`, taking the directory realpath while retaining the command basename; unresolved temporary entries refuse installation. The injected path flavour governs join and separator. The shared PATH scanner follows npm/fnm symlinks and POSIX target execute access, skips non-executable and special-file entries, and reads only the first 16 KiB of regular files through a descriptor with nonblocking POSIX open and a post-open type check. An unreadable executable still shadows later commands. Overlay activation requires the selected executable to identify the recorded private wrapper or the same file through an alias; another marker-bearing shim does not prove activation. Shell functions/aliases and empty PATH components are outside this scanner’s guarantee. `src/cli/codex-shim-readiness.ts` retains connect’s advisory exit policy. `tests/codex-integration/codex-shim-path-readiness.test.ts` covers POSIX shell lookup parity, FIFO replacement, symlinks and bounded reads. Windows retains PATHEXT lookup without POSIX execute-access checks. | +| `src/codex/shim.ts` | Codex autostart facade: dispatches Unix private PATH overlays to `src/codex/shim-overlay.ts` and retains Windows in-place script wrapping. `src/codex/shim-migration.ts` owns explicit legacy Unix restoration. Legacy Unix shim migration rejects symlink, foreign-owned, and group/world-writable state files. The writable-mode refusal prints a shell-quoted `chmod 600` hint; it clears only that mode refusal, leaving file-type and ownership checks intact. `tests/codex-integration/codex-shim-migration.test.ts` executes the hint through `/bin/sh` with a space and apostrophe in the state path, then verifies mode `0600` and schema-2 migration. `src/codex/shim-diagnostics.ts` owns diagnosis/status, distinguishes runnable from active overlays and states the unhealthy verdict for a damaged in-place (Windows) wrapper. `src/codex/shim-templates.ts` uses `src/lib/self-launch-argv.ts` to invoke standalone executables with `ensure` directly in Unix, CMD and PowerShell wrappers, without a virtual source entrypoint; management subcommands bypass startup even after value-taking global flags. Under `OCX_SHIM_BYPASS` no flavor loads the service API token, so a bypassed native launch never hands it to Codex. Unix wrappers carry a Unix revision and CMD/PowerShell wrappers a Windows revision; a marked wrapper without the current revision is obsolete, and both `installCodexShim` and `autoRestoreCodexShim` rewrite it in place over the same backing launcher (`tests/codex-integration/codex-shim-windows-revision.test.ts`). Discovery resolves fnm’s temporary `fnm_multishells` directory to the durable installation directory through `src/codex/shim-path-resolution.ts`, taking the directory realpath while retaining the command basename; unresolved temporary entries refuse installation. The injected path flavour governs join and separator. The shared PATH scanner follows npm/fnm symlinks and POSIX target execute access, skips non-executable and special-file entries, and reads only the first 16 KiB of regular files through a descriptor with nonblocking POSIX open and a post-open type check. An unreadable executable still shadows later commands. Overlay activation requires the selected executable to identify the recorded private wrapper or the same file through an alias; another marker-bearing shim does not prove activation. Shell functions/aliases and empty PATH components are outside this scanner’s guarantee. `src/cli/codex-shim-readiness.ts` retains connect’s advisory exit policy. `tests/codex-integration/codex-shim-path-readiness.test.ts` covers POSIX shell lookup parity, FIFO replacement, symlinks and bounded reads. Windows retains PATHEXT lookup without POSIX execute-access checks. | | `src/service.ts` | OS service manager (macOS launchd, Linux systemd, Windows schtasks and native WinSW): always-on proxy with crash restart. The scheduler wrapper logs transactional-backup recovery without interpolating backup names. Facade over the `src/service/` leaves — `src/service/launchd.ts`, `src/service/systemd.ts`, `src/service/windows-ops.ts`, `src/service/windows-scheduler.ts`, `src/service/windows-taskxml.ts`, `src/service/state.ts`, `src/service/guards.ts`, `src/service/health.ts`, `src/service/repair.ts`, `src/service/orchestration.ts`, `src/service/diagnostics.ts`, `src/service/cli.ts`. Launchd start and load-state checks select the installed plist port, falling back to the configured service port (including `OCX_BAKE_PORT`). A failed-load already-running no-op also requires a readable installed plist; a missing or unreadable plist requires repair even when the live command matches. Copied systemd or Windows artifacts are not launchd port evidence; the generic artifact readers are not all platform-gated. `tests/service/launchd-port-fallback.test.ts` covers missing/unreadable plist refusal, readable malformed-port fallback with a foreign systemd port that differs from config, and installed-plist precedence, using synthetic reads and injected manager calls. Service definitions filter shell-local multishell PATH entries, including WinSW XML generated during install and repair, and recorded temporary launchers are replaced during repair; changed launchd definitions are reloaded. Codex-home ownership accepts either the recorded path or the same existing physical directory so path aliases remain compatible across upgrades. Elevated Task Scheduler repair stages bounded payloads; the unelevated launcher pins every namespace ancestor and payload with non-reparse handles that deny write/delete sharing on the payload and delete sharing on each ancestor until UAC processing exits. On Windows, `src/service/windows-process-priority.ts` raises the process that binds the proxy to ABOVE_NORMAL just before `startServer` (best-effort; `OCX_DISABLE_PRIORITY_BOOST=1` opts out), because the NORMAL task priority (`4`) still let a saturated host hold `/healthz` past the 750 ms CLI liveness ceiling; spawned children fall back to NORMAL since Windows does not inherit ABOVE_NORMAL. The Task Scheduler batch wrapper (`src/service/windows-taskxml.ts`) checks bundled Bun against `REAL_BUN_MIN_BYTES` before each launch: npm's placeholder, or a file that vanished before its size was read, is logged and re-checked every five seconds instead of executed, while a Bun path missing at the exist check keeps the backup-restore then exit-3 path ([wrapper contract](ops/docs-and-release.md#windows-service-wrapper-and-incomplete-updates)). | `src/cli/provider.ts` accepts the Google-only `--google-tool-schema-policy` creation flag and rejects diff --git a/tests/codex-integration/codex-shim-windows-revision.test.ts b/tests/codex-integration/codex-shim-windows-revision.test.ts new file mode 100644 index 0000000000..088900c88a --- /dev/null +++ b/tests/codex-integration/codex-shim-windows-revision.test.ts @@ -0,0 +1,90 @@ +/** + * An installed Windows CMD or PowerShell wrapper from before the bypass-token fix loads the + * service token before honoring OCX_SHIM_BYPASS. Those wrappers carry the shim marker but no + * Windows revision, so explicit install and auto-restore must rewrite them in place over the same + * backing launcher, never report them healthy or "already installed". No Windows shell runs here: + * process.platform is overridden and the wrappers are only written and read. + */ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import * as fs from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { autoRestoreCodexShim, installCodexShim } from "../../src/codex/shim"; +import { buildWindowsCodexShim, buildWindowsPowerShellCodexShim, WINDOWS_SHIM_REVISION_MARKER } from "../../src/codex/shim-templates"; +import { isolateCodexShimEnvironment } from "../helpers/codex-shim-install-fixture"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +isolateCodexShimEnvironment(); + +const platform = Object.getOwnPropertyDescriptor(process, "platform")!; +const previousHome = process.env.OPENCODEX_HOME; +let dir = ""; + +beforeEach(() => { + dir = fs.mkdtempSync(join(tmpdir(), "ocx-win-shim-revision-")); + process.env.OPENCODEX_HOME = dir; +}); + +afterEach(() => { + Object.defineProperty(process, "platform", platform); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + removeTreeWithRetry(dir); +}); + +/** The pre-fix template: no Windows revision line, and the token load ahead of the bypass check. */ +function previousTemplate(flavor: "cmd" | "ps1", backing: string): string { + const current = flavor === "cmd" + ? buildWindowsCodexShim(backing, "C:\\bun.exe", "C:\\cli.ts", "process") + : buildWindowsPowerShellCodexShim(backing, "C:\\bun.exe", "C:\\cli.ts", "process"); + const lines = current.split("\n").filter(line => !line.includes(WINDOWS_SHIM_REVISION_MARKER)); + if (flavor === "cmd") { + const bypass = lines.findIndex(line => line.includes('if not "%OCX_SHIM_BYPASS%"=="" goto run_codex')); + const load = lines.findIndex(line => line.includes("set /p OPENCODEX_API_AUTH_TOKEN=")); + [lines[bypass], lines[load]] = [lines[load]!, lines[bypass]!]; + return lines.join("\n"); + } + return lines.join("\n").replace("-not $env:OCX_SHIM_BYPASS -and ", ""); +} + +function seed(flavor: "cmd" | "ps1"): { wrapper: string; backing: string; previous: string } { + const wrapper = join(dir, `codex.${flavor}`), backing = `${wrapper}.opencodex-real`; + const previous = previousTemplate(flavor, backing); + fs.writeFileSync(wrapper, previous); + fs.writeFileSync(backing, "native fixture launcher"); + fs.writeFileSync(join(dir, "codex-shim.json"), JSON.stringify({ platform: "win32", originalPath: wrapper, wrapperPath: wrapper, backupPath: backing })); + Object.defineProperty(process, "platform", { ...platform, value: "win32" }); + return { wrapper, backing, previous }; +} + +function expectCurrent(flavor: "cmd" | "ps1", wrapper: string, backing: string): void { + const text = fs.readFileSync(wrapper, "utf8"); + expect(text).toContain(WINDOWS_SHIM_REVISION_MARKER); + if (flavor === "cmd") { + expect(text.indexOf("set /p OPENCODEX_API_AUTH_TOKEN=")).toBeGreaterThan(text.indexOf('if not "%OCX_SHIM_BYPASS%"=="" goto run_codex')); + } else { + expect(text).toContain("-not $env:OCX_SHIM_BYPASS -and -not $env:OPENCODEX_API_AUTH_TOKEN"); + } + expect(fs.readFileSync(backing, "utf8")).toBe("native fixture launcher"); +} + +describe("obsolete Windows shims are upgraded", () => { + for (const flavor of ["cmd", "ps1"] as const) { + test(`explicit install rewrites a previous ${flavor} wrapper`, () => { + const { wrapper, backing, previous } = seed(flavor); + const result = installCodexShim(); + expect(result.installed).toBe(true); + expect(fs.readFileSync(wrapper, "utf8")).not.toBe(previous); + expectCurrent(flavor, wrapper, backing); + // Now current: a second install is a no-op. + expect(installCodexShim().installed).toBe(false); + }); + + test(`auto-restore rewrites a previous ${flavor} wrapper instead of reporting it healthy`, () => { + const { wrapper, backing } = seed(flavor); + expect(autoRestoreCodexShim({ enabled: () => true, stabilitySleep: () => {} }).status).toBe("restored"); + expectCurrent(flavor, wrapper, backing); + expect(autoRestoreCodexShim({ enabled: () => true, stabilitySleep: () => {} }).status).toBe("healthy"); + }); + } +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 0b581f39eb..41ddbc7f7f 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -568,6 +568,7 @@ "codex-service-manager-probe.test.ts": "codex-integration", "codex-systemd-env-probe.test.ts": "codex-integration", "codex-shim-autorestore.test.ts": "codex-integration", "codex-shim-destroyed-probe.test.ts": "codex-integration", "codex-shim-bypass-token.test.ts": "codex-integration", + "codex-shim-windows-revision.test.ts": "codex-integration", "codex-shim-ensure-failure.test.ts": "codex-integration", "codex-shim-readiness.test.ts": "codex-integration", "codex-shim-standalone.test.ts": "codex-integration", "codex-shim.test.ts": "codex-integration", "codex-signin-lockout.test.ts": "codex-integration", "codex-spark-visibility.test.ts": "codex-integration", From 7eec3cd064385ef38567d88f092ce08314e51aff Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 20 Oct 2026 06:20:03 +0900 Subject: [PATCH 17/22] test(codex): mark the in-place Windows diagnostics fixture with the current revision --- tests/codex-integration/codex-shim-readiness.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/codex-integration/codex-shim-readiness.test.ts b/tests/codex-integration/codex-shim-readiness.test.ts index 354887f8f9..abfd4e5fee 100644 --- a/tests/codex-integration/codex-shim-readiness.test.ts +++ b/tests/codex-integration/codex-shim-readiness.test.ts @@ -13,6 +13,7 @@ import { delimiter, dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { codexShimReadinessWarnings } from "../../src/cli/codex-shim-readiness"; import { diagnoseCodexShim } from "../../src/codex/shim-diagnostics"; +import { WINDOWS_SHIM_REVISION_MARKER } from "../../src/codex/shim-templates"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { SPAWN_BUDGET_MS } from "../helpers/test-budget"; @@ -302,10 +303,13 @@ describe("in-place Windows shim status verdict", () => { // Legacy in-place record, as Windows installs write it; readable on every host. writeFileSync(join(home, "codex-shim.json"), JSON.stringify({ platform: "win32", wrapperPath, originalPath: wrapperPath, backupPath })); process.env.OPENCODEX_HOME = home; - const body = `@echo off\r\nrem opencodex codex autostart shim${"\r\nrem padding".repeat(20)}\r\n`; + const body = `@echo off\r\nrem opencodex codex autostart shim\r\nrem ${WINDOWS_SHIM_REVISION_MARKER}${"\r\nrem padding".repeat(20)}\r\n`; writeFileSync(wrapperPath, `${body}ocx codex-shim ensure\r\n`); expect(diagnoseCodexShim()).toMatchObject({ installed: true, healthy: true }); expect(diagnoseCodexShim().summary).not.toContain("unhealthy"); + // A marked wrapper from before the current Windows revision needs a reinstall. + writeFileSync(wrapperPath, `${body.replace(`\r\nrem ${WINDOWS_SHIM_REVISION_MARKER}`, "")}ocx codex-shim ensure\r\n`); + expect(diagnoseCodexShim()).toMatchObject({ installed: true, healthy: false }); writeFileSync(wrapperPath, `${body}ocx codex-shim broken\r\n`); const damaged = diagnoseCodexShim(); expect(damaged).toMatchObject({ installed: true, healthy: false }); From b070ff0e6054521ef8bfb7a0ab2c0be3ec90c698 Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 20 Oct 2026 06:26:50 +0900 Subject: [PATCH 18/22] fix(codex): refresh obsolete Windows shims without clobbering a concurrent replacement The obsolete-wrapper refresh truncated whatever occupied the wrapper path. It now moves the observed wrapper aside and proves it is the probed file, stages the current wrapper beside it, and publishes with a hard link that refuses an occupied path. A concurrent replacement is restored or kept untouched and the refresh reports deferred. --- src/codex/shim-windows-refresh.ts | 59 +++++++++++++++++++ src/codex/shim.ts | 15 ++++- .../codex-shim-windows-revision.test.ts | 42 ++++++++++++- 3 files changed, 112 insertions(+), 4 deletions(-) create mode 100644 src/codex/shim-windows-refresh.ts diff --git a/src/codex/shim-windows-refresh.ts b/src/codex/shim-windows-refresh.ts new file mode 100644 index 0000000000..f5eaa7f32d --- /dev/null +++ b/src/codex/shim-windows-refresh.ts @@ -0,0 +1,59 @@ +import { randomUUID } from "node:crypto"; +import { existsSync, linkSync, renameSync, unlinkSync } from "node:fs"; +import { extname } from "node:path"; +import { sameFingerprintAfterRename, shimPathFingerprint, type StableShimPathProbe } from "./shim-fingerprint"; + +/** + * Replace an owned, obsolete Windows wrapper without ever truncating or replacing a file someone + * else published at the same path (an npm or version-manager install racing this refresh). + * + * 1. Move the observed wrapper aside and prove the moved file is the one that was probed; a + * different file means a concurrent replacement, which is put back untouched. + * 2. Render the current wrapper into a staged sibling with the same extension. + * 3. Publish with a hard link, which fails instead of replacing when the path is occupied again. + * A concurrent publisher then keeps the path, and only our own obsolete copy is discarded. + * + * Returns true only when the current wrapper was published. Any filesystem that refuses hard links + * leaves the obsolete wrapper restored and the refresh deferred. + */ +export function refreshObsoleteWindowsShim( + wrapperPath: string, + observed: StableShimPathProbe, + writeStaged: (stagedPath: string) => void, +): boolean { + const tag = randomUUID().slice(0, 8); + const extension = extname(wrapperPath); + const aside = `${wrapperPath}.ocx-obsolete-${tag}${extension}`; + const staged = `${wrapperPath}.ocx-refresh-${tag}${extension}`; + const discard = (path: string): void => { try { unlinkSync(path); } catch { /* already gone */ } }; + /** Put the moved file back only onto an empty path; an occupied path keeps its new owner. */ + const restoreAside = (): void => { + if (existsSync(wrapperPath)) return; + try { linkSync(aside, wrapperPath); discard(aside); } catch { /* the aside copy stays recoverable */ } + }; + + try { renameSync(wrapperPath, aside); } catch { return false; } + const moved = shimPathFingerprint(aside); + if (!moved || !sameFingerprintAfterRename(observed.fingerprint, moved)) { + restoreAside(); + return false; + } + try { + writeStaged(staged); + } catch (error) { + discard(staged); + restoreAside(); + throw error; + } + try { + linkSync(staged, wrapperPath); + } catch (error) { + discard(staged); + if ((error as NodeJS.ErrnoException).code === "EEXIST") discard(aside); + else restoreAside(); + return false; + } + discard(staged); + discard(aside); + return true; +} diff --git a/src/codex/shim.ts b/src/codex/shim.ts index 3d2aaa0118..dc4a1091e8 100644 --- a/src/codex/shim.ts +++ b/src/codex/shim.ts @@ -56,6 +56,7 @@ import { type UnixShimProbeResult, } from "./shim-probe"; import { tryAcquireShimRestoreLock } from "./shim-restore-lock"; +import { refreshObsoleteWindowsShim } from "./shim-windows-refresh"; import { isWindowsInteropDir, isExecutableCodexCandidate, @@ -511,12 +512,14 @@ function refreshShimFile(file: ShimFileState, runtime: DurableBunRuntime): boole return true; } // An owned Windows wrapper from an older template revision (e.g. one that loads the service - // token before honoring OCX_SHIM_BYPASS) is rewritten in place over the same backing launcher. + // token before honoring OCX_SHIM_BYPASS) is replaced over the same backing launcher through an + // ownership-checked stage-and-link transaction that never clobbers a concurrent replacement. if (process.platform === "win32" && existsSync(file.wrapperPath) && isShim(file.wrapperPath) && hasUsableBackingPath(file)) { const probe = stableShimPathProbe(file.wrapperPath); if (probe && probe.prefix.includes(SHIM_MARKER) && !isCurrentShimProbe(probe, "win32")) { - writeShim(file.wrapperPath, file.realPath ?? file.backupPath, runtime); - return true; + return refreshObsoleteWindowsShim(file.wrapperPath, probe, staged => { + writeShim(staged, file.realPath ?? file.backupPath, runtime); + }); } } return false; @@ -945,6 +948,12 @@ function installCodexShimInternal(options: InstallCodexShimInternalOptions): { i } let refreshed = false; for (const file of files) refreshed = refreshShimFile(file, runtime) || refreshed; + // A Windows refresh that refused to publish over a concurrent replacement is not a success. + if (process.platform === "win32" && files.some(file => { + if (file.preserveOnly) return false; + const probe = stableShimPathProbe(file.wrapperPath); + return !probe || !probe.prefix.includes(SHIM_MARKER) || !isCurrentShimProbe(probe, "win32"); + })) return { installed: false, message: "Codex shim refresh deferred because a tracked launcher changed during the refresh." }; const allInstalled = files.every(file => file.preserveOnly ? existsSync(file.backupPath) && !existsSync(file.originalPath) : existsSync(file.wrapperPath) diff --git a/tests/codex-integration/codex-shim-windows-revision.test.ts b/tests/codex-integration/codex-shim-windows-revision.test.ts index 088900c88a..c83d73fbd1 100644 --- a/tests/codex-integration/codex-shim-windows-revision.test.ts +++ b/tests/codex-integration/codex-shim-windows-revision.test.ts @@ -8,8 +8,10 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import * as fs from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { basename, join } from "node:path"; import { autoRestoreCodexShim, installCodexShim } from "../../src/codex/shim"; +import { stableShimPathProbe } from "../../src/codex/shim-fingerprint"; +import { refreshObsoleteWindowsShim } from "../../src/codex/shim-windows-refresh"; import { buildWindowsCodexShim, buildWindowsPowerShellCodexShim, WINDOWS_SHIM_REVISION_MARKER } from "../../src/codex/shim-templates"; import { isolateCodexShimEnvironment } from "../helpers/codex-shim-install-fixture"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -88,3 +90,41 @@ describe("obsolete Windows shims are upgraded", () => { }); } }); + +describe("obsolete Windows refresh transaction never clobbers a concurrent replacement", () => { + const leftovers = (wrapper: string) => fs.readdirSync(dir).filter(name => name.startsWith(`${basename(wrapper)}.ocx-`)); + + test("publishes the staged wrapper and leaves no staging files", () => { + const wrapper = join(dir, "codex.cmd"); + fs.writeFileSync(wrapper, "@echo off\r\nrem obsolete\r\n"); + const observed = stableShimPathProbe(wrapper)!; + expect(refreshObsoleteWindowsShim(wrapper, observed, staged => fs.writeFileSync(staged, "current wrapper"))).toBe(true); + expect(fs.readFileSync(wrapper, "utf8")).toBe("current wrapper"); + expect(leftovers(wrapper)).toEqual([]); + }); + + test("a replacement published after the probe is put back untouched and nothing is written", () => { + const wrapper = join(dir, "codex.cmd"); + fs.writeFileSync(wrapper, "@echo off\r\nrem obsolete\r\n"); + const observed = stableShimPathProbe(wrapper)!; + fs.unlinkSync(wrapper); + fs.writeFileSync(wrapper, "replacement native launcher from a concurrent installer"); + let wrote = false; + expect(refreshObsoleteWindowsShim(wrapper, observed, () => { wrote = true; })).toBe(false); + expect(wrote).toBe(false); + expect(fs.readFileSync(wrapper, "utf8")).toBe("replacement native launcher from a concurrent installer"); + expect(leftovers(wrapper)).toEqual([]); + }); + + test("a launcher published while staging keeps the path; only our obsolete copy is discarded", () => { + const wrapper = join(dir, "codex.ps1"); + fs.writeFileSync(wrapper, "# obsolete"); + const observed = stableShimPathProbe(wrapper)!; + expect(refreshObsoleteWindowsShim(wrapper, observed, staged => { + fs.writeFileSync(staged, "current wrapper"); + fs.writeFileSync(wrapper, "replacement published mid-refresh"); + })).toBe(false); + expect(fs.readFileSync(wrapper, "utf8")).toBe("replacement published mid-refresh"); + expect(leftovers(wrapper)).toEqual([]); + }); +}); From 97d557567274aef617d519a09006e8e9612521ed Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 20 Oct 2026 06:26:57 +0900 Subject: [PATCH 19/22] docs(structure): record the Windows shim refresh transaction --- structure/runtime.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/structure/runtime.md b/structure/runtime.md index 7eb2493860..f7e1e16551 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -125,7 +125,7 @@ does not perform OAuth, and runtime credential resolution rereads the owned sour | `src/types.ts` | Shared config, parsed request, adapter, and event types. | | `src/reasoning-effort.ts` | Codex reasoning-level definitions (`low`/`medium`/`high`/`xhigh`), per-model effort mapping, and catalog effort sanitization. | | `src/cli/tray-proxy.ts` | Proxy restart coordination, bounded replacement observation and the [guarded CLI update restart contract](ops/service-and-sidecars.md#guarded-cli-update-restart). | -| `src/codex/shim.ts` | Codex autostart facade: dispatches Unix private PATH overlays to `src/codex/shim-overlay.ts` and retains Windows in-place script wrapping. `src/codex/shim-migration.ts` owns explicit legacy Unix restoration. Legacy Unix shim migration rejects symlink, foreign-owned, and group/world-writable state files. The writable-mode refusal prints a shell-quoted `chmod 600` hint; it clears only that mode refusal, leaving file-type and ownership checks intact. `tests/codex-integration/codex-shim-migration.test.ts` executes the hint through `/bin/sh` with a space and apostrophe in the state path, then verifies mode `0600` and schema-2 migration. `src/codex/shim-diagnostics.ts` owns diagnosis/status, distinguishes runnable from active overlays and states the unhealthy verdict for a damaged in-place (Windows) wrapper. `src/codex/shim-templates.ts` uses `src/lib/self-launch-argv.ts` to invoke standalone executables with `ensure` directly in Unix, CMD and PowerShell wrappers, without a virtual source entrypoint; management subcommands bypass startup even after value-taking global flags. Under `OCX_SHIM_BYPASS` no flavor loads the service API token, so a bypassed native launch never hands it to Codex. Unix wrappers carry a Unix revision and CMD/PowerShell wrappers a Windows revision; a marked wrapper without the current revision is obsolete, and both `installCodexShim` and `autoRestoreCodexShim` rewrite it in place over the same backing launcher (`tests/codex-integration/codex-shim-windows-revision.test.ts`). Discovery resolves fnm’s temporary `fnm_multishells` directory to the durable installation directory through `src/codex/shim-path-resolution.ts`, taking the directory realpath while retaining the command basename; unresolved temporary entries refuse installation. The injected path flavour governs join and separator. The shared PATH scanner follows npm/fnm symlinks and POSIX target execute access, skips non-executable and special-file entries, and reads only the first 16 KiB of regular files through a descriptor with nonblocking POSIX open and a post-open type check. An unreadable executable still shadows later commands. Overlay activation requires the selected executable to identify the recorded private wrapper or the same file through an alias; another marker-bearing shim does not prove activation. Shell functions/aliases and empty PATH components are outside this scanner’s guarantee. `src/cli/codex-shim-readiness.ts` retains connect’s advisory exit policy. `tests/codex-integration/codex-shim-path-readiness.test.ts` covers POSIX shell lookup parity, FIFO replacement, symlinks and bounded reads. Windows retains PATHEXT lookup without POSIX execute-access checks. | +| `src/codex/shim.ts` | Codex autostart facade: dispatches Unix private PATH overlays to `src/codex/shim-overlay.ts` and retains Windows in-place script wrapping. `src/codex/shim-migration.ts` owns explicit legacy Unix restoration. Legacy Unix shim migration rejects symlink, foreign-owned, and group/world-writable state files. The writable-mode refusal prints a shell-quoted `chmod 600` hint; it clears only that mode refusal, leaving file-type and ownership checks intact. `tests/codex-integration/codex-shim-migration.test.ts` executes the hint through `/bin/sh` with a space and apostrophe in the state path, then verifies mode `0600` and schema-2 migration. `src/codex/shim-diagnostics.ts` owns diagnosis/status, distinguishes runnable from active overlays and states the unhealthy verdict for a damaged in-place (Windows) wrapper. `src/codex/shim-templates.ts` uses `src/lib/self-launch-argv.ts` to invoke standalone executables with `ensure` directly in Unix, CMD and PowerShell wrappers, without a virtual source entrypoint; management subcommands bypass startup even after value-taking global flags. Under `OCX_SHIM_BYPASS` no flavor loads the service API token, so a bypassed native launch never hands it to Codex. Unix wrappers carry a Unix revision and CMD/PowerShell wrappers a Windows revision; a marked wrapper without the current revision is obsolete, and both `installCodexShim` and `autoRestoreCodexShim` replace it over the same backing launcher. On Windows that replacement is the stage-and-link transaction in `src/codex/shim-windows-refresh.ts`: the observed wrapper is moved aside and its identity rechecked, and the staged wrapper is published by hard link so a concurrent replacement is never truncated or replaced (`tests/codex-integration/codex-shim-windows-revision.test.ts`). Discovery resolves fnm’s temporary `fnm_multishells` directory to the durable installation directory through `src/codex/shim-path-resolution.ts`, taking the directory realpath while retaining the command basename; unresolved temporary entries refuse installation. The injected path flavour governs join and separator. The shared PATH scanner follows npm/fnm symlinks and POSIX target execute access, skips non-executable and special-file entries, and reads only the first 16 KiB of regular files through a descriptor with nonblocking POSIX open and a post-open type check. An unreadable executable still shadows later commands. Overlay activation requires the selected executable to identify the recorded private wrapper or the same file through an alias; another marker-bearing shim does not prove activation. Shell functions/aliases and empty PATH components are outside this scanner’s guarantee. `src/cli/codex-shim-readiness.ts` retains connect’s advisory exit policy. `tests/codex-integration/codex-shim-path-readiness.test.ts` covers POSIX shell lookup parity, FIFO replacement, symlinks and bounded reads. Windows retains PATHEXT lookup without POSIX execute-access checks. | | `src/service.ts` | OS service manager (macOS launchd, Linux systemd, Windows schtasks and native WinSW): always-on proxy with crash restart. The scheduler wrapper logs transactional-backup recovery without interpolating backup names. Facade over the `src/service/` leaves — `src/service/launchd.ts`, `src/service/systemd.ts`, `src/service/windows-ops.ts`, `src/service/windows-scheduler.ts`, `src/service/windows-taskxml.ts`, `src/service/state.ts`, `src/service/guards.ts`, `src/service/health.ts`, `src/service/repair.ts`, `src/service/orchestration.ts`, `src/service/diagnostics.ts`, `src/service/cli.ts`. Launchd start and load-state checks select the installed plist port, falling back to the configured service port (including `OCX_BAKE_PORT`). A failed-load already-running no-op also requires a readable installed plist; a missing or unreadable plist requires repair even when the live command matches. Copied systemd or Windows artifacts are not launchd port evidence; the generic artifact readers are not all platform-gated. `tests/service/launchd-port-fallback.test.ts` covers missing/unreadable plist refusal, readable malformed-port fallback with a foreign systemd port that differs from config, and installed-plist precedence, using synthetic reads and injected manager calls. Service definitions filter shell-local multishell PATH entries, including WinSW XML generated during install and repair, and recorded temporary launchers are replaced during repair; changed launchd definitions are reloaded. Codex-home ownership accepts either the recorded path or the same existing physical directory so path aliases remain compatible across upgrades. Elevated Task Scheduler repair stages bounded payloads; the unelevated launcher pins every namespace ancestor and payload with non-reparse handles that deny write/delete sharing on the payload and delete sharing on each ancestor until UAC processing exits. On Windows, `src/service/windows-process-priority.ts` raises the process that binds the proxy to ABOVE_NORMAL just before `startServer` (best-effort; `OCX_DISABLE_PRIORITY_BOOST=1` opts out), because the NORMAL task priority (`4`) still let a saturated host hold `/healthz` past the 750 ms CLI liveness ceiling; spawned children fall back to NORMAL since Windows does not inherit ABOVE_NORMAL. The Task Scheduler batch wrapper (`src/service/windows-taskxml.ts`) checks bundled Bun against `REAL_BUN_MIN_BYTES` before each launch: npm's placeholder, or a file that vanished before its size was read, is logged and re-checked every five seconds instead of executed, while a Bun path missing at the exist check keeps the backup-restore then exit-3 path ([wrapper contract](ops/docs-and-release.md#windows-service-wrapper-and-incomplete-updates)). | `src/cli/provider.ts` accepts the Google-only `--google-tool-schema-policy` creation flag and rejects From c0ba8c381fddde28286fa3c69ef8f45d1332305b Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 20 Oct 2026 06:33:04 +0900 Subject: [PATCH 20/22] fix(codex): create the Windows shim refresh stage exclusively and resume partial upgrades The refresh stage is now created with O_CREAT|O_EXCL (and O_NOFOLLOW on POSIX) under an independent random name, and publication, withdrawal and cleanup are bound to the identity of the file this refresh created, so a planted link is never written through. Auto-restore resumes a Windows revision-only sibling mix (current plus obsolete wrappers) instead of deferring forever after one transient publish failure. --- src/codex/shim-windows-refresh.ts | 100 ++++++++++++++---- src/codex/shim.ts | 37 ++++--- .../codex-shim-windows-revision.test.ts | 43 ++++++-- 3 files changed, 131 insertions(+), 49 deletions(-) diff --git a/src/codex/shim-windows-refresh.ts b/src/codex/shim-windows-refresh.ts index f5eaa7f32d..89b355b1c6 100644 --- a/src/codex/shim-windows-refresh.ts +++ b/src/codex/shim-windows-refresh.ts @@ -1,59 +1,115 @@ import { randomUUID } from "node:crypto"; -import { existsSync, linkSync, renameSync, unlinkSync } from "node:fs"; +import { closeSync, constants, existsSync, fstatSync, linkSync, lstatSync, openSync, renameSync, unlinkSync, writeFileSync } from "node:fs"; import { extname } from "node:path"; -import { sameFingerprintAfterRename, shimPathFingerprint, type StableShimPathProbe } from "./shim-fingerprint"; +import { sameFingerprint, sameFingerprintAfterRename, shimPathFingerprint, type ShimPathFingerprint, type StableShimPathProbe } from "./shim-fingerprint"; + +interface FileIdentity { dev: bigint; ino: bigint } + +function entryIs(path: string, identity: FileIdentity): boolean { + try { + const entry = lstatSync(path, { bigint: true }); + return !entry.isSymbolicLink() && entry.isFile() && entry.dev === identity.dev && entry.ino === identity.ino; + } catch { + return false; + } +} + +/** Create `path` exclusively (never through an existing entry or link) and return the written file's identity. */ +function createExclusive(path: string, content: string): FileIdentity { + const flags = constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL + | (process.platform === "win32" ? 0 : constants.O_NOFOLLOW); + const fd = openSync(path, flags, 0o644); + try { + writeFileSync(fd, content, "utf8"); + const stat = fstatSync(fd, { bigint: true }); + return { dev: stat.dev, ino: stat.ino }; + } finally { + closeSync(fd); + } +} /** - * Replace an owned, obsolete Windows wrapper without ever truncating or replacing a file someone - * else published at the same path (an npm or version-manager install racing this refresh). + * Replace an owned, obsolete Windows wrapper without ever truncating, replacing or writing through + * a file someone else published (an npm or version-manager install racing this refresh, or a link + * planted beside the wrapper). * * 1. Move the observed wrapper aside and prove the moved file is the one that was probed; a * different file means a concurrent replacement, which is put back untouched. - * 2. Render the current wrapper into a staged sibling with the same extension. - * 3. Publish with a hard link, which fails instead of replacing when the path is occupied again. - * A concurrent publisher then keeps the path, and only our own obsolete copy is discarded. + * 2. Create the stage exclusively under an unpredictable name and record its identity. An existing + * entry, including a planted link, makes creation fail instead of being written through. + * 3. Publish with a hard link, which fails instead of replacing when the path is occupied again, + * and only while the stage is still the file this refresh created. * - * Returns true only when the current wrapper was published. Any filesystem that refuses hard links - * leaves the obsolete wrapper restored and the refresh deferred. + * Every cleanup is bound to identity: only the stage this refresh created and only the moved copy + * of the observed wrapper are ever unlinked. Returns true only when the current wrapper was + * published; a filesystem that refuses hard links leaves the obsolete wrapper restored and the + * refresh deferred. */ export function refreshObsoleteWindowsShim( wrapperPath: string, observed: StableShimPathProbe, - writeStaged: (stagedPath: string) => void, + render: () => string, + /** Test seam only: a fixed stage path, so a planted entry can be placed where the stage goes. */ + stagedPathForTests?: string, ): boolean { - const tag = randomUUID().slice(0, 8); const extension = extname(wrapperPath); - const aside = `${wrapperPath}.ocx-obsolete-${tag}${extension}`; - const staged = `${wrapperPath}.ocx-refresh-${tag}${extension}`; - const discard = (path: string): void => { try { unlinkSync(path); } catch { /* already gone */ } }; + const aside = `${wrapperPath}.ocx-obsolete-${randomUUID()}${extension}`; + const staged = stagedPathForTests ?? `${wrapperPath}.ocx-refresh-${randomUUID()}${extension}`; + let moved: ShimPathFingerprint | null = null; + const discardAside = (): void => { + const current = shimPathFingerprint(aside); + if (moved && current && sameFingerprint(current, moved)) { try { unlinkSync(aside); } catch { /* already gone */ } } + }; /** Put the moved file back only onto an empty path; an occupied path keeps its new owner. */ const restoreAside = (): void => { if (existsSync(wrapperPath)) return; - try { linkSync(aside, wrapperPath); discard(aside); } catch { /* the aside copy stays recoverable */ } + try { linkSync(aside, wrapperPath); } catch { return; /* the aside copy stays recoverable */ } + try { unlinkSync(aside); } catch { /* the restored link keeps the content */ } }; try { renameSync(wrapperPath, aside); } catch { return false; } - const moved = shimPathFingerprint(aside); + moved = shimPathFingerprint(aside); if (!moved || !sameFingerprintAfterRename(observed.fingerprint, moved)) { restoreAside(); return false; } + let created: FileIdentity; try { - writeStaged(staged); + created = createExclusive(staged, render()); } catch (error) { - discard(staged); restoreAside(); + if ((error as NodeJS.ErrnoException).code === "EEXIST" || (error as NodeJS.ErrnoException).code === "ELOOP") return false; throw error; } + const discardStaged = (): void => { + if (entryIs(staged, created)) { try { unlinkSync(staged); } catch { /* already gone */ } } + }; + if (!entryIs(staged, created)) { + restoreAside(); + return false; + } try { linkSync(staged, wrapperPath); } catch (error) { - discard(staged); - if ((error as NodeJS.ErrnoException).code === "EEXIST") discard(aside); + discardStaged(); + if ((error as NodeJS.ErrnoException).code === "EEXIST") discardAside(); else restoreAside(); return false; } - discard(staged); - discard(aside); + if (!entryIs(wrapperPath, created)) { + // The stage was swapped between the check and the link. Withdraw only the link this call made + // to that swapped file (it still shares the stage's identity); a later replacement of the + // wrapper path is someone else's and stays. + try { + const published = lstatSync(wrapperPath, { bigint: true }); + if (entryIs(staged, { dev: published.dev, ino: published.ino })) { + unlinkSync(wrapperPath); + restoreAside(); + } + } catch { /* nothing to withdraw */ } + return false; + } + discardStaged(); + discardAside(); return true; } diff --git a/src/codex/shim.ts b/src/codex/shim.ts index dc4a1091e8..6f05bcb71a 100644 --- a/src/codex/shim.ts +++ b/src/codex/shim.ts @@ -350,24 +350,22 @@ function rollbackFreshShimInstall(journal: readonly FreshShimInstallJournalEntry * updater's wrapper can carry them, and a replacement landing between the write * and the observation is otherwise indistinguishable from our own file. */ +/** The Windows wrapper for `wrapperPath`'s flavor (.ps1, .cmd/.bat, or extensionless Git-Bash sh). */ +function windowsShimContent(wrapperPath: string, realCodexPath: string, runtime?: DurableBunRuntime): string { + const { bun, bunRuntimeSource, cli } = cliEntry(runtime); + const lower = wrapperPath.toLowerCase(); + // UTF-8 BOM: Windows PowerShell 5.1 decodes BOM-less .ps1 files in the ANSI + // codepage, which mangles non-ASCII paths embedded in the shim. + if (lower.endsWith(".ps1")) return `\uFEFF${buildWindowsPowerShellCodexShim(realCodexPath, bun, cli, bunRuntimeSource)}`; + if (lower.endsWith(".cmd") || lower.endsWith(".bat")) return buildWindowsCodexShim(realCodexPath, bun, cli, bunRuntimeSource); + // Extensionless Git-Bash sh launcher: sh shim with forward-slash paths. + return buildUnixCodexShim(gitBashPath(realCodexPath), gitBashPath(bun), gitBashPath(cli), bunRuntimeSource, gitBashPath(serviceApiTokenFilePath())); +} + function writeShim(wrapperPath: string, realCodexPath: string, runtime?: DurableBunRuntime): { dev: number; ino: number } | undefined { const { bun, bunRuntimeSource, cli } = cliEntry(runtime); if (process.platform === "win32") { - const lower = wrapperPath.toLowerCase(); - if (lower.endsWith(".ps1")) { - // UTF-8 BOM: Windows PowerShell 5.1 decodes BOM-less .ps1 files in the ANSI - // codepage, which mangles non-ASCII paths embedded in the shim. - writeFileSync(wrapperPath, `\uFEFF${buildWindowsPowerShellCodexShim(realCodexPath, bun, cli, bunRuntimeSource)}`, "utf8"); - } else if (lower.endsWith(".cmd") || lower.endsWith(".bat")) { - writeFileSync(wrapperPath, buildWindowsCodexShim(realCodexPath, bun, cli, bunRuntimeSource), "utf8"); - } else { - // Extensionless Git-Bash sh launcher: sh shim with forward-slash paths. - writeFileSync( - wrapperPath, - buildUnixCodexShim(gitBashPath(realCodexPath), gitBashPath(bun), gitBashPath(cli), bunRuntimeSource, gitBashPath(serviceApiTokenFilePath())), - "utf8", - ); - } + writeFileSync(wrapperPath, windowsShimContent(wrapperPath, realCodexPath, runtime), "utf8"); return undefined; } else { // Stage the wrapper as its own inode and rename it into place, so ownership @@ -517,9 +515,7 @@ function refreshShimFile(file: ShimFileState, runtime: DurableBunRuntime): boole if (process.platform === "win32" && existsSync(file.wrapperPath) && isShim(file.wrapperPath) && hasUsableBackingPath(file)) { const probe = stableShimPathProbe(file.wrapperPath); if (probe && probe.prefix.includes(SHIM_MARKER) && !isCurrentShimProbe(probe, "win32")) { - return refreshObsoleteWindowsShim(file.wrapperPath, probe, staged => { - writeShim(staged, file.realPath ?? file.backupPath, runtime); - }); + return refreshObsoleteWindowsShim(file.wrapperPath, probe, () => windowsShimContent(file.wrapperPath, file.realPath ?? file.backupPath, runtime)); } } return false; @@ -1163,7 +1159,10 @@ export function autoRestoreCodexShim(options: RuntimePreflightDeps & { if (replacementProbes.size === 0 && obsoleteShimProbes.size === 0) return { status: "healthy" }; if (!options.enabled()) return { status: "disabled" }; - if (files.length > 1 && (healthyCount > 0 || (replacementProbes.size > 0 && obsoleteShimProbes.size > 0))) { + // Windows current + obsolete siblings (a partial revision upgrade) resume: only the obsolete + // wrappers are refreshed, so a transient publish failure cannot block recovery forever. + const revisionOnlyMix = state.platform === "win32" && replacementProbes.size === 0; + if (files.length > 1 && ((healthyCount > 0 && !revisionOnlyMix) || (replacementProbes.size > 0 && obsoleteShimProbes.size > 0))) { return { status: "deferred", message: "Codex shim auto-restore deferred because tracked launcher siblings are in a mixed shim/replacement state.", diff --git a/tests/codex-integration/codex-shim-windows-revision.test.ts b/tests/codex-integration/codex-shim-windows-revision.test.ts index c83d73fbd1..a17956a5fe 100644 --- a/tests/codex-integration/codex-shim-windows-revision.test.ts +++ b/tests/codex-integration/codex-shim-windows-revision.test.ts @@ -94,24 +94,24 @@ describe("obsolete Windows shims are upgraded", () => { describe("obsolete Windows refresh transaction never clobbers a concurrent replacement", () => { const leftovers = (wrapper: string) => fs.readdirSync(dir).filter(name => name.startsWith(`${basename(wrapper)}.ocx-`)); - test("publishes the staged wrapper and leaves no staging files", () => { + test("publishes the rendered wrapper and leaves no staging files", () => { const wrapper = join(dir, "codex.cmd"); fs.writeFileSync(wrapper, "@echo off\r\nrem obsolete\r\n"); const observed = stableShimPathProbe(wrapper)!; - expect(refreshObsoleteWindowsShim(wrapper, observed, staged => fs.writeFileSync(staged, "current wrapper"))).toBe(true); + expect(refreshObsoleteWindowsShim(wrapper, observed, () => "current wrapper")).toBe(true); expect(fs.readFileSync(wrapper, "utf8")).toBe("current wrapper"); expect(leftovers(wrapper)).toEqual([]); }); - test("a replacement published after the probe is put back untouched and nothing is written", () => { + test("a replacement published after the probe is put back untouched and nothing is rendered", () => { const wrapper = join(dir, "codex.cmd"); fs.writeFileSync(wrapper, "@echo off\r\nrem obsolete\r\n"); const observed = stableShimPathProbe(wrapper)!; fs.unlinkSync(wrapper); fs.writeFileSync(wrapper, "replacement native launcher from a concurrent installer"); - let wrote = false; - expect(refreshObsoleteWindowsShim(wrapper, observed, () => { wrote = true; })).toBe(false); - expect(wrote).toBe(false); + let rendered = false; + expect(refreshObsoleteWindowsShim(wrapper, observed, () => { rendered = true; return "current wrapper"; })).toBe(false); + expect(rendered).toBe(false); expect(fs.readFileSync(wrapper, "utf8")).toBe("replacement native launcher from a concurrent installer"); expect(leftovers(wrapper)).toEqual([]); }); @@ -120,11 +120,38 @@ describe("obsolete Windows refresh transaction never clobbers a concurrent repla const wrapper = join(dir, "codex.ps1"); fs.writeFileSync(wrapper, "# obsolete"); const observed = stableShimPathProbe(wrapper)!; - expect(refreshObsoleteWindowsShim(wrapper, observed, staged => { - fs.writeFileSync(staged, "current wrapper"); + expect(refreshObsoleteWindowsShim(wrapper, observed, () => { fs.writeFileSync(wrapper, "replacement published mid-refresh"); + return "current wrapper"; })).toBe(false); expect(fs.readFileSync(wrapper, "utf8")).toBe("replacement published mid-refresh"); expect(leftovers(wrapper)).toEqual([]); }); + + test.skipIf(process.platform === "win32")("a link planted at the stage path is never written through", () => { + const wrapper = join(dir, "codex.cmd"); + fs.writeFileSync(wrapper, "@echo off\r\nrem obsolete\r\n"); + const victim = join(dir, "unrelated-launcher"); + fs.writeFileSync(victim, "unrelated launcher bytes"); + const staged = `${wrapper}.ocx-refresh-planted.cmd`; + fs.symlinkSync(victim, staged); + const observed = stableShimPathProbe(wrapper)!; + expect(refreshObsoleteWindowsShim(wrapper, observed, () => "current wrapper", staged)).toBe(false); + expect(fs.readFileSync(victim, "utf8")).toBe("unrelated launcher bytes"); + expect(fs.lstatSync(staged).isSymbolicLink()).toBe(true); + expect(fs.readFileSync(wrapper, "utf8")).toBe("@echo off\r\nrem obsolete\r\n"); + }); + + test("a partial sibling upgrade resumes automatically instead of deferring forever", () => { + const cmd = join(dir, "codex.cmd"), ps1 = join(dir, "codex.ps1"); + const wrappers = [cmd, ps1].map(wrapperPath => ({ originalPath: wrapperPath, wrapperPath, backupPath: `${wrapperPath}.opencodex-real` })); + for (const file of wrappers) fs.writeFileSync(file.backupPath, "native fixture launcher"); + fs.writeFileSync(cmd, buildWindowsCodexShim(wrappers[0]!.backupPath, "C:\\bun.exe", "C:\\cli.ts", "process")); + fs.writeFileSync(ps1, previousTemplate("ps1", wrappers[1]!.backupPath)); + fs.writeFileSync(join(dir, "codex-shim.json"), JSON.stringify({ platform: "win32", ...wrappers[0], wrappers })); + Object.defineProperty(process, "platform", { ...platform, value: "win32" }); + expect(autoRestoreCodexShim({ enabled: () => true, stabilitySleep: () => {} }).status).toBe("restored"); + expectCurrent("ps1", ps1, wrappers[1]!.backupPath); + expect(autoRestoreCodexShim({ enabled: () => true, stabilitySleep: () => {} }).status).toBe("healthy"); + }); }); From 9492d61d5e78a481621ea9e2f53baf0587bd3fbe Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 20 Oct 2026 06:38:56 +0900 Subject: [PATCH 21/22] fix(codex): bind Windows shim refresh rollback and recovery to observed identities Publication keeps a linked entry only if it is the created stage, and withdraws a link to a swapped stage (file or symlink) while leaving independent replacements. Restoring the moved wrapper requires the aside to still be the moved file. Automatic revision recovery now refreshes only the admitted obsolete wrappers and first re-verifies every current sibling, so a replacement during the stability wait is never adopted. --- src/codex/shim-windows-refresh.ts | 146 ++++++++++++------ src/codex/shim.ts | 13 +- .../codex-shim-windows-revision.test.ts | 70 ++++++++- 3 files changed, 171 insertions(+), 58 deletions(-) diff --git a/src/codex/shim-windows-refresh.ts b/src/codex/shim-windows-refresh.ts index 89b355b1c6..351c783e40 100644 --- a/src/codex/shim-windows-refresh.ts +++ b/src/codex/shim-windows-refresh.ts @@ -1,19 +1,39 @@ import { randomUUID } from "node:crypto"; -import { closeSync, constants, existsSync, fstatSync, linkSync, lstatSync, openSync, renameSync, unlinkSync, writeFileSync } from "node:fs"; +import { closeSync, constants, existsSync, fstatSync, linkSync, lstatSync, openSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs"; import { extname } from "node:path"; -import { sameFingerprint, sameFingerprintAfterRename, shimPathFingerprint, type ShimPathFingerprint, type StableShimPathProbe } from "./shim-fingerprint"; +import { sameFingerprintAfterRename, sameStableShimPathProbe, shimPathFingerprint, stableShimPathProbe, type StableShimPathProbe } from "./shim-fingerprint"; interface FileIdentity { dev: bigint; ino: bigint } -function entryIs(path: string, identity: FileIdentity): boolean { +/** Identity of the directory entry itself (a link is its own entry); null when absent. */ +function entryIdentity(path: string): (FileIdentity & { link: boolean }) | null { try { const entry = lstatSync(path, { bigint: true }); - return !entry.isSymbolicLink() && entry.isFile() && entry.dev === identity.dev && entry.ino === identity.ino; + return { dev: entry.dev, ino: entry.ino, link: entry.isSymbolicLink() }; } catch { - return false; + return null; + } +} + +/** Identity of what a path resolves to; null when absent or dangling. */ +function targetIdentity(path: string): FileIdentity | null { + try { + const target = statSync(path, { bigint: true }); + return { dev: target.dev, ino: target.ino }; + } catch { + return null; } } +const same = (left: FileIdentity | null, right: FileIdentity | null): boolean => + !!left && !!right && left.dev === right.dev && left.ino === right.ino; + +/** A regular, non-link entry at `path` that is exactly `identity`. */ +function entryIs(path: string, identity: FileIdentity): boolean { + const entry = entryIdentity(path); + return !!entry && !entry.link && same(entry, identity); +} + /** Create `path` exclusively (never through an existing entry or link) and return the written file's identity. */ function createExclusive(path: string, content: string): FileIdentity { const flags = constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL @@ -28,22 +48,40 @@ function createExclusive(path: string, content: string): FileIdentity { } } +/** + * Hard-link `source` onto the empty `destination` and keep the result only if the published entry + * is exactly `expected`. If `source` was swapped (for another file or a link) between the caller's + * check and the link, the entry this call created is withdrawn; an entry that matches neither the + * source nor its target belongs to an independent later publisher and stays. + */ +function linkExpected(source: string, destination: string, expected: FileIdentity): "published" | "occupied" | "refused" { + try { + linkSync(source, destination); + } catch (error) { + return (error as NodeJS.ErrnoException).code === "EEXIST" ? "occupied" : "refused"; + } + const published = entryIdentity(destination); + if (published && same(published, expected)) return "published"; + if (published && (same(published, entryIdentity(source)) || same(published, targetIdentity(source)))) { + try { unlinkSync(destination); } catch { /* nothing to withdraw */ } + } + return "refused"; +} + /** * Replace an owned, obsolete Windows wrapper without ever truncating, replacing or writing through - * a file someone else published (an npm or version-manager install racing this refresh, or a link + * a file someone else published (an npm or version-manager install racing this refresh, or an entry * planted beside the wrapper). * * 1. Move the observed wrapper aside and prove the moved file is the one that was probed; a * different file means a concurrent replacement, which is put back untouched. - * 2. Create the stage exclusively under an unpredictable name and record its identity. An existing - * entry, including a planted link, makes creation fail instead of being written through. - * 3. Publish with a hard link, which fails instead of replacing when the path is occupied again, - * and only while the stage is still the file this refresh created. + * 2. Create the stage exclusively under an unpredictable name and record its identity. + * 3. Publish by hard link onto the empty path, keeping the result only if it is that stage file. * - * Every cleanup is bound to identity: only the stage this refresh created and only the moved copy - * of the observed wrapper are ever unlinked. Returns true only when the current wrapper was - * published; a filesystem that refuses hard links leaves the obsolete wrapper restored and the - * refresh deferred. + * Every restore, withdrawal and cleanup is bound to identity: only the stage this refresh created + * and only the moved copy of the observed wrapper are ever published or unlinked. Returns true only + * when the current wrapper was published; a filesystem that refuses hard links leaves the obsolete + * wrapper restored and the refresh deferred. */ export function refreshObsoleteWindowsShim( wrapperPath: string, @@ -55,61 +93,67 @@ export function refreshObsoleteWindowsShim( const extension = extname(wrapperPath); const aside = `${wrapperPath}.ocx-obsolete-${randomUUID()}${extension}`; const staged = stagedPathForTests ?? `${wrapperPath}.ocx-refresh-${randomUUID()}${extension}`; - let moved: ShimPathFingerprint | null = null; - const discardAside = (): void => { - const current = shimPathFingerprint(aside); - if (moved && current && sameFingerprint(current, moved)) { try { unlinkSync(aside); } catch { /* already gone */ } } + let moved: FileIdentity | null = null; + const discard = (path: string, identity: FileIdentity | null): void => { + if (identity && entryIs(path, identity)) { try { unlinkSync(path); } catch { /* already gone */ } } }; - /** Put the moved file back only onto an empty path; an occupied path keeps its new owner. */ + /** Put the moved wrapper back only onto an empty path, and only while the aside is still it. */ const restoreAside = (): void => { - if (existsSync(wrapperPath)) return; - try { linkSync(aside, wrapperPath); } catch { return; /* the aside copy stays recoverable */ } - try { unlinkSync(aside); } catch { /* the restored link keeps the content */ } + if (!moved || existsSync(wrapperPath) || !entryIs(aside, moved)) return; + if (linkExpected(aside, wrapperPath, moved) === "published") discard(aside, moved); }; try { renameSync(wrapperPath, aside); } catch { return false; } - moved = shimPathFingerprint(aside); - if (!moved || !sameFingerprintAfterRename(observed.fingerprint, moved)) { - restoreAside(); + const movedFingerprint = shimPathFingerprint(aside); + const movedEntry = entryIdentity(aside); + if (!movedFingerprint || !movedEntry || movedEntry.link || !sameFingerprintAfterRename(observed.fingerprint, movedFingerprint)) { + // Not the observed wrapper: hand the path back to whatever was moved, untouched. + if (movedEntry && !existsSync(wrapperPath)) { + if (linkExpected(aside, wrapperPath, movedEntry) === "published") discard(aside, movedEntry); + } return false; } + moved = movedEntry; let created: FileIdentity; try { created = createExclusive(staged, render()); } catch (error) { restoreAside(); - if ((error as NodeJS.ErrnoException).code === "EEXIST" || (error as NodeJS.ErrnoException).code === "ELOOP") return false; + const code = (error as NodeJS.ErrnoException).code; + if (code === "EEXIST" || code === "ELOOP") return false; throw error; } - const discardStaged = (): void => { - if (entryIs(staged, created)) { try { unlinkSync(staged); } catch { /* already gone */ } } - }; if (!entryIs(staged, created)) { restoreAside(); return false; } - try { - linkSync(staged, wrapperPath); - } catch (error) { - discardStaged(); - if ((error as NodeJS.ErrnoException).code === "EEXIST") discardAside(); - else restoreAside(); - return false; + const outcome = linkExpected(staged, wrapperPath, created); + discard(staged, created); + if (outcome === "published") { + discard(aside, moved); + return true; } - if (!entryIs(wrapperPath, created)) { - // The stage was swapped between the check and the link. Withdraw only the link this call made - // to that swapped file (it still shares the stage's identity); a later replacement of the - // wrapper path is someone else's and stays. - try { - const published = lstatSync(wrapperPath, { bigint: true }); - if (entryIs(staged, { dev: published.dev, ino: published.ino })) { - unlinkSync(wrapperPath); - restoreAside(); - } - } catch { /* nothing to withdraw */ } - return false; + // Occupied: a concurrent publisher owns the path now; only our obsolete copy is discarded. + if (outcome === "occupied") discard(aside, moved); + else restoreAside(); + return false; +} + +/** + * Automatic Windows revision recovery: refresh exactly the admitted obsolete wrappers, and only + * while every current sibling is still the file observed before the stability wait. It never + * adopts a replacement launcher or rewrites a backup; anything unexpected defers. + */ +export function refreshAdmittedWindowsShims( + obsolete: ReadonlyMap, + current: ReadonlyMap, + render: (wrapperPath: string) => string, +): boolean { + for (const [path, first] of current) { + const second = stableShimPathProbe(path); + if (!second || !sameStableShimPathProbe(first, second)) return false; } - discardStaged(); - discardAside(); - return true; + let all = true; + for (const [path, observed] of obsolete) all = refreshObsoleteWindowsShim(path, observed, () => render(path)) && all; + return all; } diff --git a/src/codex/shim.ts b/src/codex/shim.ts index 6f05bcb71a..60c9d672f1 100644 --- a/src/codex/shim.ts +++ b/src/codex/shim.ts @@ -56,7 +56,7 @@ import { type UnixShimProbeResult, } from "./shim-probe"; import { tryAcquireShimRestoreLock } from "./shim-restore-lock"; -import { refreshObsoleteWindowsShim } from "./shim-windows-refresh"; +import { refreshAdmittedWindowsShims, refreshObsoleteWindowsShim } from "./shim-windows-refresh"; import { isWindowsInteropDir, isExecutableCodexCandidate, @@ -1122,7 +1122,7 @@ export function autoRestoreCodexShim(options: RuntimePreflightDeps & { const replacementProbes = new Map(); const obsoleteShimProbes = new Map(); const seen = new Set(); - let healthyCount = 0; + const currentShimProbes = new Map(); for (const file of files) { if (seen.has(file.wrapperPath)) return { status: "ineligible" }; seen.add(file.wrapperPath); @@ -1145,7 +1145,7 @@ export function autoRestoreCodexShim(options: RuntimePreflightDeps & { obsoleteShimProbes.set(file.wrapperPath, probe); continue; } - healthyCount += 1; + currentShimProbes.set(file.wrapperPath, probe); continue; } // A surviving backup would otherwise let the replacement path below wrap the @@ -1162,7 +1162,7 @@ export function autoRestoreCodexShim(options: RuntimePreflightDeps & { // Windows current + obsolete siblings (a partial revision upgrade) resume: only the obsolete // wrappers are refreshed, so a transient publish failure cannot block recovery forever. const revisionOnlyMix = state.platform === "win32" && replacementProbes.size === 0; - if (files.length > 1 && ((healthyCount > 0 && !revisionOnlyMix) || (replacementProbes.size > 0 && obsoleteShimProbes.size > 0))) { + if (files.length > 1 && ((currentShimProbes.size > 0 && !revisionOnlyMix) || (replacementProbes.size > 0 && obsoleteShimProbes.size > 0))) { return { status: "deferred", message: "Codex shim auto-restore deferred because tracked launcher siblings are in a mixed shim/replacement state.", @@ -1189,8 +1189,9 @@ export function autoRestoreCodexShim(options: RuntimePreflightDeps & { || !sameStableShimPathProbe(firstProbe, secondProbe)) return { status: "deferred" }; } if (state.platform === "win32") { - const refreshed = installCodexShimInternal({ runtime, runtimeAdmitted: true, allowFreshInstall: false }); - return refreshed.installed ? { status: "restored", message: refreshed.message } : { status: "deferred" }; + const realFor = (path: string) => { const file = files.find(entry => entry.wrapperPath === path)!; return file.realPath ?? file.backupPath; }; + return refreshAdmittedWindowsShims(obsoleteShimProbes, currentShimProbes, path => windowsShimContent(path, realFor(path), runtime)) + ? { status: "restored", message: `Upgraded Codex autostart shim at ${[...obsoleteShimProbes.keys()].join(", ")}.` } : { status: "deferred" }; } const result = refreshObsoleteUnixShims(files); return result.installed diff --git a/tests/codex-integration/codex-shim-windows-revision.test.ts b/tests/codex-integration/codex-shim-windows-revision.test.ts index a17956a5fe..8d13d39758 100644 --- a/tests/codex-integration/codex-shim-windows-revision.test.ts +++ b/tests/codex-integration/codex-shim-windows-revision.test.ts @@ -5,7 +5,7 @@ * backing launcher, never report them healthy or "already installed". No Windows shell runs here: * process.platform is overridden and the wrappers are only written and read. */ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import * as fs from "node:fs"; import { tmpdir } from "node:os"; import { basename, join } from "node:path"; @@ -155,3 +155,71 @@ describe("obsolete Windows refresh transaction never clobbers a concurrent repla expect(autoRestoreCodexShim({ enabled: () => true, stabilitySleep: () => {} }).status).toBe("healthy"); }); }); + +describe("round-4 identity bindings", () => { + const obsoleteCmd = "@echo off\r\nrem obsolete\r\n"; + + test.skipIf(process.platform === "win32")("a stage swapped for a link just before publication is withdrawn", () => { + const wrapper = join(dir, "codex.cmd"), victim = join(dir, "unrelated-launcher.cmd"); + fs.writeFileSync(wrapper, obsoleteCmd); + fs.writeFileSync(victim, "unrelated launcher bytes"); + const observed = stableShimPathProbe(wrapper)!; + const link = fs.linkSync; + let swapped = false; + const spy = spyOn(fs, "linkSync").mockImplementation(((source: fs.PathLike, destination: fs.PathLike) => { + if (!swapped && String(source).includes(".ocx-refresh-")) { + fs.unlinkSync(source); fs.symlinkSync(victim, source); swapped = true; + } + return link(source, destination); + }) as typeof fs.linkSync); + try { + expect(refreshObsoleteWindowsShim(wrapper, observed, () => "current wrapper")).toBe(false); + } finally { spy.mockRestore(); } + expect(swapped).toBe(true); + expect(fs.readFileSync(victim, "utf8")).toBe("unrelated launcher bytes"); + expect(fs.lstatSync(wrapper).isSymbolicLink()).toBe(false); + expect(fs.statSync(wrapper).ino).not.toBe(fs.statSync(victim).ino); + expect(fs.readFileSync(wrapper, "utf8")).toBe(obsoleteCmd); + }); + + test("an aside substituted before a failed publication is never restored as the launcher", () => { + const wrapper = join(dir, "codex.cmd"); + fs.writeFileSync(wrapper, obsoleteCmd); + const observed = stableShimPathProbe(wrapper)!; + const link = fs.linkSync; + let substituted = ""; + const spy = spyOn(fs, "linkSync").mockImplementation(((source: fs.PathLike, destination: fs.PathLike) => { + if (!substituted && String(source).includes(".ocx-refresh-")) { + substituted = fs.readdirSync(dir).find(name => name.includes(".ocx-obsolete-"))!; + fs.unlinkSync(join(dir, substituted)); + fs.writeFileSync(join(dir, substituted), "foreign aside bytes"); + throw Object.assign(new Error("transient sharing failure"), { code: "EACCES" }); + } + return link(source, destination); + }) as typeof fs.linkSync); + try { + expect(refreshObsoleteWindowsShim(wrapper, observed, () => "current wrapper")).toBe(false); + } finally { spy.mockRestore(); } + expect(substituted).not.toBe(""); + expect(fs.existsSync(wrapper)).toBe(false); + expect(fs.readFileSync(join(dir, substituted), "utf8")).toBe("foreign aside bytes"); + }); + + test("a current sibling replaced during the stability wait defers without adopting it", () => { + const cmd = join(dir, "codex.cmd"), ps1 = join(dir, "codex.ps1"); + const wrappers = [cmd, ps1].map(wrapperPath => ({ originalPath: wrapperPath, wrapperPath, backupPath: `${wrapperPath}.opencodex-real` })); + for (const file of wrappers) fs.writeFileSync(file.backupPath, "native fixture launcher"); + fs.writeFileSync(cmd, buildWindowsCodexShim(wrappers[0]!.backupPath, "C:\\bun.exe", "C:\\cli.ts", "process")); + const obsolete = previousTemplate("ps1", wrappers[1]!.backupPath); + fs.writeFileSync(ps1, obsolete); + fs.writeFileSync(join(dir, "codex-shim.json"), JSON.stringify({ platform: "win32", ...wrappers[0], wrappers })); + Object.defineProperty(process, "platform", { ...platform, value: "win32" }); + expect(autoRestoreCodexShim({ enabled: () => true, stabilitySleep: () => { + fs.unlinkSync(cmd); + fs.writeFileSync(cmd, "replacement native launcher"); + } }).status).toBe("deferred"); + expect(fs.readFileSync(cmd, "utf8")).toBe("replacement native launcher"); + expect(fs.readFileSync(wrappers[0]!.backupPath, "utf8")).toBe("native fixture launcher"); + expect(fs.readFileSync(ps1, "utf8")).toBe(obsolete); + }); +}); From b1ffd89c6367de6104d1a8a7546006d2050c1c19 Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 20 Oct 2026 06:43:37 +0900 Subject: [PATCH 22/22] fix(codex): never displace a Windows shim where its replacement cannot be published The refresh proves hard-link support in the wrapper directory with a throwaway pair before moving the wrapper aside, and if a restore link still fails it falls back to an identity-checked rename onto the empty path, so the launcher never stays missing. --- src/codex/shim-windows-refresh.ts | 29 +++++++++++++++-- .../codex-shim-windows-revision.test.ts | 32 +++++++++++++++++++ 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/src/codex/shim-windows-refresh.ts b/src/codex/shim-windows-refresh.ts index 351c783e40..e80eedd0f8 100644 --- a/src/codex/shim-windows-refresh.ts +++ b/src/codex/shim-windows-refresh.ts @@ -1,6 +1,6 @@ import { randomUUID } from "node:crypto"; import { closeSync, constants, existsSync, fstatSync, linkSync, lstatSync, openSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs"; -import { extname } from "node:path"; +import { dirname, extname, join } from "node:path"; import { sameFingerprintAfterRename, sameStableShimPathProbe, shimPathFingerprint, stableShimPathProbe, type StableShimPathProbe } from "./shim-fingerprint"; interface FileIdentity { dev: bigint; ino: bigint } @@ -68,6 +68,22 @@ function linkExpected(source: string, destination: string, expected: FileIdentit return "refused"; } +/** Whether `directory` supports hard links, proven with a throwaway pair before anything is displaced. */ +function hardLinksWork(directory: string): boolean { + const probe = join(directory, `.ocx-link-probe-${randomUUID()}`); + const linked = `${probe}-linked`; + let identity: FileIdentity; + try { identity = createExclusive(probe, ""); } catch { return false; } + try { + linkSync(probe, linked); + return entryIs(linked, identity); + } catch { + return false; + } finally { + for (const path of [linked, probe]) if (entryIs(path, identity)) { try { unlinkSync(path); } catch { /* best effort */ } } + } +} + /** * Replace an owned, obsolete Windows wrapper without ever truncating, replacing or writing through * a file someone else published (an npm or version-manager install racing this refresh, or an entry @@ -97,12 +113,19 @@ export function refreshObsoleteWindowsShim( const discard = (path: string, identity: FileIdentity | null): void => { if (identity && entryIs(path, identity)) { try { unlinkSync(path); } catch { /* already gone */ } } }; - /** Put the moved wrapper back only onto an empty path, and only while the aside is still it. */ + /** + * Put the moved wrapper back only onto an empty path, and only while the aside is still it. If the + * link fails (hard links became unavailable mid-refresh), an identity-checked rename keeps the + * launcher from staying missing. + */ const restoreAside = (): void => { if (!moved || existsSync(wrapperPath) || !entryIs(aside, moved)) return; - if (linkExpected(aside, wrapperPath, moved) === "published") discard(aside, moved); + if (linkExpected(aside, wrapperPath, moved) === "published") { discard(aside, moved); return; } + if (!existsSync(wrapperPath) && entryIs(aside, moved)) { try { renameSync(aside, wrapperPath); } catch { /* the aside copy stays recoverable */ } } }; + // Never displace a working wrapper on a filesystem that cannot publish its replacement. + if (!hardLinksWork(dirname(wrapperPath))) return false; try { renameSync(wrapperPath, aside); } catch { return false; } const movedFingerprint = shimPathFingerprint(aside); const movedEntry = entryIdentity(aside); diff --git a/tests/codex-integration/codex-shim-windows-revision.test.ts b/tests/codex-integration/codex-shim-windows-revision.test.ts index 8d13d39758..8b07b3d5d1 100644 --- a/tests/codex-integration/codex-shim-windows-revision.test.ts +++ b/tests/codex-integration/codex-shim-windows-revision.test.ts @@ -223,3 +223,35 @@ describe("round-4 identity bindings", () => { expect(fs.readFileSync(ps1, "utf8")).toBe(obsolete); }); }); + +describe("round-5 availability", () => { + test("no hard-link support leaves the wrapper in place and untouched", () => { + const wrapper = join(dir, "codex.cmd"); + fs.writeFileSync(wrapper, "@echo off\r\nrem obsolete\r\n"); + const before = fs.statSync(wrapper, { bigint: true }).ino; + const observed = stableShimPathProbe(wrapper)!; + const spy = spyOn(fs, "linkSync").mockImplementation((() => { throw Object.assign(new Error("no links"), { code: "EPERM" }); }) as typeof fs.linkSync); + try { + expect(refreshObsoleteWindowsShim(wrapper, observed, () => "current wrapper")).toBe(false); + } finally { spy.mockRestore(); } + expect(fs.statSync(wrapper, { bigint: true }).ino).toBe(before); + expect(fs.readdirSync(dir).filter(name => name.includes(".ocx-"))).toEqual([]); + }); + + test("links failing after displacement still restore the launcher", () => { + const wrapper = join(dir, "codex.cmd"); + const obsolete = "@echo off\r\nrem obsolete\r\n"; + fs.writeFileSync(wrapper, obsolete); + const observed = stableShimPathProbe(wrapper)!; + const link = fs.linkSync; + const spy = spyOn(fs, "linkSync").mockImplementation(((source: fs.PathLike, destination: fs.PathLike) => { + if (String(source).includes(".ocx-link-probe-")) return link(source, destination); + throw Object.assign(new Error("links became unavailable"), { code: "EPERM" }); + }) as typeof fs.linkSync); + try { + expect(refreshObsoleteWindowsShim(wrapper, observed, () => "current wrapper")).toBe(false); + } finally { spy.mockRestore(); } + expect(fs.readFileSync(wrapper, "utf8")).toBe(obsolete); + expect(fs.readdirSync(dir).filter(name => name.includes(".ocx-"))).toEqual([]); + }); +});