@bitkyc08/opencodex 2.68.0 → 2.69.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (175) hide show
  1. package/bin/ocx.mjs +25 -17
  2. package/gui/dist/assets/App-DDbgOTh6.js +51 -0
  3. package/gui/dist/assets/{Tray-Bn8WhKBH.js → Tray-uqKRqfvw.js} +1 -1
  4. package/gui/dist/assets/index-BF9QnKO_.css +1 -0
  5. package/gui/dist/assets/index-CuaBJkG4.js +86 -0
  6. package/gui/dist/assets/{quota-summary-DL-bNuNL.js → quota-summary-COppQSVq.js} +1 -1
  7. package/gui/dist/index.html +2 -2
  8. package/gui/dist/provider-icons/factory-droid.svg +8 -0
  9. package/package.json +1 -1
  10. package/src/adapters/anthropic-output-schema.ts +109 -11
  11. package/src/adapters/coding-agent/protocol.ts +106 -11
  12. package/src/adapters/coding-agent/turn.ts +23 -18
  13. package/src/adapters/google-errors.ts +16 -1
  14. package/src/adapters/google-http.ts +14 -0
  15. package/src/adapters/openai-chat/passthrough.ts +2 -0
  16. package/src/adapters/openai-chat/summary-budget.ts +72 -0
  17. package/src/adapters/openai-chat/tool-call-id-remint.ts +11 -2
  18. package/src/adapters/openai-chat.ts +6 -9
  19. package/src/bridge/response-json.ts +3 -1
  20. package/src/bridge/sse.ts +3 -1
  21. package/src/claude/desktop-picker.ts +6 -2
  22. package/src/claude/desktop-system-proxy.ts +155 -0
  23. package/src/claude/inbound-model-options.ts +10 -2
  24. package/src/claude/inbound.ts +1 -1
  25. package/src/claude/intercept/picker-ca-cleanup.ts +31 -0
  26. package/src/claude/intercept/picker-ca.ts +283 -83
  27. package/src/claude/intercept/picker-models.ts +4 -1
  28. package/src/claude/intercept/picker-trust.ts +15 -2
  29. package/src/claude/intercept/runtime.ts +99 -32
  30. package/src/cli/account-api.ts +5 -2
  31. package/src/cli/account-extended.ts +47 -11
  32. package/src/cli/account-target.ts +12 -7
  33. package/src/cli/account.ts +47 -3
  34. package/src/cli/capabilities.ts +33 -7
  35. package/src/cli/claude-agent-startup-sync.ts +2 -0
  36. package/src/cli/claude-desktop.ts +37 -13
  37. package/src/cli/cross-home-owner.ts +138 -0
  38. package/src/cli/dispatch.ts +1 -1
  39. package/src/cli/doctor.ts +18 -8
  40. package/src/cli/ensure-desired-integrations.ts +5 -0
  41. package/src/cli/export-command.ts +6 -6
  42. package/src/cli/help.ts +1 -1
  43. package/src/cli/index.ts +15 -18
  44. package/src/cli/integrations.ts +8 -1
  45. package/src/cli/models-runtime-subcommands.ts +1 -0
  46. package/src/cli/models-runtime.ts +106 -4
  47. package/src/cli/models.ts +6 -1
  48. package/src/cli/opencode.ts +2 -83
  49. package/src/cli/registry.ts +3 -2
  50. package/src/cli/status.ts +86 -7
  51. package/src/client/connect.ts +24 -10
  52. package/src/client/link-ingress.ts +3 -1
  53. package/src/client/link-join.ts +76 -9
  54. package/src/client/link-relay-transport.ts +184 -0
  55. package/src/client/link-relay.ts +16 -4
  56. package/src/client/machine-listener.ts +2 -0
  57. package/src/clients/config-export/constants.ts +8 -0
  58. package/src/clients/config-export/contracts.ts +13 -1
  59. package/src/clients/config-export/droid.ts +71 -0
  60. package/src/clients/config-export/kilo.ts +116 -0
  61. package/src/clients/config-export.ts +33 -2
  62. package/src/codex/auth-api/routes.ts +1 -1
  63. package/src/codex/catalog/access-programs.ts +14 -2
  64. package/src/codex/catalog/provider-models.ts +1 -1
  65. package/src/codex/catalog-auto-refresh.ts +122 -0
  66. package/src/codex/config-drift-heal.ts +80 -0
  67. package/src/codex/desktop-switches.ts +7 -5
  68. package/src/codex/inject/bounded-config-reader.ts +69 -0
  69. package/src/codex/inject/config-toml.ts +31 -3
  70. package/src/codex/inject/routing-target.ts +6 -1
  71. package/src/codex/inject.ts +4 -0
  72. package/src/codex/model-entitlements.ts +32 -5
  73. package/src/codex/native-profile-startup.ts +17 -5
  74. package/src/codex/project-config-warnings.ts +72 -21
  75. package/src/codex/quota-auto-refresh-state.ts +8 -2
  76. package/src/codex/quota-auto-refresh.ts +80 -9
  77. package/src/codex/sync.ts +10 -0
  78. package/src/combos/failover.ts +4 -1
  79. package/src/combos/jev.ts +15 -4
  80. package/src/combos/types.ts +16 -0
  81. package/src/config/diagnostics.ts +19 -1
  82. package/src/config/load-degrade.ts +29 -0
  83. package/src/config/macos-system-proxy.ts +114 -0
  84. package/src/config/proxy-env.ts +67 -20
  85. package/src/config/schema/config-schema.ts +15 -0
  86. package/src/config/schema/leaf-validators.ts +23 -0
  87. package/src/config.ts +2 -0
  88. package/src/generated/compatibility-version.json +220 -144
  89. package/src/integrations/catalog-refresh.ts +1 -1
  90. package/src/integrations/config-io.ts +12 -3
  91. package/src/integrations/droid-settings.ts +65 -0
  92. package/src/integrations/kilo-candidates.ts +30 -0
  93. package/src/integrations/mutation-plan.ts +62 -7
  94. package/src/integrations/native/ownership-preflight.ts +46 -12
  95. package/src/integrations/registry.ts +136 -7
  96. package/src/integrations/state.ts +52 -4
  97. package/src/integrations/target.ts +3 -0
  98. package/src/integrations/writer.ts +55 -14
  99. package/src/lib/account-selection-events.ts +14 -0
  100. package/src/lib/jsonc.ts +110 -0
  101. package/src/lib/standalone.ts +9 -1
  102. package/src/lib/windows-system-proxy.ts +101 -1
  103. package/src/link/relay-auth.ts +29 -0
  104. package/src/link/ssh-argv.ts +6 -3
  105. package/src/link/ssh-runner.ts +5 -5
  106. package/src/oauth/generic-account-failover.ts +52 -8
  107. package/src/oauth/index.ts +37 -14
  108. package/src/oauth/store.ts +96 -11
  109. package/src/oauth/token-guardian.ts +7 -3
  110. package/src/oauth/types.ts +2 -0
  111. package/src/plugins/loader.ts +5 -3
  112. package/src/providers/fastwire.ts +3 -0
  113. package/src/providers/key-failover.ts +13 -5
  114. package/src/providers/kiro-model-catalog.ts +4 -3
  115. package/src/providers/model-rename-fields.ts +1 -0
  116. package/src/providers/openai-tiers-destination.ts +9 -0
  117. package/src/providers/quota/account-cache.ts +17 -3
  118. package/src/providers/quota/vendor-probes-oauth.ts +2 -0
  119. package/src/providers/quota.ts +2 -2
  120. package/src/responses/code-mode-helper-compat.ts +14 -1
  121. package/src/responses/custom-tool-compat.ts +1 -1
  122. package/src/server/auth-cors.ts +1 -0
  123. package/src/server/images.ts +70 -20
  124. package/src/server/index/link-listener.ts +20 -3
  125. package/src/server/index/link-relay-sessions.ts +138 -0
  126. package/src/server/index/optional-listeners.ts +11 -1
  127. package/src/server/index/serve-options.ts +12 -0
  128. package/src/server/inference/client-encoder-delivery.ts +1 -0
  129. package/src/server/local-management-read-client.ts +21 -2
  130. package/src/server/management/agent-settings-routes.ts +19 -5
  131. package/src/server/management/combo-routes.ts +5 -3
  132. package/src/server/management/config-routes.ts +32 -6
  133. package/src/server/management/model-routes.ts +232 -7
  134. package/src/server/management/model-rows.ts +199 -1
  135. package/src/server/management/oauth-account-routes.ts +63 -3
  136. package/src/server/management/route-registry.ts +2 -0
  137. package/src/server/port-reclaim.ts +170 -32
  138. package/src/server/responses/adapter-delivery.ts +6 -4
  139. package/src/server/responses/adapter-dispatch.ts +72 -2
  140. package/src/server/responses/core-combo.ts +6 -1
  141. package/src/server/responses/core-normalize.ts +19 -4
  142. package/src/server/responses/core-options.ts +2 -0
  143. package/src/server/responses/memory-models.ts +169 -0
  144. package/src/server/responses/passthrough-delivery.ts +4 -0
  145. package/src/server/responses/passthrough-dispatch.ts +10 -0
  146. package/src/server/responses/request-prepare.ts +86 -4
  147. package/src/server/responses/request-transport.ts +4 -0
  148. package/src/server/responses/run-turn-execution.ts +5 -2
  149. package/src/server/responses/shadow-target-availability.ts +17 -4
  150. package/src/server/responses/sidecar-execution.ts +18 -2
  151. package/src/server/responses/skills-snapshot.ts +239 -0
  152. package/src/server/responses-undeclared-tool-guard.ts +43 -6
  153. package/src/server/search.ts +1 -1
  154. package/src/service/guards.ts +4 -5
  155. package/src/service/state.ts +39 -2
  156. package/src/service.ts +2 -2
  157. package/src/types/config.ts +46 -4
  158. package/src/types/provider.ts +9 -0
  159. package/src/types/request.ts +6 -0
  160. package/src/types/tools.ts +38 -8
  161. package/src/types.ts +3 -0
  162. package/src/update/async-check.ts +5 -1
  163. package/src/update/index.ts +30 -12
  164. package/src/update/job.ts +3 -1
  165. package/src/update/pnpm-read-policy.d.mts +4 -0
  166. package/src/update/pnpm-read-policy.mjs +44 -0
  167. package/src/update/worker-launch.ts +152 -11
  168. package/src/usage/cost.ts +6 -3
  169. package/src/usage/log.ts +4 -0
  170. package/src/web-search/backends.ts +2 -2
  171. package/src/web-search/devin-executor.ts +1 -1
  172. package/src/web-search/sidecar-providers.ts +5 -5
  173. package/gui/dist/assets/App-mpsibsFj.js +0 -51
  174. package/gui/dist/assets/index-BDUBS8PW.css +0 -1
  175. package/gui/dist/assets/index-kUE8tuAK.js +0 -86
@@ -1,6 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { getValidAccessTokenForAccount } from "../../oauth";
3
- import { getAccountCredential, getAccountSet } from "../../oauth/store";
3
+ import { getAccountCredential, getAccountCredentialWithStatus, getAccountSet } from "../../oauth/store";
4
4
  import type { GenerationContext } from "../../lib/state-store-sweeper";
5
5
  import { ACCOUNT_QUOTA_TTL_MS, toFiniteNumber } from "../quota-wire";
6
6
  import { clearKiroAccountUsageState, hydrateKiroUsageVerdict, kiroPersistableVerdicts, reconcileKiroAccountUsageState } from "../kiro-usage";
@@ -437,8 +437,11 @@ export function clearAccountQuotaCache(provider?: string): void {
437
437
  * Anthropic's lock only adopts disk credentials for `local-cli` rows.
438
438
  */
439
439
  export async function getTokenForAccountQuotaProbe(provider: string, accountId: string): Promise<string> {
440
- const stored = getAccountCredential(provider, accountId);
441
- if (!stored) throw new Error("account credential missing");
440
+ const row = getAccountCredentialWithStatus(provider, accountId);
441
+ if (!row) throw new Error("account credential missing");
442
+ // An operator-paused account is excluded from every automatic upstream use, quota reads included.
443
+ if (row.paused) throw new Error("account is paused; quota probe skipped");
444
+ const stored = row.credential;
442
445
  if (stored.expires > Date.now() + ACCOUNT_TOKEN_SKEW_MS) return stored.access;
443
446
  const activeId = getAccountSet(provider)?.activeAccountId;
444
447
  if (activeId !== accountId && stored.source === "local-cli") {
@@ -447,6 +450,17 @@ export async function getTokenForAccountQuotaProbe(provider: string, accountId:
447
450
  return getValidAccessTokenForAccount(provider, accountId);
448
451
  }
449
452
 
453
+ /**
454
+ * Rows that must not be probed: an unsupported provider reads as unavailable, and a paused
455
+ * account returns its last reading unchanged (its `ts` shows the age) or an unavailable row,
456
+ * without writing the cache. Undefined means "probe normally".
457
+ */
458
+ export function accountQuotaProbeSkip(provider: string, accountId: string): AccountQuotaCacheEntry | undefined {
459
+ if (!supportsPerAccountQuota(provider)) return { ts: Date.now(), quota: null, unavailable: true };
460
+ if (getAccountCredentialWithStatus(provider, accountId)?.paused !== true) return undefined;
461
+ return accountQuotaCache.get(accountCacheKey(provider, accountId)) ?? { ts: Date.now(), quota: null, unavailable: true };
462
+ }
463
+
450
464
  export function explicitQuotaConfig(provider: string, configured?: OcxProviderConfig): OcxProviderConfig | undefined {
451
465
  if (configured) return configured;
452
466
  const entry = getProviderRegistryEntry(provider);
@@ -400,6 +400,8 @@ export async function fetchKiroQuota(provider: string): Promise<ProviderQuotaRep
400
400
  export async function fetchMuseKeyQuota(provider: string): Promise<ProviderQuotaReport | null> {
401
401
  const probedAccountId = getAccountSet(provider)?.activeAccountId;
402
402
  if (!probedAccountId) return null;
403
+ // A paused account is excluded from every automatic upstream use; a key mint is one.
404
+ if (getAccountSet(provider)?.accounts.find(account => account.id === probedAccountId)?.paused === true) return null;
403
405
  const oauthAccessToken = getAccountCredential(provider, probedAccountId)?.muse?.oauthAccessToken;
404
406
  // An imported or pasted credential has no account token and never will: it is
405
407
  // capability, not provider id, that decides whether a probe is possible.
@@ -45,7 +45,7 @@ import {
45
45
  explicitQuotaConfig,
46
46
  explicitQuotaDestination,
47
47
  explicitQuotaIdentity,
48
- getTokenForAccountQuotaProbe,
48
+ accountQuotaProbeSkip, getTokenForAccountQuotaProbe,
49
49
  hasPassiveAccountQuota,
50
50
  hydrateAccountQuotaCache,
51
51
  mayCommitAccountQuotaKey,
@@ -421,7 +421,7 @@ async function fetchAccountQuota(
421
421
  forceRefresh: boolean,
422
422
  providerConfig?: OcxProviderConfig,
423
423
  ): Promise<AccountQuotaCacheEntry> {
424
- if (!supportsPerAccountQuota(provider)) return { ts: Date.now(), quota: null, unavailable: true };
424
+ if (accountQuotaProbeSkip(provider, accountId)) return accountQuotaProbeSkip(provider, accountId)!;
425
425
  if (explicitAccountReader(provider)) return fetchExplicitAccountQuota(provider, accountId, forceRefresh, providerConfig);
426
426
  if (provider === "anthropic" || provider === "kiro") hydrateAccountQuotaCache();
427
427
  const key = accountCacheKey(provider, accountId);
@@ -3,13 +3,16 @@ import {
3
3
  normalizeApplyPatchDelimiters,
4
4
  unwrapFreeformToolInput,
5
5
  } from "./apply-patch-envelope";
6
- import { declaresCodeModeExec } from "../types/tools";
6
+ import { declaresCodeModeExec, isCodeModeMcpDirectName } from "../types/tools";
7
7
  import { parseCodeModeShellInput } from "./code-mode-shell-input";
8
8
 
9
9
  function isPlainObject(value: unknown): value is Record<string, unknown> {
10
10
  return !!value && typeof value === "object" && !Array.isArray(value);
11
11
  }
12
12
 
13
+ /** A nested host tool reachable as `tools.<name>` when the flattened name is one identifier. */
14
+ const CODE_MODE_IDENTIFIER_NAME = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
15
+
13
16
  /**
14
17
  * Convert a nested Code Mode helper call into unified-exec JavaScript.
15
18
  *
@@ -95,6 +98,16 @@ export function compileCodeModeHelperInput(
95
98
  if (helperName === "create_goal" || helperName === "get_goal" || helperName === "update_goal") {
96
99
  return `const result = await tools.${helperName}(${JSON.stringify(args)});\ntext(result);`;
97
100
  }
101
+ if (isCodeModeMcpDirectName(helperName)) {
102
+ // Direct `mcp__<server>__<tool>` call under a code-mode catalog: the emitted name IS the
103
+ // nested host tool's name, so compile to the same `tools.<name>(args)` the model could
104
+ // have written. Dot access when the name is a clean identifier (the common case); bracket
105
+ // access otherwise, so a hyphenated server name still addresses the same tool.
106
+ const target = CODE_MODE_IDENTIFIER_NAME.test(helperName)
107
+ ? `tools.${helperName}`
108
+ : `tools[${JSON.stringify(helperName)}]`;
109
+ return `const result = await ${target}(${JSON.stringify(args)});\ntext(result);`;
110
+ }
98
111
  return `const result = await tools.exec_command(${JSON.stringify(args)});\ntext(result);`;
99
112
  }
100
113
 
@@ -76,7 +76,7 @@ export function routedCustomToolTargetName(
76
76
  if (wireName === undefined) return undefined;
77
77
  if (names.has(wireName)) return wireName;
78
78
  if (!isPlainObject(value) || typeof value.namespace === "string") return undefined;
79
- const normalized = normalizeDeclaredToolName(wireName, declaredNames);
79
+ const normalized = normalizeDeclaredToolName(wireName, declaredNames, undefined, names);
80
80
  return normalized !== wireName && names.has(normalized) ? normalized : undefined;
81
81
  }
82
82
 
@@ -973,6 +973,7 @@ const PROVIDER_CONFIG_FIELD_POLICY = {
973
973
  mcpMaxResultBytes: "editor",
974
974
  modelAdapters: "editor",
975
975
  fastWire: "editor",
976
+ responseTierAuthoritative: "editor",
976
977
  fastEnabled: "editor",
977
978
  baseUrl: "editor",
978
979
  responsesPath: "editor",
@@ -37,7 +37,7 @@ import type { DataPlaneAdmission } from "./auth-cors";
37
37
  import { admissionScopeDenial } from "./admission-model-scope";
38
38
  import type { RequestLogContext } from "./request-log";
39
39
  import { codexLogAccountId, decodeRequestErrorResponse } from "./responses";
40
- import { getValidAccessToken, getOAuthCredentialProjectId } from "../oauth/index";
40
+ import { getValidAccessToken, getOAuthCredentialProjectId, OAuthAccountPausedError } from "../oauth/index";
41
41
  import { safeAntigravityHttpErrorMessage } from "../adapters/google-errors";
42
42
  import { sanitizeUpstreamErrorText } from "../adapters/upstream-http-error";
43
43
  import { ANTIGRAVITY_REQUEST_UA } from "../adapters/google-antigravity-wire";
@@ -56,6 +56,14 @@ import { codexModelAvailabilityErrorResponse } from "./responses/codex-auth-erro
56
56
 
57
57
  export type ImagesEndpoint = "generations" | "edits";
58
58
 
59
+ function assertImagesUpstreamAuthorization(headers: Headers, config: OcxConfig): void {
60
+ const authorization = headers.get("authorization")?.trim() ?? "";
61
+ if (!/^Bearer[\t ]+[^\s,]+$/i.test(authorization)) {
62
+ throw new TypeError("invalid image upstream authorization");
63
+ }
64
+ validateForwardAdmissionCredential(headers, config);
65
+ }
66
+
59
67
  /** Image generation is slow (tens of seconds); bound a hung upstream, not a working one. */
60
68
  const IMAGES_UPSTREAM_TIMEOUT_MS = 300_000;
61
69
 
@@ -275,6 +283,9 @@ async function tryCcaImageGeneration(
275
283
  if (linkedSignal.signal.aborted) {
276
284
  return formatErrorResponse(504, "upstream_error", "CCA image generation timed out during authentication");
277
285
  }
286
+ if (err instanceof OAuthAccountPausedError) {
287
+ return formatErrorResponse(403, "permission_error", err.message);
288
+ }
278
289
  // Missing/revoked credential → 401 (re-login required); transient refresh/network → 502.
279
290
  const errName = err instanceof Error ? err.name : "";
280
291
  if (errName === "OAuthLoginRequiredError") {
@@ -659,25 +670,41 @@ export async function handleImages(
659
670
  }
660
671
  const explicitKeyedProvider = config.images?.provider !== undefined && candidates.keyed !== undefined;
661
672
  // Admission bearer is valid proxy auth (requireApiAuth already passed) but must never be
662
- // forwarded as OpenAI ChatGPT credentials. When the caller sent it, skip OpenAI forward
663
- // and allow CCA / keyed paths instead of rejecting the whole request.
664
- let skipOpenAiForwardForAdmissionBearer = false;
673
+ // forwarded as OpenAI ChatGPT credentials. Pool replaces it with a stored credential;
674
+ // Direct would consume the caller bearer, so only Direct becomes ineligible.
675
+ let callerBearerMayBeForwarded = true;
665
676
  if (!explicitKeyedProvider) {
666
677
  try { validateForwardAdmissionCredential(req.headers, config); }
667
678
  catch (err) {
668
679
  if (err instanceof ForwardAdmissionCredentialError) {
669
- skipOpenAiForwardForAdmissionBearer = true;
680
+ callerBearerMayBeForwarded = false;
670
681
  } else {
671
682
  throw err;
672
683
  }
673
684
  }
674
685
  }
675
686
 
676
- const canUseOpenAiForward = !skipOpenAiForwardForAdmissionBearer && candidates.forwardCandidates.length > 0;
687
+ // Both relay branches copy the body upstream, so the destination is the
688
+ // provider chosen in that branch and the model the caller named. A scoped key
689
+ // is checked against each forward destination BEFORE any stored credential is
690
+ // resolved, refreshed or leased, so a forbidden destination spends nothing.
691
+ const relaySelector = requestedImageSelector(body);
692
+ let forwardScopeDenial: Response | undefined;
693
+ const eligibleForwardCandidates = candidates.forwardCandidates.filter(candidate => {
694
+ if (!callerBearerMayBeForwarded && candidate.accountMode === "direct") return false;
695
+ const denial = admissionScopeDenial(config, admission, relaySelector, {
696
+ providerName: candidate.providerName,
697
+ modelId: relaySelector,
698
+ });
699
+ if (denial) forwardScopeDenial ??= denial;
700
+ return denial === undefined;
701
+ });
702
+ const canUseOpenAiForward = eligibleForwardCandidates.length > 0;
677
703
 
678
704
  if (!canUseOpenAiForward && !candidates.keyed) {
679
705
  const ccaResponse = await tryCcaImageGeneration(body, config, logCtx, req.signal, endpoint, admission);
680
706
  if (ccaResponse) return ccaResponse;
707
+ if (forwardScopeDenial) return forwardScopeDenial;
681
708
  // 400, not 5xx: codex retries every 5xx up to 5 total attempts, and this is a permanent
682
709
  // configuration state that must surface on the first attempt.
683
710
  return formatErrorResponse(
@@ -696,7 +723,7 @@ export async function handleImages(
696
723
  let forwardAuthError: Response | undefined;
697
724
  if (canUseOpenAiForward) {
698
725
  try {
699
- forward = await resolveFirstUsableOpenAiSidecar(candidates.forwardCandidates, req.headers, config, {
726
+ forward = await resolveFirstUsableOpenAiSidecar(eligibleForwardCandidates, req.headers, config, {
700
727
  beginCodexAccountSelection: codexAccountSelectionForTurn(turnAdmissionLease),
701
728
  });
702
729
  if (forward) logCtx.provider = formatCodexProviderForLog(forward.providerName, codexLogAccountId(forward.authContext), config);
@@ -708,38 +735,48 @@ export async function handleImages(
708
735
  } else if (err instanceof CodexThreadAffinityExpiredError) {
709
736
  forwardAuthError = formatErrorResponse(409, "invalid_request_error", "Codex thread account affinity expired; start a new session");
710
737
  } else if (err instanceof CodexAuthContextError) {
711
- const safeAccountLabel = formatCodexProviderForLog("openai", err.accountId, config);
712
- console.error(`[images] Pool account ${safeAccountLabel} token failed; reauthentication required`);
738
+ console.error("[images] Pool credential failed; reauthentication required");
713
739
  forwardAuthError = formatErrorResponse(401, "authentication_error", "Selected Codex account needs reauthentication");
714
740
  } else if (err instanceof CodexModelAvailabilityError) {
715
741
  forwardAuthError = codexModelAvailabilityErrorResponse(err);
716
742
  } else if (err instanceof CodexPoolAuthenticationError) {
717
743
  forwardAuthError = formatErrorResponse(401, "authentication_error", err.message);
744
+ } else if (err instanceof TypeError) {
745
+ // The sidecar releases any acquired probe lease if credential materialization fails.
746
+ return formatErrorResponse(500, "internal_error", "image generation request failed forward credential validation");
718
747
  } else {
719
748
  throw err;
720
749
  }
721
750
  }
722
751
  }
723
752
 
724
- const headers: Record<string, string> = { "content-type": "application/json" };
753
+ const headers = new Headers({ "content-type": "application/json" });
754
+ const releaseForwardProbe = (): void => {
755
+ try { forward?.releaseProbeLease?.(); }
756
+ catch { console.error("[images] Failed to release probe lease"); }
757
+ };
725
758
  let url: string;
726
- // Both relay branches copy the body upstream, so the destination is the
727
- // provider chosen in that branch and the model the caller named. Each branch
728
- // is checked as it is entered, before it resolves a credential or commits a
729
- // key rotation, so a refused request spends nothing.
730
- const relaySelector = requestedImageSelector(body);
759
+ // Each branch is re-checked as it is entered: the forward check is defence in
760
+ // depth for the pre-filter above, and the keyed check runs before a key
761
+ // rotation is committed.
731
762
  if (forward) {
732
763
  const denial = admissionScopeDenial(config, admission, relaySelector, {
733
764
  providerName: forward.providerName,
734
765
  modelId: relaySelector,
735
766
  });
736
767
  if (denial) {
737
- forward.releaseProbeLease?.();
768
+ releaseForwardProbe();
738
769
  return denial;
739
770
  }
740
771
  const { provider } = forward;
741
- if (provider.headers) Object.assign(headers, provider.headers);
742
- for (const [name, value] of forward.headers) headers[name] = value;
772
+ try {
773
+ for (const [name, value] of Object.entries(provider.headers ?? {})) headers.set(name, value);
774
+ for (const [name, value] of forward.headers) headers.set(name, value);
775
+ assertImagesUpstreamAuthorization(headers, config);
776
+ } catch {
777
+ releaseForwardProbe();
778
+ return formatErrorResponse(500, "internal_error", "image generation request failed forward credential validation");
779
+ }
743
780
  // The ChatGPT codex backend takes bare paths (matches the adapter's `${baseUrl}/responses`).
744
781
  url = `${provider.baseUrl}/images/${endpoint}`;
745
782
  } else if (forwardAuthError) {
@@ -783,8 +820,13 @@ export async function handleImages(
783
820
  );
784
821
  }
785
822
  const apiKey = warmKeyProvider?.apiKey ?? candidates.keyed.apiKey;
786
- if (provider.headers) Object.assign(headers, provider.headers);
787
- headers["authorization"] = `Bearer ${apiKey}`;
823
+ try {
824
+ for (const [name, value] of Object.entries(provider.headers ?? {})) headers.set(name, value);
825
+ headers.set("authorization", `Bearer ${apiKey}`);
826
+ assertImagesUpstreamAuthorization(headers, config);
827
+ } catch {
828
+ return formatErrorResponse(500, "internal_error", "image generation request failed forward credential validation");
829
+ }
788
830
  logCtx.provider = providerName;
789
831
  // Keyed providers tolerate baseUrl with or without /v1 (mirrors openai-responses.ts).
790
832
  url = `${provider.baseUrl.replace(/\/v1\/?$/, "")}/v1/images/${endpoint}`;
@@ -799,6 +841,11 @@ export async function handleImages(
799
841
  );
800
842
  }
801
843
 
844
+ if (req.signal.aborted) {
845
+ releaseForwardProbe();
846
+ return formatErrorResponse(499, "client_closed_request", `image ${endpoint} request canceled by client`);
847
+ }
848
+
802
849
  const timeoutMs = config.images?.timeoutMs ?? IMAGES_UPSTREAM_TIMEOUT_MS;
803
850
  const linkedSignal = signalWithTimeout(timeoutMs, req.signal);
804
851
  const sidecarExit = sidecarEnter("images");
@@ -846,6 +893,9 @@ export async function handleImages(
846
893
  // Client cancel first: it aborts the linked signal too, and must not be logged as an
847
894
  // upstream failure (499 maps to client_closed_request in the request log).
848
895
  if (req.signal.aborted) {
896
+ // No upstream outcome is recorded for a client cancel, so a recovery probe
897
+ // lease taken by the selected Pool account must be returned explicitly.
898
+ releaseForwardProbe();
849
899
  return formatErrorResponse(499, "client_closed_request", `image ${endpoint} request canceled by client`);
850
900
  }
851
901
  if (linkedSignal.signal.aborted || (err instanceof Error && err.name === "TimeoutError")) {
@@ -1,3 +1,4 @@
1
+ import { createLinkRelaySessions } from "./link-relay-sessions";
1
2
  import type { Server } from "bun";
2
3
  import {
3
4
  emptyLinkStore,
@@ -13,6 +14,7 @@ export const LINK_INGRESS_HOSTNAME = "opencodex-link.invalid";
13
14
  export interface LinkListenerStartContext<T> {
14
15
  dispatch: (req: Request, server: Server<T>) => Promise<Response>;
15
16
  maxRequestBodySize: number;
17
+ keyFingerprints?: (apiKeyId: string) => readonly string[];
16
18
  }
17
19
 
18
20
  export interface LinkListenerDeps {
@@ -58,6 +60,7 @@ export function createLinkListenerLifecycle<T>(deps: LinkListenerDeps = {}): Lin
58
60
  const serve = deps.serve ?? (options => Bun.serve(options));
59
61
  const warn = deps.warn ?? ((message: string) => console.warn(message));
60
62
  let listener: Server<T> | null = null;
63
+ let relaySessions: ReturnType<typeof createLinkRelaySessions> | undefined;
61
64
  let startContext: LinkListenerStartContext<T> | undefined;
62
65
  let ensureFlight: Promise<void> | undefined;
63
66
  let closeFlight: Promise<void> | undefined;
@@ -92,6 +95,10 @@ export function createLinkListenerLifecycle<T>(deps: LinkListenerDeps = {}): Lin
92
95
  return;
93
96
  }
94
97
  const requestedPort = store.listenerPort ?? 0;
98
+ const sessions = createLinkRelaySessions({ fingerprints: (keyId, linkId) => {
99
+ if (!readStoreForAdmission().links.some(link => link.id === linkId && link.apiKeyId === keyId)) return [];
100
+ return startContext?.keyFingerprints?.(keyId) ?? [];
101
+ } });
95
102
  let bound: Server<unknown>;
96
103
  try {
97
104
  bound = serve({
@@ -101,7 +108,7 @@ export function createLinkListenerLifecycle<T>(deps: LinkListenerDeps = {}): Lin
101
108
  // relayed turn that the Home holds or that streams with a long gap.
102
109
  idleTimeout: 255,
103
110
  maxRequestBodySize: startContext.maxRequestBodySize,
104
- fetch: (req: Request, server: Server<unknown>) => startContext!.dispatch(req, server as Server<T>),
111
+ fetch: (req: Request, server: Server<unknown>) => sessions.dispatch(req, server as Server<T>, startContext!.dispatch),
105
112
  } as Parameters<typeof Bun.serve>[0]);
106
113
  } catch (error) {
107
114
  reportFailure("bind", error, "bind");
@@ -136,6 +143,7 @@ export function createLinkListenerLifecycle<T>(deps: LinkListenerDeps = {}): Lin
136
143
  }
137
144
  }
138
145
  listener = bound as Server<T>;
146
+ relaySessions = sessions;
139
147
  setStatus("listening", bound.port ?? (requestedPort > 0 ? requestedPort : null), null);
140
148
  };
141
149
 
@@ -159,8 +167,13 @@ export function createLinkListenerLifecycle<T>(deps: LinkListenerDeps = {}): Lin
159
167
  const flight = (async () => {
160
168
  if (ensureFlight) await ensureFlight;
161
169
  const current = listener;
162
- listener = null;
163
- if (current) await current.stop(true);
170
+ // Retain the bound port AND ingress ownership while an authenticated connection is
171
+ // reserved or a dispatched response is still being consumed. No new proofs are issued.
172
+ if (current) {
173
+ await relaySessions?.drain();
174
+ await current.stop(false);
175
+ }
176
+ if (listener === current) { listener = null; relaySessions = undefined; }
164
177
  // Closing is the caller saying "no links now": an earlier bind or persist failure no
165
178
  // longer describes anything, so the status reads off either way.
166
179
  setStatus("off", null, null);
@@ -194,6 +207,10 @@ export function createLinkListenerLifecycle<T>(deps: LinkListenerDeps = {}): Lin
194
207
  close,
195
208
  async stop() {
196
209
  stopped = true;
210
+ // Process shutdown is deliberately forceful, unlike last-link removal. Every
211
+ // authenticated socket is closed, and the client transport cannot reconnect it.
212
+ relaySessions?.abortReservations();
213
+ if (listener) await listener.stop(true);
197
214
  await close();
198
215
  startContext = undefined;
199
216
  setStatus("off", null, null);
@@ -0,0 +1,138 @@
1
+ import type { Server } from "bun";
2
+ import {
3
+ LINK_RELAY_AUTH_PATH, LINK_RELAY_AUTH_TIMEOUT_MS, LINK_RELAY_AUTH_VERSION,
4
+ LINK_RELAY_SESSION_HEADER, linkRelayProof, linkRelayProofMatches,
5
+ } from "../../link/relay-auth";
6
+
7
+ interface Session {
8
+ peer: string;
9
+ keyId: string;
10
+ linkId: string;
11
+ fingerprint: string;
12
+ expiresAt: number;
13
+ timer: ReturnType<typeof setTimeout>;
14
+ release: () => void;
15
+ }
16
+
17
+ export interface LinkRelaySessionOptions {
18
+ fingerprints: (keyId: string, linkId: string) => readonly string[];
19
+ /** Tests can use a bounded shorter reservation; production uses five seconds. */
20
+ reservationMs?: number;
21
+ }
22
+
23
+ /** One book per bound listener. A pending proof reserves the bind before the data request arrives. */
24
+ export function createLinkRelaySessions(options: LinkRelaySessionOptions) {
25
+ const sessions = new Map<string, Session>();
26
+ const drains = new Set<() => void>();
27
+ let closing = false;
28
+ let active = 0;
29
+ const reservationMs = options.reservationMs ?? LINK_RELAY_AUTH_TIMEOUT_MS;
30
+ const refuse = () => new Response(null, { status: 404 });
31
+ const peerOf = (req: Request, server: Server<unknown>): string | null => {
32
+ const peer = server.requestIP(req);
33
+ return peer ? `${peer.address}:${peer.port}` : null;
34
+ };
35
+ const acquire = () => {
36
+ active += 1;
37
+ let done = false;
38
+ return () => {
39
+ if (done) return;
40
+ done = true;
41
+ if (--active === 0) { for (const resolve of drains) resolve(); drains.clear(); }
42
+ };
43
+ };
44
+ const consume = (nonce: string): Session | undefined => {
45
+ const session = sessions.get(nonce);
46
+ if (session) { clearTimeout(session.timer); sessions.delete(nonce); }
47
+ return session;
48
+ };
49
+
50
+ return {
51
+ /** Forced process shutdown retires unused proofs before closing their sockets. */
52
+ abortReservations(): void {
53
+ closing = true;
54
+ for (const nonce of [...sessions.keys()]) consume(nonce)?.release();
55
+ },
56
+ /** Refuse new work, but keep reservations and active bodies alive until they settle. */
57
+ async drain(): Promise<void> {
58
+ closing = true;
59
+ if (active > 0) await new Promise<void>(resolve => drains.add(resolve));
60
+ },
61
+ async dispatch<T>(req: Request, server: Server<T>, dispatch: (req: Request, server: Server<T>) => Promise<Response>): Promise<Response> {
62
+ const url = new URL(req.url);
63
+ if (url.pathname === LINK_RELAY_AUTH_PATH) {
64
+ if (closing || req.method !== "GET" || sessions.size >= 256 || req.headers.has("upgrade")
65
+ || req.headers.has("authorization") || req.headers.has("x-opencodex-api-key")) return refuse();
66
+ const q = url.searchParams;
67
+ if (q.get("version") !== LINK_RELAY_AUTH_VERSION || [...q.keys()].length !== 5
68
+ || ["version", "key", "link", "nonce", "proof"].some(name => q.getAll(name).length !== 1)) return refuse();
69
+ const keyId = q.get("key")!, linkId = q.get("link")!, nonce = q.get("nonce")!;
70
+ const peer = peerOf(req, server as Server<unknown>);
71
+ if (!peer || sessions.has(nonce)) return refuse();
72
+ const fingerprint = options.fingerprints(keyId, linkId).find(candidate =>
73
+ linkRelayProofMatches(q.get("proof"), linkRelayProof(candidate, "caller", keyId, linkId, nonce)));
74
+ if (!fingerprint) return refuse();
75
+ const release = acquire();
76
+ const timer = setTimeout(() => { consume(nonce)?.release(); }, reservationMs);
77
+ sessions.set(nonce, { peer, keyId, linkId, fingerprint, release, timer, expiresAt: Date.now() + reservationMs });
78
+ return new Response(null, { status: 204, headers: {
79
+ "x-opencodex-link-proof": linkRelayProof(fingerprint, "listener", keyId, linkId, nonce)!,
80
+ "cache-control": "no-store",
81
+ } });
82
+ }
83
+ const nonce = req.headers.get(LINK_RELAY_SESSION_HEADER);
84
+ let release: () => void;
85
+ if (nonce !== null) {
86
+ // A proof received on one HTTP socket never authorizes another socket, even on the same port.
87
+ const session = consume(nonce);
88
+ if (!session) return refuse();
89
+ release = session.release;
90
+ if (session.peer !== peerOf(req, server as Server<unknown>) || Date.now() >= session.expiresAt
91
+ || !options.fingerprints(session.keyId, session.linkId).includes(session.fingerprint)) {
92
+ release(); return refuse();
93
+ }
94
+ // Keep the native Request identity: Bun's requestIP/timeout APIs need the original request.
95
+ try { req.headers.delete(LINK_RELAY_SESSION_HEADER); } catch { release(); return refuse(); }
96
+ } else {
97
+ if (closing) return new Response(null, { status: 503 });
98
+ // Older peers may still use the ordinary key admission. Its response also owns a drain lease.
99
+ release = acquire();
100
+ }
101
+ // Socket cancellation before headers is terminal too; an upstream handler that is
102
+ // settling its own cleanup cannot keep a dead transport's listener lease forever.
103
+ const beforeHeadersAbort = () => release();
104
+ req.signal.addEventListener("abort", beforeHeadersAbort, { once: true });
105
+ if (req.signal.aborted) beforeHeadersAbort();
106
+ try {
107
+ const response = await dispatch(req, server);
108
+ req.signal.removeEventListener("abort", beforeHeadersAbort);
109
+ if (!response.body || req.method === "HEAD") { release(); return response; }
110
+ const reader = response.body.getReader();
111
+ let ended = false;
112
+ const finish = () => {
113
+ if (ended) return;
114
+ ended = true;
115
+ req.signal.removeEventListener("abort", aborted);
116
+ release();
117
+ };
118
+ const cancel = async (reason?: unknown) => {
119
+ try { await reader.cancel(reason); } finally { finish(); }
120
+ };
121
+ const aborted = () => { void cancel(req.signal.reason).catch(() => {}); };
122
+ req.signal.addEventListener("abort", aborted, { once: true });
123
+ if (req.signal.aborted) aborted();
124
+ const body = new ReadableStream<Uint8Array>({
125
+ async pull(controller) {
126
+ try {
127
+ const next = await reader.read();
128
+ if (next.done) { finish(); controller.close(); }
129
+ else controller.enqueue(next.value);
130
+ } catch (error) { finish(); controller.error(error); }
131
+ },
132
+ cancel,
133
+ }, { highWaterMark: 0 });
134
+ return new Response(body, { status: response.status, statusText: response.statusText, headers: response.headers });
135
+ } catch (error) { req.signal.removeEventListener("abort", beforeHeadersAbort); release(); throw error; }
136
+ },
137
+ };
138
+ }
@@ -1,3 +1,4 @@
1
+ import { serviceApiTokenFingerprint } from "../../lib/service-secrets";
1
2
  import type { Server } from "bun";
2
3
  import type { OcxConfig } from "../../types";
3
4
  import {
@@ -81,7 +82,16 @@ export function createOptionalListenerSet<T>(linkDeps: LinkListenerDeps = {}): O
81
82
  linkSupervisor: () => supervisor,
82
83
  start(ctx) {
83
84
  activeConfig = ctx.config;
84
- linkListener.start({ dispatch: ctx.dispatch, maxRequestBodySize: ctx.maxRequestBodySize });
85
+ linkListener.start({ dispatch: ctx.dispatch, maxRequestBodySize: ctx.maxRequestBodySize,
86
+ keyFingerprints: apiKeyId => {
87
+ const entry = activeConfig?.apiKeys?.find(candidate => candidate.id === apiKeyId);
88
+ if (!entry?.key) return [];
89
+ const fingerprints = [serviceApiTokenFingerprint(entry.key)];
90
+ const pending = entry.pendingRotation;
91
+ if (pending && Date.parse(pending.expiresAt) > Date.now()) fingerprints.push(serviceApiTokenFingerprint(pending.key));
92
+ return fingerprints;
93
+ },
94
+ });
85
95
  unregisterSupervisorAdmission ??= linkListener.onAuthenticatedCatalog(apiKeyId => supervisor.notifyAuthenticatedRequest?.(apiKeyId));
86
96
  if (linkListenerOwnsTarget(linkListener.status())) {
87
97
  supervisor.start();
@@ -170,6 +170,7 @@ import {
170
170
  createLocalAttestationProof,
171
171
  } from "../../lib/local-management-attestation";
172
172
  import { SYSTEM_RESTART_CAPABILITY_VERSION } from "../../lib/system-restart-contract";
173
+ import { LOCAL_MANAGEMENT_NONCE_HEADER } from "../../lib/local-management-capability";
173
174
  import { LOCAL_PROVIDER_RELOAD_CAPABILITY_VERSION } from "../../lib/local-provider-reload-contract";
174
175
  import { LOCAL_ASIDE_SYNC_CAPABILITY_VERSION } from "../../lib/local-aside-sync-contract";
175
176
  import {
@@ -697,6 +698,17 @@ export function createServeOptions(ctx: ServeOptionsContext) {
697
698
  trustedLoopback: trustedLoopbackForIngress(ingress, config.hostname ?? "127.0.0.1"),
698
699
  guiSessionIssuance: managementSessionIssuance(req, managementAuth),
699
700
  });
701
+ // A local read capability authenticates the request; sign its single-use nonce so the
702
+ // caller can tell this answer came from this process and not from whoever holds the port.
703
+ const readNonce = principal === "local-read-capability" ? req.headers.get(LOCAL_MANAGEMENT_NONCE_HEADER) : null;
704
+ const readProof = readNonce
705
+ ? createLocalAttestationProof(localAttestationSecret, readNonce, process.pid, localManagementAuth.port) : null;
706
+ if (mgmtResponse && readProof) {
707
+ const headers = new Headers(mgmtResponse.headers);
708
+ headers.set(LOCAL_ATTESTATION_PROOF_HEADER, readProof);
709
+ const signed = new Response(mgmtResponse.body, { status: mgmtResponse.status, statusText: mgmtResponse.statusText, headers });
710
+ return withManagementCors(signed, req, config);
711
+ }
700
712
  if (mgmtResponse) return withManagementCors(mgmtResponse, req, config);
701
713
  return withManagementCors(formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${url.pathname}`), req, config);
702
714
  }
@@ -91,6 +91,7 @@ export interface ClientEncodedDelivery {
91
91
  declaredToolNames?: ReadonlySet<string>;
92
92
  toolParameterSchemas?: ReadonlyMap<string, Record<string, unknown>>;
93
93
  freeformToolNames?: Set<string>;
94
+ bareCustomToolNames?: ReadonlySet<string>;
94
95
  toolSearchToolNames?: Set<string>;
95
96
  };
96
97
  stallTimeoutSec?: number;
@@ -1,5 +1,9 @@
1
1
  import { readRuntimePort, type RuntimePortState } from "../config/process-state";
2
- import { createLocalAttestationChallenge } from "../lib/local-management-attestation";
2
+ import {
3
+ LOCAL_ATTESTATION_PROOF_HEADER,
4
+ createLocalAttestationChallenge,
5
+ verifyLocalAttestationProof,
6
+ } from "../lib/local-management-attestation";
3
7
  import {
4
8
  LOCAL_MANAGEMENT_CAPABILITY_HEADER,
5
9
  LOCAL_MANAGEMENT_CAPABILITY_EXPIRES_AT_HEADER,
@@ -14,7 +18,10 @@ import { probeHostname, type LiveProxy } from "./proxy-liveness";
14
18
 
15
19
  export type LocalManagementReadResult =
16
20
  | { kind: "response"; response: Response; targetPid: number }
17
- | { kind: "unavailable"; reason: "unattested-target" | "runtime-mismatch" | "capability-unavailable" | "transport" };
21
+ | {
22
+ kind: "unavailable";
23
+ reason: "unattested-target" | "runtime-mismatch" | "capability-unavailable" | "transport" | "unattested-response";
24
+ };
18
25
 
19
26
  export interface LocalManagementReadDeps {
20
27
  fetchImpl?: typeof fetch;
@@ -25,6 +32,12 @@ export interface LocalManagementReadDeps {
25
32
 
26
33
  export interface LocalManagementReadRequestDeps extends LocalManagementReadDeps {
27
34
  timeoutMs?: number;
35
+ /**
36
+ * Require the response to carry the server's attestation over this request's nonce. The
37
+ * capability authenticates the request to the real server; only this proof tells the caller
38
+ * that the answer came from it and not from whatever process now holds the port.
39
+ */
40
+ requireResponseProof?: boolean;
28
41
  }
29
42
 
30
43
  /**
@@ -83,6 +96,12 @@ export async function fetchBoundLocalManagementRead(
83
96
  signal: AbortSignal.timeout(deps.timeoutMs ?? 4_000),
84
97
  },
85
98
  );
99
+ if (deps.requireResponseProof && !verifyLocalAttestationProof(
100
+ runtime.attestationSecret, nonce, target.pid, target.port, response.headers.get(LOCAL_ATTESTATION_PROOF_HEADER),
101
+ )) {
102
+ void response.body?.cancel().catch(() => {});
103
+ return { kind: "unavailable", reason: "unattested-response" };
104
+ }
86
105
  return { kind: "response", response, targetPid: target.pid };
87
106
  } catch {
88
107
  return { kind: "unavailable", reason: "transport" };