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/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/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index c5176ae844..fd71559980 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 @@ -679,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 9c7bec47c0..7d14a8507b 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", @@ -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", @@ -711,6 +712,8 @@ "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", @@ -1410,6 +1413,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", @@ -1690,6 +1694,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/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 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/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/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/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 82d67869e2..6815201ffc 100644 --- a/src/codex/shim-templates.ts +++ b/src/codex/shim-templates.ts @@ -5,7 +5,14 @@ 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"; +// 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."; @@ -122,7 +129,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 @@ -190,13 +199,14 @@ 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 ${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 @@ -240,10 +250,11 @@ 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 { -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}) @@ -294,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-windows-refresh.ts b/src/codex/shim-windows-refresh.ts new file mode 100644 index 0000000000..e80eedd0f8 --- /dev/null +++ b/src/codex/shim-windows-refresh.ts @@ -0,0 +1,182 @@ +import { randomUUID } from "node:crypto"; +import { closeSync, constants, existsSync, fstatSync, linkSync, lstatSync, openSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs"; +import { dirname, extname, join } from "node:path"; +import { sameFingerprintAfterRename, sameStableShimPathProbe, shimPathFingerprint, stableShimPathProbe, type StableShimPathProbe } from "./shim-fingerprint"; + +interface FileIdentity { dev: bigint; ino: bigint } + +/** 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 { dev: entry.dev, ino: entry.ino, link: entry.isSymbolicLink() }; + } catch { + 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 + | (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); + } +} + +/** + * 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"; +} + +/** 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 + * 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. + * 3. Publish by hard link onto the empty path, keeping the result only if it is that stage file. + * + * 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, + observed: StableShimPathProbe, + render: () => string, + /** Test seam only: a fixed stage path, so a planted entry can be placed where the stage goes. */ + stagedPathForTests?: string, +): boolean { + const extension = extname(wrapperPath); + const aside = `${wrapperPath}.ocx-obsolete-${randomUUID()}${extension}`; + const staged = stagedPathForTests ?? `${wrapperPath}.ocx-refresh-${randomUUID()}${extension}`; + 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 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); 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); + 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(); + const code = (error as NodeJS.ErrnoException).code; + if (code === "EEXIST" || code === "ELOOP") return false; + throw error; + } + if (!entryIs(staged, created)) { + restoreAside(); + return false; + } + const outcome = linkExpected(staged, wrapperPath, created); + discard(staged, created); + if (outcome === "published") { + discard(aside, moved); + return true; + } + // 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; + } + 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 b3f999466b..60c9d672f1 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, @@ -55,6 +56,7 @@ import { type UnixShimProbeResult, } from "./shim-probe"; import { tryAcquireShimRestoreLock } from "./shim-restore-lock"; +import { refreshAdmittedWindowsShims, refreshObsoleteWindowsShim } from "./shim-windows-refresh"; import { isWindowsInteropDir, isExecutableCodexCandidate, @@ -348,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 @@ -509,6 +509,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 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")) { + return refreshObsoleteWindowsShim(file.wrapperPath, probe, () => windowsShimContent(file.wrapperPath, file.realPath ?? file.backupPath, runtime)); + } + } return false; } @@ -884,7 +893,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." }; } @@ -934,6 +944,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) @@ -1106,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); @@ -1125,11 +1141,11 @@ 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; } - healthyCount += 1; + currentShimProbes.set(file.wrapperPath, probe); continue; } // A surviving backup would otherwise let the replacement path below wrap the @@ -1143,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 && ((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.", @@ -1166,9 +1185,14 @@ 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 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 ? { status: "restored", message: result.message } diff --git a/src/lib/service-secrets.ts b/src/lib/service-secrets.ts index 97ffe111dc..9717bf4e34 100644 --- a/src/lib/service-secrets.ts +++ b/src/lib/service-secrets.ts @@ -268,19 +268,54 @@ 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; + // 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, { 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) { + 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/src/oauth/account-import/google-antigravity-adapter.ts b/src/oauth/account-import/google-antigravity-adapter.ts index 4c62e4c022..83eab11144 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, signal)).kind === "available" && !signal?.aborted; + } 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/structure/providers/claude-cli-replay.md b/structure/providers/claude-cli-replay.md index 1e322099ac..1bbdaf5f22 100644 --- a/structure/providers/claude-cli-replay.md +++ b/structure/providers/claude-cli-replay.md @@ -12,6 +12,8 @@ Claude Code controls actual cache breakpoints, so stable replay alone is not a m ## Usage admission +The shared runner's `onCliOutputError` callback carries accepted CLI error-result text or captured stderr on unsuccessful completion, after redaction. Claude records usage refusals only from that callback; local setup, spawn, stream, replay, and bridge-validation failures retain their client error semantics without recording a subscription reset. Login guidance is applied separately to the emitted client event. + `src/adapters/claude-cli/usage-admission.ts` gates every `claude-cli` launch on the local Claude Code subscription quota. The adapter checks it before spawning the CLI, and the Responses executor checks it before dispatch so an exhausted plan returns 429 `claude_subscription_cooldown` with `Retry-After` instead of a generic 502; Chat Completions keeps that code. Admission and cache invalidation share one window selection (plan windows plus custom windows in scope for the requested model family) and one `>= 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/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/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/structure/runtime.md b/structure/runtime.md index 3585af3cc6..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. 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 @@ -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/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/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/claude-integration/claude-desktop-picker-profile.test.ts b/tests/claude-integration/claude-desktop-picker-profile.test.ts index 99b317e967..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,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, 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"; 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,97 @@ 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) => { + // 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 => { + 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/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 }); diff --git a/tests/clients/desktop-3p-removal.test.ts b/tests/clients/desktop-3p-removal.test.ts index 0168e60737..2b008dfe3b 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,265 @@ 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. +// 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); + 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) { + 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; + }); + 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(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); 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 = { 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/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 }); 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..8b07b3d5d1 --- /dev/null +++ b/tests/codex-integration/codex-shim-windows-revision.test.ts @@ -0,0 +1,257 @@ +/** + * 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, spyOn, test } from "bun:test"; +import * as fs from "node:fs"; +import { tmpdir } from "node:os"; +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"; + +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"); + }); + } +}); + +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 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, () => "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 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 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([]); + }); + + 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, () => { + 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"); + }); +}); + +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); + }); +}); + +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([]); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index f6c4ff57e1..41ddbc7f7f 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", @@ -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", @@ -566,6 +567,8 @@ "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-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", @@ -997,6 +1000,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", @@ -1160,6 +1164,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/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", () => { 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", 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/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); + }); }); 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", () => { 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/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); + }); +}); 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]; 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..e5b8056cda --- /dev/null +++ b/tests/server/account-import-project-fallback.test.ts @@ -0,0 +1,145 @@ +/** + * #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, 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"; +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([]); + }); +}); + +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); } + }); +}); 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-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(); + }); +}); 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 () => {