@bitkyc08/opencodex 2.76.0 → 2.77.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 (189) hide show
  1. package/gui/dist/assets/App-COdEeqSj.css +1 -0
  2. package/gui/dist/assets/App-DEpCJEP_.js +54 -0
  3. package/gui/dist/assets/{Tray-DjNwwrWS.js → Tray-gloIYNaW.js} +1 -1
  4. package/gui/dist/assets/index-Cd7ittMv.css +1 -0
  5. package/gui/dist/assets/index-DwJKFJm5.js +94 -0
  6. package/gui/dist/assets/quota-summary-DGcEJU3h.js +1 -0
  7. package/gui/dist/index.html +4 -3
  8. package/package.json +1 -1
  9. package/src/adapters/antigravity-refusal.ts +28 -0
  10. package/src/adapters/cursor/protobuf-request.ts +6 -0
  11. package/src/adapters/cursor/request-builder.ts +3 -0
  12. package/src/adapters/cursor/structured-output.ts +13 -0
  13. package/src/adapters/cursor/types.ts +2 -0
  14. package/src/adapters/ollama-native.ts +29 -2
  15. package/src/adapters/openai-responses/passthrough.ts +133 -103
  16. package/src/adapters/openai-responses/request-strips.ts +10 -4
  17. package/src/adapters/zed.ts +26 -4
  18. package/src/bridge/response-json.ts +14 -14
  19. package/src/bridge/sse.ts +15 -5
  20. package/src/chatgpt/app-server-shim/bundle-trust.ts +40 -10
  21. package/src/chatgpt/app-server-shim/gate-rewrite.ts +3 -0
  22. package/src/claude/agents-inject.ts +4 -43
  23. package/src/claude/gateway-cache.ts +16 -13
  24. package/src/claude/inbound-model-options.ts +9 -2
  25. package/src/claude/intercept/cli-catalog.ts +27 -16
  26. package/src/claude/intercept/picker-bootstrap.ts +37 -16
  27. package/src/claude/intercept/picker-budget.ts +41 -0
  28. package/src/claude/outbound.ts +2 -0
  29. package/src/claude/subagent-force-status.ts +53 -0
  30. package/src/claude/subagent-model.ts +93 -0
  31. package/src/cli/account-api.ts +3 -0
  32. package/src/cli/account.ts +3 -1
  33. package/src/cli/agent.ts +10 -0
  34. package/src/cli/capabilities.ts +9 -0
  35. package/src/cli/chatgpt-command.ts +10 -7
  36. package/src/cli/claude.ts +22 -6
  37. package/src/cli/codex-shim-readiness.ts +89 -0
  38. package/src/cli/connect.ts +19 -0
  39. package/src/cli/dispatch.ts +3 -3
  40. package/src/cli/export-command.ts +20 -9
  41. package/src/cli/gui.ts +8 -3
  42. package/src/cli/help-catalog.ts +84 -0
  43. package/src/cli/help-models-context.ts +17 -0
  44. package/src/cli/help-navigation.ts +50 -0
  45. package/src/cli/help-recovery.ts +66 -0
  46. package/src/cli/help.ts +52 -8
  47. package/src/cli/models-runtime.ts +2 -1
  48. package/src/cli/opencode.ts +54 -10
  49. package/src/cli/provider.ts +3 -29
  50. package/src/cli/registry.ts +29 -5
  51. package/src/cli/root.ts +30 -9
  52. package/src/cli/runtime-api.ts +1 -1
  53. package/src/cli/tray-proxy.ts +3 -2
  54. package/src/clients/config-export/contracts.ts +100 -0
  55. package/src/clients/config-export/droid.ts +54 -2
  56. package/src/clients/config-export/kilo.ts +12 -2
  57. package/src/clients/config-export/model-metadata.ts +10 -4
  58. package/src/clients/config-export/reasoning-metadata.ts +47 -0
  59. package/src/clients/config-export.ts +46 -66
  60. package/src/clients/omo-role-models.ts +17 -3
  61. package/src/codex/account-credit-use.ts +4 -2
  62. package/src/codex/account-runtime-state.ts +21 -0
  63. package/src/codex/account-store.ts +11 -47
  64. package/src/codex/account-usability.ts +11 -8
  65. package/src/codex/auth-api/account-list.ts +73 -63
  66. package/src/codex/auth-api/main-account-probe.ts +25 -12
  67. package/src/codex/auth-api/pool-mode-gate.ts +6 -9
  68. package/src/codex/auth-api/pool-quota-probe.ts +2 -4
  69. package/src/codex/auth-api/reset-credit-service.ts +3 -2
  70. package/src/codex/auth-context.ts +30 -11
  71. package/src/codex/catalog/aggregation.ts +3 -0
  72. package/src/codex/catalog/model-hints.ts +2 -1
  73. package/src/codex/catalog/model-visibility.ts +6 -3
  74. package/src/codex/catalog/parsing.ts +2 -0
  75. package/src/codex/catalog/provider-models.ts +13 -4
  76. package/src/codex/catalog/retained-sync.ts +2 -1
  77. package/src/codex/chatgpt-refresh-failure.ts +56 -0
  78. package/src/codex/convergence.ts +2 -1
  79. package/src/codex/main-account.ts +144 -11
  80. package/src/codex/quota-refresh-outcome.ts +17 -2
  81. package/src/codex/quota-types.ts +33 -1
  82. package/src/codex/quota.ts +56 -15
  83. package/src/codex/routing/cooldown-math.ts +10 -4
  84. package/src/codex/routing/selection.ts +11 -4
  85. package/src/codex/routing.ts +9 -9
  86. package/src/codex/shim-path-resolution.ts +133 -0
  87. package/src/codex/shim.ts +39 -39
  88. package/src/codex/subagent-model-fallback.ts +2 -1
  89. package/src/combos/failover.ts +82 -1
  90. package/src/combos/jev.ts +13 -3
  91. package/src/config/diagnostics.ts +4 -0
  92. package/src/config/load-degrade.ts +4 -0
  93. package/src/config/provider-validation.ts +3 -0
  94. package/src/config/schema/compaction-triggers.ts +8 -0
  95. package/src/config/schema/config-schema.ts +11 -0
  96. package/src/config/schema/leaf-validators.ts +5 -1
  97. package/src/config/subagent-models.ts +12 -0
  98. package/src/generated/compatibility-version.json +245 -153
  99. package/src/grok/session-identity.ts +33 -0
  100. package/src/integrations/catalog-refresh.ts +1 -1
  101. package/src/integrations/mutation-plan.ts +4 -2
  102. package/src/integrations/state.ts +68 -6
  103. package/src/integrations/writer.ts +18 -4
  104. package/src/lib/gui-pair-capability.ts +17 -0
  105. package/src/lib/provider-client-headers.ts +62 -0
  106. package/src/oauth/anthropic-continuity.ts +65 -0
  107. package/src/oauth/anthropic-identity.ts +75 -0
  108. package/src/oauth/anthropic-routing.ts +3 -3
  109. package/src/oauth/anthropic.ts +7 -1
  110. package/src/oauth/generic-account-failover.ts +21 -0
  111. package/src/oauth/health.ts +32 -5
  112. package/src/oauth/index.ts +36 -27
  113. package/src/oauth/local-token-detect.ts +7 -0
  114. package/src/oauth/refresh-policy.ts +2 -0
  115. package/src/oauth/store.ts +55 -6
  116. package/src/oauth/types.ts +9 -0
  117. package/src/protocols/encoders/messages.ts +4 -3
  118. package/src/providers/antigravity-effort-families.ts +75 -0
  119. package/src/providers/antigravity-models.ts +54 -16
  120. package/src/providers/antigravity-wire-snapshot.ts +66 -0
  121. package/src/providers/derive.ts +2 -0
  122. package/src/providers/model-rename-fields.ts +3 -0
  123. package/src/providers/new-model-policy.ts +48 -10
  124. package/src/providers/registry/entries-extended.ts +3 -0
  125. package/src/providers/registry/model-ids.ts +2 -0
  126. package/src/providers/registry/model-seeds.ts +5 -0
  127. package/src/providers/registry/types.ts +4 -0
  128. package/src/responses/compaction.ts +19 -0
  129. package/src/router.ts +6 -0
  130. package/src/server/auth-cors.ts +9 -0
  131. package/src/server/chat-completions.ts +10 -0
  132. package/src/server/claude-messages.ts +18 -11
  133. package/src/server/droid-reasoning-default.ts +50 -0
  134. package/src/server/github-copilot-responses-repair.ts +3 -6
  135. package/src/server/grok-responses-control-frame.ts +2 -2
  136. package/src/server/grok-responses-snapshot-repair.ts +4 -5
  137. package/src/server/gui-session.ts +33 -11
  138. package/src/server/index/claude-intercept-lifecycle.ts +4 -0
  139. package/src/server/index/serve-options.ts +2 -1
  140. package/src/server/management/agent-settings-routes.ts +6 -129
  141. package/src/server/management/config-routes.ts +1 -1
  142. package/src/server/management/integration-routes.ts +64 -6
  143. package/src/server/management/link-routes.ts +20 -13
  144. package/src/server/management/model-row-export-metadata.ts +150 -0
  145. package/src/server/management/model-rows.ts +148 -9
  146. package/src/server/management/route-registry.ts +4 -3
  147. package/src/server/management/shared.ts +19 -5
  148. package/src/server/management/subagent-model-routes.ts +214 -0
  149. package/src/server/relay.ts +14 -44
  150. package/src/server/request-log-generation-window.ts +38 -0
  151. package/src/server/request-log.ts +13 -0
  152. package/src/server/responses/adapter-dispatch.ts +100 -1
  153. package/src/server/responses/codex-auth-error.ts +10 -2
  154. package/src/server/responses/combo-stream-preflight.ts +29 -3
  155. package/src/server/responses/compaction-routing.ts +10 -1
  156. package/src/server/responses/core-auth.ts +57 -26
  157. package/src/server/responses/core-codex-account.ts +6 -3
  158. package/src/server/responses/core-combo-failure.ts +5 -1
  159. package/src/server/responses/core-combo-native.ts +7 -1
  160. package/src/server/responses/core-combo.ts +18 -1
  161. package/src/server/responses/core-normalize.ts +7 -0
  162. package/src/server/responses/core-options.ts +5 -0
  163. package/src/server/responses/fetch-helpers.ts +9 -0
  164. package/src/server/responses/passthrough-delivery.ts +1 -1
  165. package/src/server/responses/policy-fallback.ts +1 -1
  166. package/src/server/responses/request-prepare.ts +5 -0
  167. package/src/server/responses/request-spend.ts +30 -30
  168. package/src/server/responses-custom-tool-repair.ts +2 -2
  169. package/src/server/responses-hosted-image-display.ts +70 -9
  170. package/src/server/responses-terminal-repair.ts +15 -2
  171. package/src/server/sse-frame-buffer.ts +38 -36
  172. package/src/server/sse-payload-rewrite.ts +78 -49
  173. package/src/server/ws-bridge.ts +1 -11
  174. package/src/service/windows-taskxml.ts +1 -2
  175. package/src/service-manager-probe.ts +36 -2
  176. package/src/types/config.ts +4 -0
  177. package/src/types/provider.ts +18 -0
  178. package/src/update/npm-invocation.mjs +35 -3
  179. package/src/update/proxy-liveness-probe.mjs +38 -8
  180. package/src/usage/expected-prices.ts +9 -0
  181. package/src/usage/log.ts +7 -0
  182. package/src/usage/summary.ts +82 -1
  183. package/src/web-search/loop.ts +11 -8
  184. package/src/web-search/run-turn-loop.ts +10 -7
  185. package/gui/dist/assets/App-CddorHdA.js +0 -51
  186. package/gui/dist/assets/App-DMudtISW.css +0 -1
  187. package/gui/dist/assets/index-Btze1I3T.css +0 -1
  188. package/gui/dist/assets/index-CvnY7E6i.js +0 -86
  189. package/gui/dist/assets/quota-summary-B_AWpHfP.js +0 -1
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Grok Build sends its conversation identity as `x-grok-conv-id` (and the same value as the body
3
+ * `prompt_cache_key`), but never the `session_id` header Codex clients send. The ChatGPT Codex
4
+ * backend only reuses a warmed prompt prefix for requests that carry `session_id`, and opencodex
5
+ * reads the same header for thread affinity and conversation logging. Without it, every Grok turn
6
+ * routed to an OpenAI model was a cold `new_bind` with near-zero cached input.
7
+ *
8
+ * Only the managed Grok surface (`x-opencodex-grok: 1`, written by `ocx` into Grok's config) is
9
+ * promoted, and an explicit caller session header always wins.
10
+ */
11
+ const GROK_SURFACE_HEADER = "x-opencodex-grok";
12
+ const GROK_CONVERSATION_HEADER = "x-grok-conv-id";
13
+ const SESSION_HEADERS = ["session_id", "session-id", "thread-id"] as const;
14
+ // Grok emits UUIDs and `turn-summary-<uuid>`; anything else is not promoted.
15
+ const SAFE_CONVERSATION_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
16
+
17
+ export function grokConversationSessionId(headers: Headers): string | undefined {
18
+ if (headers.get(GROK_SURFACE_HEADER) !== "1") return undefined;
19
+ if (SESSION_HEADERS.some(name => headers.has(name))) return undefined;
20
+ const conversationId = headers.get(GROK_CONVERSATION_HEADER)?.trim();
21
+ if (!conversationId || !SAFE_CONVERSATION_ID.test(conversationId)) return undefined;
22
+ return conversationId;
23
+ }
24
+
25
+ /** Returns `req` unchanged unless a Grok conversation id can stand in for the missing `session_id`. */
26
+ export function withGrokSessionIdentity(req: Request): Request {
27
+ const sessionId = grokConversationSessionId(req.headers);
28
+ if (!sessionId) return req;
29
+ const headers = new Headers(req.headers);
30
+ headers.set("session_id", sessionId);
31
+ // The clone keeps the unread body stream and the caller's abort signal.
32
+ return new Request(req, { headers });
33
+ }
@@ -11,7 +11,7 @@ import {
11
11
  /** Refresh only previously connected clients; a refused file never blocks its peers. */
12
12
  export async function refreshOwnedCatalogIntegrations(
13
13
  input: Omit<OwnedIntegrationRefreshInput, "clientId">,
14
- clientIds: readonly IntegrationClientId[] = ["pi", "aside", "raycast", "omo", "droid"],
14
+ clientIds: readonly IntegrationClientId[] = ["pi", "aside", "raycast", "omo", "droid", "opencode", "kilo"],
15
15
  ): Promise<OwnedIntegrationRefreshOutcome[]> {
16
16
  // Client files are shared with the live proxy a sibling instance runs beside; their entries
17
17
  // point at the owner's port, and refreshing them here would re-point them at this one.
@@ -36,7 +36,7 @@ import {
36
36
  } from "./registry";
37
37
  import { declaredIntegrationTarget, resolveIntegrationTarget, type IntegrationTarget } from "./target";
38
38
  import { shouldInjectApiAuthHeader } from "../codex/inject";
39
- import { classifyIntegration, exportContextOf, readPath, type IntegrationState, type StateReason } from "./state";
39
+ import { buildIntegrationContribution, classifyIntegration, exportContextOf, readPath, type IntegrationState, type StateReason } from "./state";
40
40
  import { inspectKiloCandidates } from "./kilo-candidates";
41
41
  import { InvalidSelectorError } from "./merge";
42
42
  import { createIntegrationStateStore, type IntegrationStateStore } from "./store";
@@ -791,6 +791,8 @@ export interface IntegrationWriteInput {
791
791
  models: readonly ExportModel[];
792
792
  config: OcxConfig;
793
793
  port: number;
794
+ /** Explicit Droid-only defaults; omitted means retain matching owned rows. */
795
+ droidReasoningDefaults?: Record<string, string>;
794
796
  env?: NodeJS.ProcessEnv;
795
797
  home?: string;
796
798
  store?: IntegrationStateStore;
@@ -936,13 +938,13 @@ export function observeIntegration(
936
938
  * reads a different document there, and a write in the config file's shape
937
939
  * would be as unread as a write to the config file itself.
938
940
  */
939
- const contribution = effective.buildContribution(exportContextOf(input));
940
941
  // A record proves ownership of the file it was written FOR. Matching only by
941
942
  // client id let a record for one home authorize a write to another whose
942
943
  // bytes happened to hash the same — which deleted a config we never touched.
943
944
  const record = stored && stored.clientId === clientId && stored.configPath === configPath
944
945
  ? stored
945
946
  : null;
947
+ const contribution = buildIntegrationContribution(input, effective, parsed, record);
946
948
  if (clientId === "droid" && input.models.length > 0 && contribution.fragments.length === 0 && !record) {
947
949
  return { failed: observationFailure("unsafe", "unsafe", "Factory Droid has no addressable models in the selected catalog") } as const;
948
950
  }
@@ -10,7 +10,7 @@
10
10
  */
11
11
  import { createClineIO } from "./cline-io";
12
12
  import { parseClineDocument } from "./cline-document";
13
- import { ClientPathError, EXPORT_CLIENTS, opencodeProxyBaseUrl, type ExportModel, type ManagedContribution } from "../clients/config-export";
13
+ import { ClientPathError, EXPORT_CLIENTS, opencodeProxyBaseUrl, droidDefaultsFromOwnedRows, type ExportModel, type ManagedContribution } from "../clients/config-export";
14
14
  import type { ConfigFormat } from "../clients/config-export";
15
15
  import type { OcxConfig } from "../types";
16
16
  import { PARSE_FAILED, loadTarget, parseConfig, type IntegrationIO } from "./config-io";
@@ -449,6 +449,7 @@ export interface IntegrationStateInput {
449
449
  models: readonly ExportModel[];
450
450
  config: OcxConfig;
451
451
  port: number;
452
+ droidReasoningDefaults?: Record<string, string>;
452
453
  env?: NodeJS.ProcessEnv;
453
454
  home?: string;
454
455
  /** The whole integration state store, bound to one root. */
@@ -462,7 +463,8 @@ export function exportContextOf(input: {
462
463
  models: readonly ExportModel[];
463
464
  config: OcxConfig;
464
465
  port: number;
465
- }): { baseUrl: string; models: readonly ExportModel[]; config: OcxConfig } {
466
+ droidReasoningDefaults?: Record<string, string>;
467
+ }): { baseUrl: string; models: readonly ExportModel[]; config: OcxConfig; droidReasoningDefaults?: Record<string, string> } {
466
468
  return {
467
469
  /*
468
470
  * Composed through the SAME helper `ocx export` uses. Interpolating the
@@ -476,9 +478,41 @@ export function exportContextOf(input: {
476
478
  baseUrl: opencodeProxyBaseUrl(input.port, input.config.hostname, input.config),
477
479
  models: input.models,
478
480
  config: input.config,
481
+ ...(input.droidReasoningDefaults === undefined ? {} : { droidReasoningDefaults: input.droidReasoningDefaults }),
479
482
  };
480
483
  }
481
484
 
485
+ export function buildIntegrationContribution(
486
+ input: IntegrationStateInput,
487
+ effective: IntegrationTarget,
488
+ parsed: unknown,
489
+ record: OwnershipRecord | null,
490
+ ): ManagedContribution {
491
+ const context = exportContextOf(input);
492
+ if (input.clientId === "droid" && input.droidReasoningDefaults === undefined && record?.configPath === effective.configPath) {
493
+ const inherited = recordedDroidContributionMatches(parsed, record)
494
+ ? droidDefaultsFromOwnedRows(context, parsed, record.fragmentPaths)
495
+ : {};
496
+ if (Object.keys(inherited).length > 0) context.droidReasoningDefaults = inherited;
497
+ }
498
+ return effective.buildContribution(context);
499
+ }
500
+
501
+ function recordedDroidContributionMatches(document: unknown, record: OwnershipRecord): boolean {
502
+ try {
503
+ const fragments = record.fragmentPaths.flatMap(path => {
504
+ const value = readPath(document, path);
505
+ return value === undefined ? [] : [{ path, value }];
506
+ });
507
+ if (fragments.length !== record.fragmentPaths.length) return false;
508
+ const observed: ManagedContribution = { clientId: "droid", fragments };
509
+ return record.semanticBlockFingerprint === fingerprint(semanticContribution(observed))
510
+ || record.blockFingerprint === fingerprint(canonicalContribution(observed));
511
+ } catch {
512
+ return false;
513
+ }
514
+ }
515
+
482
516
  let retriedThisProcess = false;
483
517
 
484
518
  /**
@@ -535,9 +569,7 @@ export function readIntegrationState(input: IntegrationStateInput): IntegrationS
535
569
  try {
536
570
  // One resolution for both, so a client whose paths come from mutable state
537
571
  // cannot report one account's install beside another account's config path.
538
- const context = exportContextOf(input);
539
- const paths = input.resolvedPaths ?? resolveIntegrationPaths(input.clientId, input.env, input.home, context);
540
- if (input.clientId === "droid" && input.resolvedPaths) assertDroidPathsUnambiguous(paths.detectDir, context);
572
+ const paths = resolveStatePaths(input);
541
573
  installed = io.statKind(paths.detectDir) === "dir";
542
574
  if (input.clientId === "cline") io = createClineIO(io, paths.configPath, store);
543
575
  /*
@@ -608,7 +640,7 @@ export function readIntegrationState(input: IntegrationStateInput): IntegrationS
608
640
  const parsed = input.clientId === "cline"
609
641
  ? parseClineDocument(loaded.before)
610
642
  : parseConfig(loaded.before, effective.format, EXPORT_CLIENTS[input.clientId].jsonc ? { jsonc: true } : undefined);
611
- const contribution = effective.buildContribution(exportContextOf(input));
643
+ const contribution = buildIntegrationContribution(input, effective, parsed, record);
612
644
  if (input.clientId === "droid" && input.models.length > 0 && contribution.fragments.length === 0
613
645
  && (!record || record.configPath !== configPath)) {
614
646
  return { clientId: input.clientId, state: "unsafe", installed, configPath,
@@ -650,3 +682,33 @@ export function readIntegrationState(input: IntegrationStateInput): IntegrationS
650
682
  ...retention,
651
683
  };
652
684
  }
685
+
686
+ function resolveStatePaths(input: IntegrationStateInput): { configPath: string; detectDir: string } {
687
+ const context = exportContextOf(input);
688
+ const paths = input.resolvedPaths ?? resolveIntegrationPaths(input.clientId, input.env, input.home, context);
689
+ if (input.clientId === "droid" && input.resolvedPaths) assertDroidPathsUnambiguous(paths.detectDir, context);
690
+ return paths;
691
+ }
692
+
693
+ export function readOwnedDroidReasoningDefaults(input: IntegrationStateInput): Record<string, string> {
694
+ if (input.clientId !== "droid") return {};
695
+ const store = input.store ?? createIntegrationStateStore();
696
+ const io = input.io ?? store.io();
697
+ try {
698
+ const paths = resolveStatePaths(input);
699
+ const record = store.readRecords().droid;
700
+ if (!record || record.clientId !== "droid" || record.configPath !== paths.configPath) return {};
701
+ const effective = resolveIntegrationTarget({
702
+ clientId: "droid", configPath: paths.configPath, io, record, env: input.env, home: input.home,
703
+ });
704
+ const loaded = loadTarget(io, effective.configPath);
705
+ if (!loaded.ok) return {};
706
+ const parsed = parseConfig(loaded.before, "json");
707
+ if (parsed === PARSE_FAILED) return {};
708
+ if (!recordedDroidContributionMatches(parsed, record)) return {};
709
+ return droidDefaultsFromOwnedRows(exportContextOf(input), parsed, record.fragmentPaths);
710
+ } catch (error) {
711
+ if (error instanceof ClientPathError) return {};
712
+ throw error;
713
+ }
714
+ }
@@ -18,7 +18,7 @@ import { shouldInjectApiAuthHeader } from "../codex/inject";
18
18
  import { detachedConfigSnapshot } from "../config/admitted-identity";
19
19
  import { copyPlainData } from "../lib/plain-data";
20
20
  import type { OcxConfig } from "../types";
21
- import { defaultIntegrationIO, loadTarget, type IntegrationIO } from "./config-io";
21
+ import { defaultIntegrationIO, loadTarget, parseConfig, type IntegrationIO } from "./config-io";
22
22
  import { inspectKiloCandidates } from "./kilo-candidates";
23
23
  import {
24
24
  fingerprint,
@@ -43,7 +43,7 @@ import {
43
43
  type IntegrationClientId,
44
44
  } from "./registry";
45
45
  import { declaredIntegrationTarget } from "./target";
46
- import { exportContextOf } from "./state";
46
+ import { buildIntegrationContribution, exportContextOf } from "./state";
47
47
  import type { IntegrationState } from "./state";
48
48
  import { serializeDocument, UnserializableValueError } from "./serialize";
49
49
  import { ClientPathError } from "../clients/config-export";
@@ -713,7 +713,11 @@ export function restoreIntegration(input: IntegrationRestoreInput): WriteOutcome
713
713
  // exact bytes when the snapshot was taken. Re-deriving it from the file would
714
714
  // mean guessing which entries are ours, and a wrong guess deletes a user's.
715
715
  const restoredRecord = entry.priorRecord;
716
- const fresh = rowTarget.buildContribution(exportContextOf(input));
716
+ const fresh = buildIntegrationContribution(
717
+ { ...input, droidReasoningDefaults: undefined }, rowTarget,
718
+ clientId === "droid" && restoredText !== null ? parseConfig(restoredText, "json") : undefined,
719
+ restoredRecord,
720
+ );
717
721
  /*
718
722
  * Does the restored record actually describe the restored bytes?
719
723
  *
@@ -812,6 +816,12 @@ function freezeIntegrationInput(input: IntegrationWriteInput): FrozenIntegration
812
816
  if (!models.ok) {
813
817
  throw new UncopyableIntegrationInputError("the model roster could not be captured for this write");
814
818
  }
819
+ const droidReasoningDefaults = input.droidReasoningDefaults === undefined
820
+ ? undefined
821
+ : copyPlainData(input.droidReasoningDefaults);
822
+ if (droidReasoningDefaults !== undefined && !droidReasoningDefaults.ok) {
823
+ throw new UncopyableIntegrationInputError("Droid reasoning defaults could not be captured for this write");
824
+ }
815
825
  /*
816
826
  * One resolution for both paths. Aside derives them from the account id in
817
827
  * its manifest, so two independent calls could verify one account's install
@@ -828,7 +838,11 @@ function freezeIntegrationInput(input: IntegrationWriteInput): FrozenIntegration
828
838
  * checked in one state and written from another, which is the substitution the fingerprint
829
839
  * exists to prevent. Copying both here gives the plan and the document one input.
830
840
  */
831
- return { ...input, config, models: models.value, env, home, store, io, resolvedPaths };
841
+ return {
842
+ ...input, config, models: models.value,
843
+ ...(droidReasoningDefaults === undefined ? {} : { droidReasoningDefaults: droidReasoningDefaults.value }),
844
+ env, home, store, io, resolvedPaths,
845
+ };
832
846
  }
833
847
 
834
848
  function tryFreezeIntegrationInput(input: IntegrationWriteInput):
@@ -65,6 +65,23 @@ export function canonicalHttpOrigin(value: unknown): string | null {
65
65
  }
66
66
  }
67
67
 
68
+ /**
69
+ * Standalone pairing is only for this process's configured literal loopback origin.
70
+ * No CORS entry, public hub URL, wildcard bind, alias or client role can widen it.
71
+ * The CLI separately checks the attested runtime's actual port before requesting a grant.
72
+ */
73
+ export function standaloneGuiPairingOrigin(config: {
74
+ runtimeRole?: string;
75
+ hostname?: string;
76
+ port: number;
77
+ }): string | null {
78
+ if ((config.runtimeRole ?? "standalone") !== "standalone") return null;
79
+ const hostname = config.hostname ?? "127.0.0.1";
80
+ if (hostname !== "127.0.0.1" && hostname !== "::1") return null;
81
+ if (!Number.isInteger(config.port) || config.port < 1 || config.port > 65535) return null;
82
+ return new URL(`http://${hostname === "::1" ? "[::1]" : hostname}:${config.port}`).origin;
83
+ }
84
+
68
85
  function capabilityPayload(
69
86
  nonce: string,
70
87
  method: string,
@@ -0,0 +1,62 @@
1
+ const HEADER_NAME_PATTERN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
2
+ const BLOCKED_FORWARDED_CLIENT_HEADERS = new Set([
3
+ "authorization",
4
+ "chatgpt-account-id",
5
+ "connection",
6
+ "content-length",
7
+ "content-type",
8
+ "cookie",
9
+ "expect",
10
+ "host",
11
+ "keep-alive",
12
+ "proxy-authorization",
13
+ "proxy-connection",
14
+ "set-cookie",
15
+ "te",
16
+ "trailer",
17
+ "transfer-encoding",
18
+ "upgrade",
19
+ "x-amz-security-token",
20
+ "x-api-key",
21
+ "api-key",
22
+ "x-goog-api-key",
23
+ "x-oai-attestation",
24
+ ]);
25
+
26
+ const ALLOWED_FORWARDED_CLIENT_HEADERS = new Set([
27
+ "originator", "x-client-request-id", "x-codex-app-version", "user-agent",
28
+ ]);
29
+ const MAX_FORWARDED_CLIENT_HEADERS = 64;
30
+
31
+ /** Normalize one opt-in caller header while refusing credential and transport-owned fields. */
32
+ export function normalizeForwardedClientHeaderName(value: unknown): string | null {
33
+ if (typeof value !== "string") return null;
34
+ const name = value.trim().toLowerCase();
35
+ if (!name || !HEADER_NAME_PATTERN.test(name) || !ALLOWED_FORWARDED_CLIENT_HEADERS.has(name)) return null;
36
+ return name;
37
+ }
38
+
39
+ /** Validate provider.forwardClientHeaders without ever admitting credential-bearing headers. */
40
+ export function providerForwardClientHeadersConfigError(value: unknown): string | null {
41
+ if (value === undefined) return null;
42
+ if (!Array.isArray(value)) return "forwardClientHeaders must be an array of header names";
43
+ if (value.length > MAX_FORWARDED_CLIENT_HEADERS) {
44
+ return `forwardClientHeaders must contain at most ${MAX_FORWARDED_CLIENT_HEADERS} names`;
45
+ }
46
+ const seen = new Set<string>();
47
+ for (const raw of value) {
48
+ if (typeof raw !== "string" || !raw.trim() || !HEADER_NAME_PATTERN.test(raw.trim())) {
49
+ return "forwardClientHeaders must use valid HTTP header names";
50
+ }
51
+ const name = raw.trim().toLowerCase();
52
+ if (BLOCKED_FORWARDED_CLIENT_HEADERS.has(name)) {
53
+ return `forwardClientHeaders must not include credential or transport-owned header "${raw.trim()}"`;
54
+ }
55
+ if (!ALLOWED_FORWARDED_CLIENT_HEADERS.has(name)) {
56
+ return "forwardClientHeaders must use the supported client metadata allowlist";
57
+ }
58
+ if (seen.has(name)) return `forwardClientHeaders must not repeat header "${raw.trim()}"`;
59
+ seen.add(name);
60
+ }
61
+ return null;
62
+ }
@@ -0,0 +1,65 @@
1
+ import { detectClaudeCodeToken, hasClaudeCredentialContinuity } from "./local-token-detect";
2
+ import { normalizeAnthropicIdentity, resolveAnthropicAccountIdentity, type AnthropicIdentityResolver } from "./anthropic-identity";
3
+ import { credentialGeneration, type AuthStore } from "./store";
4
+ import { REFRESH_SKEW_MS } from "./refresh-policy";
5
+ import type { OAuthCredentials, ProviderAccountSet } from "./types";
6
+
7
+ export type ClaudeCredentialObservation =
8
+ | { kind: "absent" }
9
+ | { kind: "unresolved" | "different"; diskGeneration: string }
10
+ | { kind: "adopt"; credential: OAuthCredentials; diskGeneration: string };
11
+
12
+ export async function newerClaudeCredential(
13
+ stored: OAuthCredentials, now: number, signal?: AbortSignal,
14
+ resolveIdentity: AnthropicIdentityResolver = resolveAnthropicAccountIdentity,
15
+ ): Promise<ClaudeCredentialObservation> {
16
+ if (stored.source !== "local-cli") return { kind: "absent" };
17
+ const disk = detectClaudeCodeToken();
18
+ if (!disk || !Number.isFinite(disk.expires) || disk.expires <= now + REFRESH_SKEW_MS
19
+ || credentialGeneration(disk) === credentialGeneration(stored)) return { kind: "absent" };
20
+ const diskGeneration = credentialGeneration(disk);
21
+ const before = normalizeAnthropicIdentity(stored.anthropicIdentity, stored.access);
22
+ if (hasClaudeCredentialContinuity(stored, disk)) {
23
+ return { kind: "adopt", diskGeneration: credentialGeneration(disk), credential: {
24
+ ...stored, ...disk, anthropicIdentity: disk.access === stored.access ? before : undefined,
25
+ } };
26
+ }
27
+ // Independent observations share a deadline through the caller signal; neither extends the other.
28
+ const [oldProof, newProof] = await Promise.all([
29
+ before ?? resolveIdentity(stored.access, signal), resolveIdentity(disk.access, signal),
30
+ ]);
31
+ const oldIdentity = normalizeAnthropicIdentity(oldProof, stored.access);
32
+ const newIdentity = normalizeAnthropicIdentity(newProof, disk.access);
33
+ if (!oldIdentity || !newIdentity) return { kind: "unresolved", diskGeneration };
34
+ if (oldIdentity.accountUuid !== newIdentity.accountUuid) return { kind: "different", diskGeneration };
35
+ return { kind: "adopt", diskGeneration: credentialGeneration(disk), credential: {
36
+ ...stored, ...disk, accountId: newIdentity.accountUuid, anthropicIdentity: newIdentity,
37
+ } };
38
+ }
39
+
40
+ /** Capture primitives before network awaits; check them again inside the serialized writer. */
41
+ export function captureAnthropicCredentialOwner(set: ProviderAccountSet, accountId: string) {
42
+ const row = set.accounts.find(account => account.id === accountId)!;
43
+ const loginId = row.loginId;
44
+ const generation = credentialGeneration(row.credential);
45
+ const metadata = identityMetadata(row.credential);
46
+ const needsReauth = row.needsReauth;
47
+ const selection = { id: set.activeAccountId, revision: set.selectionRevision };
48
+ return (store: AuthStore, diskGeneration?: string, allowPaused = false): boolean => {
49
+ const current = store["anthropic"];
50
+ const account = current?.accounts.find(candidate => candidate.id === accountId);
51
+ if (!account || (!allowPaused && account.paused) || account.loginId !== loginId
52
+ || account.needsReauth !== needsReauth || credentialGeneration(account.credential) !== generation
53
+ || identityMetadata(account.credential) !== metadata) return false;
54
+ if (diskGeneration !== undefined) {
55
+ if (current?.activeAccountId !== selection.id || current?.selectionRevision !== selection.revision) return false;
56
+ const disk = detectClaudeCodeToken();
57
+ if (!disk || disk.expires <= Date.now() + REFRESH_SKEW_MS || credentialGeneration(disk) !== diskGeneration) return false;
58
+ }
59
+ return true;
60
+ };
61
+ }
62
+
63
+ function identityMetadata(credential: OAuthCredentials): string {
64
+ return JSON.stringify([credential.source, credential.accountId, credential.email, credential.anthropicIdentity]);
65
+ }
@@ -0,0 +1,75 @@
1
+ /** Private account proof from an authenticated Anthropic response, bound to its exact bearer. */
2
+ import { hasClaudeCredentialContinuity } from "./local-token-detect";
3
+ import { createHash } from "node:crypto";
4
+ import { readBoundedResponseBody } from "../lib/bounded-body";
5
+ import { CLAUDE_CLI_USER_AGENT } from "../providers/claude-cli-identity";
6
+ import type { OAuthCredentials } from "./types";
7
+
8
+ const PROFILE_URL = "https://api.anthropic.com/api/oauth/profile";
9
+ const PROFILE_TIMEOUT_MS = 10_000;
10
+ const PROFILE_MAX_BYTES = 65_536;
11
+ export type AnthropicIdentity = NonNullable<OAuthCredentials["anthropicIdentity"]>;
12
+ export type AnthropicIdentityResolver = (access: string, signal?: AbortSignal) => Promise<AnthropicIdentity | undefined>;
13
+
14
+ export function bindAnthropicIdentity(access: string, accountUuid: unknown): AnthropicIdentity | undefined {
15
+ if (!access || typeof accountUuid !== "string" || !accountUuid.length || accountUuid.length > 128
16
+ || accountUuid !== accountUuid.trim() || /[\x00-\x1f\x7f]/.test(accountUuid)) return undefined;
17
+ return { v: 1, accountUuid, bearerSha256: createHash("sha256").update(access).digest("hex") };
18
+ }
19
+
20
+ export function normalizeAnthropicIdentity(value: unknown, access: string): AnthropicIdentity | undefined {
21
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
22
+ const proof = value as Partial<AnthropicIdentity>;
23
+ const bound = bindAnthropicIdentity(access, proof.accountUuid);
24
+ return proof.v === 1 && bound && proof.bearerSha256 === bound.bearerSha256 ? bound : undefined;
25
+ }
26
+
27
+ /** Fixed destination, no redirects, bounded body and deadline; failures never include response data. */
28
+ export const resolveAnthropicAccountIdentity: AnthropicIdentityResolver = async (access, signal) => {
29
+ if (!access || signal?.aborted) return undefined;
30
+ const deadline = AbortSignal.timeout(PROFILE_TIMEOUT_MS);
31
+ const combined = signal ? AbortSignal.any([signal, deadline]) : deadline;
32
+ try {
33
+ const response = await fetch(PROFILE_URL, {
34
+ method: "GET", redirect: "error", signal: combined,
35
+ headers: { Authorization: `Bearer ${access}`, Accept: "application/json",
36
+ "anthropic-beta": "oauth-2025-04-20",
37
+ "Cache-Control": "no-cache", "User-Agent": CLAUDE_CLI_USER_AGENT },
38
+ });
39
+ if (!response.ok || response.redirected) {
40
+ void response.body?.cancel().catch(() => {});
41
+ return undefined;
42
+ }
43
+ const body = await readBoundedResponseBody(response, {
44
+ signal: combined, maxBytes: PROFILE_MAX_BYTES, fatalUtf8: true,
45
+ totalTimeoutMs: PROFILE_TIMEOUT_MS, inactivityTimeoutMs: PROFILE_TIMEOUT_MS,
46
+ });
47
+ if (body.truncated || body.oversized || body.timedOut || combined.aborted) return undefined;
48
+ const data: unknown = JSON.parse(body.text);
49
+ if (!data || typeof data !== "object" || !("account" in data)) return undefined;
50
+ const account = data.account;
51
+ return account && typeof account === "object" && "uuid" in account
52
+ ? bindAnthropicIdentity(access, account.uuid) : undefined;
53
+ } catch {
54
+ // Unavailable identity is an explicit unresolved result, never an ownership guess.
55
+ return undefined;
56
+ }
57
+ };
58
+
59
+ /** A changed access bearer cannot inherit the previous bearer's proof. */
60
+ export function mergeAnthropicIdentity(fresh: OAuthCredentials, previous: OAuthCredentials): Pick<OAuthCredentials, "anthropicIdentity"> {
61
+ const before = normalizeAnthropicIdentity(previous.anthropicIdentity, previous.access);
62
+ const after = normalizeAnthropicIdentity(fresh.anthropicIdentity, fresh.access);
63
+ if (before && after && before.accountUuid !== after.accountUuid) {
64
+ throw new Error("Anthropic credential identity changed; explicit account login required");
65
+ }
66
+ return { anthropicIdentity: after ?? (fresh.access === previous.access ? before : undefined) };
67
+ }
68
+
69
+ /** Explicit imports enrich only a proven slot; labels and paths never establish ownership. */
70
+ export function matchesAnthropicImport(stored: OAuthCredentials, imported: OAuthCredentials): boolean {
71
+ const before = normalizeAnthropicIdentity(stored.anthropicIdentity, stored.access);
72
+ const after = normalizeAnthropicIdentity(imported.anthropicIdentity, imported.access);
73
+ if (before && after) return before.accountUuid === after.accountUuid;
74
+ return hasClaudeCredentialContinuity(stored, imported);
75
+ }
@@ -18,6 +18,7 @@ import { anthropicFamilyRejected, anthropicFamilyRetryAt, anthropicModelExhauste
18
18
  * Token refresh failures retain the existing store needsReauth policy.
19
19
  */
20
20
  import { anthropicRatePauseUntil, clearAnthropicRatePauses, classifyAnthropic429, anthropicRetryAfterMs, pauseAnthropicRateAdmission } from "./anthropic-rate-limit-policy";
21
+ import { REFRESH_SKEW_MS } from "./refresh-policy";
21
22
  import { createHash } from "node:crypto";
22
23
  import { captureOAuthAccountSelection, commitOAuthAccountSelection, credentialGeneration, getAccountSet, getAccountCredential, getAccountCredentialWithStatus } from "./store";
23
24
  import type { OAuthAccessSnapshot } from "./index";
@@ -353,7 +354,6 @@ function usageScore(config: OcxConfig, accountId: string, model?: string): numbe
353
354
  }
354
355
  }
355
356
 
356
- const TOKEN_SKEW_MS = 60_000;
357
357
 
358
358
  /** Background `local-cli` slots with expired access are not pool-eligible (identity adoption risk). */
359
359
  function isPoolCredentialUsable(accountId: string, now: number): boolean {
@@ -361,7 +361,7 @@ function isPoolCredentialUsable(accountId: string, now: number): boolean {
361
361
  if (!cred) return false;
362
362
  if (cred.source !== "local-cli") return true;
363
363
  if (canRefreshAnthropicPoolAccount(accountId)) return true;
364
- return cred.expires > now + TOKEN_SKEW_MS;
364
+ return cred.expires > now + REFRESH_SKEW_MS;
365
365
  }
366
366
 
367
367
  export function getEligibleAnthropicAccounts(now = Date.now(), model?: string): string[] {
@@ -1093,7 +1093,7 @@ export async function getAnthropicPoolAccessToken(accountId: string): Promise<st
1093
1093
  const { OAuthLoginRequiredError } = await import("./index");
1094
1094
  throw new OAuthLoginRequiredError(PROVIDER);
1095
1095
  }
1096
- if (stored.expires > Date.now() + TOKEN_SKEW_MS) return stored.access;
1096
+ if (stored.expires > Date.now() + REFRESH_SKEW_MS) return stored.access;
1097
1097
  if (!canRefreshAnthropicPoolAccount(accountId)) {
1098
1098
  throw new Error("background local-cli token expired; refuse CLI-adopting refresh for pool");
1099
1099
  }
@@ -1,5 +1,6 @@
1
1
  /** Anthropic OAuth flow (Claude Pro/Max). Ported from jawcode oauth/anthropic.ts. */
2
2
  import { OAuthCallbackFlow } from "./callback-server";
3
+ import { bindAnthropicIdentity, resolveAnthropicAccountIdentity } from "./anthropic-identity";
3
4
  import { generatePKCE } from "./pkce";
4
5
  import type { LocalTokenImportMode, OAuthController, OAuthCredentials } from "./types";
5
6
 
@@ -96,6 +97,7 @@ function credsFrom(data: AnthropicTokenResponse, refreshFallback?: string): OAut
96
97
  refresh: data.refresh_token || refreshFallback || "",
97
98
  access: data.access_token,
98
99
  expires,
100
+ anthropicIdentity: bindAnthropicIdentity(data.access_token, accountUuid),
99
101
  accountId: typeof accountUuid === "string" && accountUuid.length > 0 ? accountUuid : undefined,
100
102
  email: typeof email === "string" && email.length > 0 ? email : undefined,
101
103
  };
@@ -159,7 +161,11 @@ export async function loginAnthropic(
159
161
  const local = detectClaudeCodeToken();
160
162
  if (local) {
161
163
  ctrl.onProgress?.("Found Claude Code token, importing automatically");
162
- if (local.expires >= Date.now() + 60_000) return local;
164
+ if (local.expires >= Date.now() + 60_000) {
165
+ const identity = await resolveAnthropicAccountIdentity(local.access, ctrl.signal);
166
+ ctrl.signal?.throwIfAborted();
167
+ return { ...local, ...(identity ? { accountId: identity.accountUuid, anthropicIdentity: identity } : {}) };
168
+ }
163
169
  try {
164
170
  return { ...(await refreshAnthropicToken(local.refresh)), source: "local-cli" };
165
171
  } catch (error) {
@@ -734,3 +734,24 @@ export function clearGenericFailoverHealth(providerName?: string): void {
734
734
  if (key.startsWith(`${providerName}\u0000`)) health.delete(key);
735
735
  }
736
736
  }
737
+
738
+ /**
739
+ * Retire one account's superseded auth evidence after an explicit (re-)login. An
740
+ * auth cooldown recorded against the old grant must not hold out the fresh
741
+ * credential, so it leaves with the login rather than lingering until its
742
+ * deadline. Rate, quota, and suspension evidence is preserved: a re-login says
743
+ * nothing about those verdicts, and dropping them would make a throttled account
744
+ * eligible early. Entries bound to the still-current generation stay as well.
745
+ */
746
+ export function clearGenericFailoverHealthForAccount(providerName: string, accountId: string): void {
747
+ const live = getAccountSet(providerName)?.accounts.find(row => row.id === accountId);
748
+ const current = live ? credentialGeneration(live.credential) : undefined;
749
+ const bare = `${providerName}\u0000${accountId}`;
750
+ for (const key of [...health.keys()]) {
751
+ if (key !== bare && !key.startsWith(`${bare}\u0000`)) continue;
752
+ const entry = health.get(key);
753
+ if (!entry || entry.cooldownSource !== "auth") continue;
754
+ if (current !== undefined && entry.identity === current) continue;
755
+ health.delete(key);
756
+ }
757
+ }