@bitkyc08/opencodex 2.52.0-preview.20260911 → 2.52.0-preview.20260912

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 (166) hide show
  1. package/gui/dist/assets/index-D_t6sCWs.js +115 -0
  2. package/gui/dist/assets/index-EdoPnm9_.css +1 -0
  3. package/gui/dist/index.html +2 -2
  4. package/gui/dist/provider-icons/devin.svg +49 -0
  5. package/gui/dist/provider-icons/omo.svg +42 -0
  6. package/package.json +3 -1
  7. package/src/AGENTS.md +1 -1
  8. package/src/adapters/cline-pass-deepseek-v4-tool-replay.ts +0 -1
  9. package/src/adapters/command-code.ts +0 -1
  10. package/src/adapters/cursor/checkpoint-store.ts +37 -0
  11. package/src/adapters/cursor/live-transport.ts +74 -2
  12. package/src/adapters/cursor.ts +6 -0
  13. package/src/adapters/devin/cloud-direct/auth.ts +264 -0
  14. package/src/adapters/devin/cloud-direct/catalog.ts +306 -0
  15. package/src/adapters/devin/cloud-direct/chat.ts +1274 -0
  16. package/src/adapters/devin/cloud-direct/index.ts +65 -0
  17. package/src/adapters/devin/cloud-direct/metadata.ts +134 -0
  18. package/src/adapters/devin/cloud-direct/wire.ts +206 -0
  19. package/src/adapters/devin/live-models.ts +133 -0
  20. package/src/adapters/devin-cli/acp.ts +204 -0
  21. package/src/adapters/devin-cli/adapter.ts +345 -0
  22. package/src/adapters/devin-cli/binary.ts +69 -0
  23. package/src/adapters/devin-cli/models.ts +57 -0
  24. package/src/adapters/devin.ts +326 -0
  25. package/src/adapters/google.ts +1 -1
  26. package/src/adapters/openai-chat.ts +31 -10
  27. package/src/adapters/openai-responses.ts +16 -1
  28. package/src/adapters/registry.ts +25 -1
  29. package/src/bridge.ts +8 -2
  30. package/src/claude/inbound-cache-stabilize.ts +130 -0
  31. package/src/claude/inbound.ts +45 -5
  32. package/src/cli/account-auth.ts +60 -1
  33. package/src/cli/account-extended.ts +23 -30
  34. package/src/cli/account.ts +2 -1
  35. package/src/cli/capabilities.ts +50 -8
  36. package/src/cli/config-command.ts +2 -2
  37. package/src/cli/dispatch.ts +14 -2
  38. package/src/cli/export-command.ts +11 -25
  39. package/src/cli/help.ts +2 -2
  40. package/src/cli/opencode.ts +5 -0
  41. package/src/cli/registry.ts +15 -4
  42. package/src/clients/config-export/cline.ts +71 -0
  43. package/src/clients/config-export/contracts.ts +10 -1
  44. package/src/clients/config-export/model-metadata.ts +33 -0
  45. package/src/clients/config-export/zcode.ts +23 -12
  46. package/src/clients/config-export.ts +156 -4
  47. package/src/codex/account-store.ts +7 -2
  48. package/src/codex/auth-context.ts +1 -1
  49. package/src/codex/catalog/effort.ts +4 -3
  50. package/src/codex/catalog/metadata.ts +8 -4
  51. package/src/codex/catalog/native-models.ts +24 -4
  52. package/src/codex/catalog/parsing.ts +19 -1
  53. package/src/codex/catalog/provider-fetch.ts +57 -0
  54. package/src/codex/catalog/sync.ts +1 -1
  55. package/src/codex/context-compat.ts +97 -0
  56. package/src/codex/context-owner.ts +201 -0
  57. package/src/codex/history-provider.ts +161 -14
  58. package/src/codex/inject-coordination.ts +3 -2
  59. package/src/codex/inject.ts +128 -9
  60. package/src/codex/pool-rotation.ts +8 -292
  61. package/src/codex/quota.ts +41 -12
  62. package/src/codex/retired-model-migration.ts +41 -0
  63. package/src/codex/routing.ts +327 -22
  64. package/src/codex/warmup.ts +2 -2
  65. package/src/combos/failover.ts +5 -0
  66. package/src/combos/resolve.ts +11 -15
  67. package/src/config.ts +46 -14
  68. package/src/generated/compatibility-version.json +293 -113
  69. package/src/grok/grpc-web.ts +120 -0
  70. package/src/grok/reset-coupon-ledger.ts +139 -0
  71. package/src/grok/reset-coupons.ts +278 -0
  72. package/src/integrations/catalog-refresh.ts +1 -1
  73. package/src/integrations/cline-document.ts +73 -0
  74. package/src/integrations/cline-io.ts +149 -0
  75. package/src/integrations/cline-transaction.ts +42 -0
  76. package/src/integrations/config-io.ts +21 -2
  77. package/src/integrations/journal.ts +43 -1
  78. package/src/integrations/omp-yaml-source.ts +123 -4
  79. package/src/integrations/ownership-policy.ts +7 -0
  80. package/src/integrations/ownership.ts +36 -0
  81. package/src/integrations/registry.ts +27 -0
  82. package/src/integrations/state.ts +5 -2
  83. package/src/integrations/store.ts +6 -0
  84. package/src/integrations/writer.ts +53 -9
  85. package/src/lab/subject/behavior-fingerprint.ts +1 -1
  86. package/src/lib/abort.ts +36 -0
  87. package/src/lib/local-destinations.ts +1 -1
  88. package/src/lib/upstream-retry.ts +36 -1
  89. package/src/oauth/account-quota-rank.ts +11 -0
  90. package/src/oauth/callback-server.ts +10 -5
  91. package/src/oauth/devin/api-base.ts +63 -0
  92. package/src/oauth/devin/login.ts +1 -0
  93. package/src/oauth/devin/register-user.ts +186 -0
  94. package/src/oauth/devin/types.ts +71 -0
  95. package/src/oauth/devin-cli.ts +149 -0
  96. package/src/oauth/devin.ts +170 -0
  97. package/src/oauth/generic-account-failover.ts +169 -1
  98. package/src/oauth/index.ts +20 -1
  99. package/src/oauth/login-cli.ts +19 -5
  100. package/src/oauth/pool-kernel.ts +321 -0
  101. package/src/oauth/pool-settings-capability.ts +127 -9
  102. package/src/oauth/store.ts +7 -3
  103. package/src/oauth/token-guardian.ts +1 -1
  104. package/src/providers/codebuddy-models.ts +0 -3
  105. package/src/providers/command-code-efforts.ts +23 -4
  106. package/src/providers/default-aliases.ts +4 -0
  107. package/src/providers/derive.ts +8 -0
  108. package/src/providers/devin-cli-authmode-migration.ts +57 -0
  109. package/src/providers/key-failover.ts +175 -0
  110. package/src/providers/model-rename-startup.ts +21 -1
  111. package/src/providers/qoder-models.ts +0 -1
  112. package/src/providers/quota-key-accounts.ts +50 -0
  113. package/src/providers/quota-routing-cache.ts +65 -6
  114. package/src/providers/quota-types.ts +7 -0
  115. package/src/providers/quota.ts +113 -30
  116. package/src/providers/registry.ts +237 -77
  117. package/src/providers/stale-context-window-migration.ts +92 -0
  118. package/src/providers/zai-responses-migration.ts +45 -0
  119. package/src/quota/reset-observer.ts +2 -1
  120. package/src/quota/reset-seen-store.ts +9 -1
  121. package/src/remote-control/crypto.ts +442 -0
  122. package/src/remote-control/host.ts +175 -0
  123. package/src/remote-control/index.ts +100 -0
  124. package/src/remote-control/protocol.ts +200 -0
  125. package/src/remote-control/relay.ts +162 -0
  126. package/src/remote-control/workspace-agent-protocol.ts +246 -0
  127. package/src/remote-control/workspace-rpc-framing.ts +128 -0
  128. package/src/remote-control/workspace-tools.ts +237 -0
  129. package/src/remote-control/workspace-utf8.ts +24 -0
  130. package/src/responses/custom-tool-compat.ts +23 -0
  131. package/src/router.ts +6 -1
  132. package/src/routing/compatibility/behavior.ts +3 -0
  133. package/src/server/adapter-resolve.ts +6 -2
  134. package/src/server/auth-cors.ts +71 -6
  135. package/src/server/chat-completions.ts +5 -3
  136. package/src/server/chat-native.ts +9 -0
  137. package/src/server/claude-messages.ts +25 -30
  138. package/src/server/context-history.ts +207 -0
  139. package/src/server/images.ts +28 -1
  140. package/src/server/index.ts +42 -22
  141. package/src/server/live.ts +2 -1
  142. package/src/server/management/agent-settings-routes.ts +7 -1
  143. package/src/server/management/config-routes.ts +3 -3
  144. package/src/server/management/grok-coupon-routes.ts +287 -0
  145. package/src/server/management/integration-routes.ts +6 -1
  146. package/src/server/management/oauth-account-routes.ts +124 -7
  147. package/src/server/management/provider-routes.ts +77 -2
  148. package/src/server/management/route-registry.ts +6 -1
  149. package/src/server/management-api.ts +7 -0
  150. package/src/server/request-log.ts +4 -0
  151. package/src/server/responses/codex-ws-exchange.ts +90 -14
  152. package/src/server/responses/codex-ws-wire.ts +51 -1
  153. package/src/server/responses/compact.ts +8 -1
  154. package/src/server/responses/core.ts +188 -22
  155. package/src/server/responses/ws-upstream.ts +1 -1
  156. package/src/server/responses-undeclared-tool-guard.ts +261 -12
  157. package/src/server/zai-responses-startup.ts +21 -0
  158. package/src/types/config.ts +33 -3
  159. package/src/types/provider.ts +54 -4
  160. package/src/types/request.ts +1 -1
  161. package/src/types/tools.ts +36 -6
  162. package/src/update/job.ts +33 -11
  163. package/src/vision/plan.ts +40 -37
  164. package/src/web-search/index.ts +1 -0
  165. package/gui/dist/assets/index-BoBRSehJ.css +0 -1
  166. package/gui/dist/assets/index-Dx0xv2EA.js +0 -115
@@ -17,6 +17,7 @@ import {
17
17
  clearPruneFailure,
18
18
  countSnapshots,
19
19
  findOperation,
20
+ findCommittedOperation,
20
21
  listOperations,
21
22
  markPruneFailure,
22
23
  pruneSnapshots,
@@ -32,6 +33,7 @@ import {
32
33
  deleteRecord,
33
34
  integrationsDir,
34
35
  readRecords,
36
+ readRecordsStrict,
35
37
  writeRecord,
36
38
  type OwnershipRecord,
37
39
  } from "./ownership";
@@ -41,6 +43,7 @@ export interface IntegrationStateStore {
41
43
  /** The integrations directory itself — never a config root to resolve again. */
42
44
  readonly root: string;
43
45
  readRecords(): Partial<Record<IntegrationClientId, OwnershipRecord>>;
46
+ readRecordsStrict(): Partial<Record<IntegrationClientId, OwnershipRecord>>;
44
47
  putRecord(record: OwnershipRecord): void;
45
48
  dropRecord(clientId: IntegrationClientId): void;
46
49
  appendJournal(entry: JournalEntry): void;
@@ -48,6 +51,7 @@ export interface IntegrationStateStore {
48
51
  retireOperation(record: JournalTombstone): void;
49
52
  listOperations(clientId?: IntegrationClientId, limit?: number): JournalEntry[];
50
53
  findOperation(opId: string): JournalEntry | null;
54
+ findCommittedOperation(opId: string): JournalEntry | null;
51
55
  captureSnapshot(clientId: IntegrationClientId, opId: string, text: string | null): SnapshotRef;
52
56
  readSnapshot(entry: JournalEntry):
53
57
  | { kind: "none" }
@@ -79,12 +83,14 @@ export function createIntegrationStateStore(root: string = integrationsDir()): I
79
83
  const store: IntegrationStateStore = {
80
84
  root: dir,
81
85
  readRecords: () => readRecords(dir),
86
+ readRecordsStrict: () => readRecordsStrict(dir),
82
87
  putRecord: record => writeRecord(record, dir),
83
88
  dropRecord: clientId => deleteRecord(clientId, dir),
84
89
  appendJournal: entry => appendOperation(entry, dir),
85
90
  retireOperation: record => appendTombstone(record, dir),
86
91
  listOperations: (clientId, limit) => listOperations(clientId, limit, dir),
87
92
  findOperation: opId => findOperation(opId, dir),
93
+ findCommittedOperation: opId => findCommittedOperation(opId, dir),
88
94
  captureSnapshot: (clientId, opId, text) => captureSnapshot(clientId, opId, text, dir),
89
95
  readSnapshot: entry => readSnapshot(entry, dir),
90
96
  countSnapshots: clientId => countSnapshots(clientId, dir),
@@ -10,6 +10,8 @@
10
10
  * Design of record: devlog/_fin/260802_client_toggle_api/030 and 031.
11
11
  */
12
12
  import { homedir } from "node:os";
13
+ import { createClineIO, ClineTransactionError } from "./cline-io";
14
+ import { parseClineDocument, serializeClineDocument, preserveClineSelection } from "./cline-document";
13
15
  import { dirname } from "node:path";
14
16
  import { EXPORT_CLIENTS, type ExportModel, type ManagedContribution } from "../clients/config-export";
15
17
  import { shouldInjectApiAuthHeader } from "../codex/inject";
@@ -35,7 +37,29 @@ import { serializeDocument, UnserializableValueError } from "./serialize";
35
37
  import { ClientPathError } from "../clients/config-export";
36
38
  import { matchesOperationResult, newOpId, type JournalEntry } from "./journal";
37
39
  import { createIntegrationStateStore, type IntegrationStateStore } from "./store";
38
- import { patchYamlFragmentSource, sourcePrunableYamlContainers } from "./omp-yaml-source";
40
+ import {
41
+ patchYamlFragmentSource,
42
+ sourcePrunableYamlContainers,
43
+ yamlFragmentUnsupportedStyle,
44
+ } from "./omp-yaml-source";
45
+
46
+ /**
47
+ * "comments or formatting" used to be the only refusal this path could report.
48
+ * For a flow-style container that names a cause which is not in the file, and
49
+ * DSH writes that shape itself, so the misdirection was routine rather than
50
+ * exotic: users went looking for a comment that was never there (#4260).
51
+ */
52
+ function yamlRefusalReason(
53
+ source: string,
54
+ path: readonly string[],
55
+ configPath: string,
56
+ outcome: string,
57
+ ): string {
58
+ if (yamlFragmentUnsupportedStyle(source, path)) {
59
+ return `${configPath} writes ${path.join(".")} as a flow mapping or sequence, a YAML style opencodex will not re-render, so ${outcome}`;
60
+ }
61
+ return `${configPath} uses YAML source opencodex cannot patch without risking unrelated comments or formatting, so ${outcome}`;
62
+ }
39
63
  import { withIntegrationWriterLock, type IntegrationWriterLockSeams } from "./writer-lock";
40
64
 
41
65
  export type RefusalReason =
@@ -126,13 +150,19 @@ interface CommitArgs {
126
150
  */
127
151
  function commit(args: CommitArgs): WriteOutcome {
128
152
  const { io, clientId, configPath } = args;
153
+ let transactionStarted = false;
129
154
  try {
155
+ if (io.beginTransaction) {
156
+ io.beginTransaction(args);
157
+ transactionStarted = true;
158
+ }
130
159
  if (args.nextText === null) io.removeFile(configPath);
131
160
  else {
132
161
  io.mkdirp(dirname(configPath));
133
162
  io.writeText(configPath, args.nextText);
134
163
  }
135
164
  } catch (error) {
165
+ if (transactionStarted) return compensate(args, error, "could not write both Cline files");
136
166
  return refuse(clientId, "write_failed", args.state, messageOf(error), args.snapshotPath);
137
167
  }
138
168
  try {
@@ -146,6 +176,7 @@ function commit(args: CommitArgs): WriteOutcome {
146
176
  } catch (error) {
147
177
  return compensate(args, error, "could not append the journal row");
148
178
  }
179
+ io.finishTransaction?.();
149
180
  return {
150
181
  ok: true,
151
182
  changed: true,
@@ -176,6 +207,7 @@ function compensate(args: CommitArgs, cause: unknown, what: string): WriteRefuse
176
207
  message: `${what}, and the change could not be rolled back. The file or its ownership record is in an intermediate state; the backup is at ${args.snapshotPath ?? "(none)"}.`,
177
208
  };
178
209
  }
210
+ io.finishTransaction?.();
179
211
  return refuse(clientId, "write_failed", args.state, `${what}; the change was rolled back. Cause: ${messageOf(cause)}`, args.snapshotPath);
180
212
  }
181
213
 
@@ -198,7 +230,7 @@ function sourcePreservingFragmentValue(
198
230
  /** Shared preflight: detect, gate, read, parse and classify. */
199
231
  function preflight(input: IntegrationWriteInput) {
200
232
  const store = input.store ?? createIntegrationStateStore();
201
- const io = input.io ?? defaultIntegrationIO(store);
233
+ let io = input.io ?? defaultIntegrationIO(store);
202
234
  const clientId = input.clientId;
203
235
  const spec = INTEGRATION_CLIENTS[clientId];
204
236
  const exportSpec = EXPORT_CLIENTS[clientId];
@@ -224,7 +256,11 @@ function preflight(input: IntegrationWriteInput) {
224
256
  const resolved = input.resolvedPaths ?? resolveIntegrationPaths(clientId, input.env, input.home);
225
257
  configPath = resolved.configPath;
226
258
  detectDir = resolved.detectDir;
259
+ if (clientId === "cline") io = createClineIO(io, configPath, store, true);
227
260
  } catch (error) {
261
+ if (error instanceof ClineTransactionError) {
262
+ return { failed: { ...refuse(clientId, "unsafe", "unsafe", error.message, error.snapshotPath), residual: true } } as const;
263
+ }
228
264
  if (!(error instanceof ClientPathError)) throw error;
229
265
  return { failed: refuse(clientId, "unsafe", "unsafe", error.message) } as const;
230
266
  }
@@ -240,7 +276,7 @@ function preflight(input: IntegrationWriteInput) {
240
276
  } as const;
241
277
  }
242
278
  const before = target.before;
243
- const parsed = parseConfig(before, exportSpec.format);
279
+ const parsed = clientId === "cline" ? parseClineDocument(before) : parseConfig(before, exportSpec.format);
244
280
  if (parsed === PARSE_FAILED) {
245
281
  return { failed: refuse(clientId, "unsafe", "unsafe",
246
282
  `${configPath} could not be parsed, or holds something opencodex cannot rewrite without changing it (a non-finite number, a large integer or a tiny one a rewrite would round, -0, a duplicate member, or nesting deeper than 1000 levels)`) } as const;
@@ -387,6 +423,7 @@ function applyOrRefreshIntegration(
387
423
  // every container exists and "did we create this?" is unanswerable.
388
424
  created = createdContainerPaths(base, contribution);
389
425
  const nextDocument = mergeContribution(base, contribution);
426
+ if (clientId === "cline") preserveClineSelection(parsed, nextDocument);
390
427
  if (spec.sourcePreservingYaml && before !== null) {
391
428
  const value = sourcePreservingFragmentValue(contribution, spec.sourcePreservingYaml.path);
392
429
  const patched = value === undefined
@@ -399,11 +436,11 @@ function applyOrRefreshIntegration(
399
436
  );
400
437
  if (patched === null) {
401
438
  return refuse(clientId, "unsafe", "unsafe",
402
- `${configPath} uses YAML source opencodex cannot patch without risking unrelated comments or formatting, so it was left alone`);
439
+ yamlRefusalReason(before, spec.sourcePreservingYaml.path, configPath, "it was left alone"));
403
440
  }
404
441
  text = patched;
405
442
  } else {
406
- text = serializeDocument(nextDocument, exportSpec.format);
443
+ text = clientId === "cline" ? serializeClineDocument(nextDocument) : serializeDocument(nextDocument, exportSpec.format);
407
444
  }
408
445
  } catch (error) {
409
446
  if (error instanceof AmbiguousSelectorError) {
@@ -536,7 +573,7 @@ export function disableIntegration(input: IntegrationWriteInput): WriteOutcome {
536
573
  : recordedCreated;
537
574
  if (prunableCreated === null) {
538
575
  return refuse(clientId, "unsafe", "unsafe",
539
- `${configPath} uses YAML source opencodex cannot patch without risking unrelated comments or formatting, so nothing was removed`);
576
+ yamlRefusalReason(before ?? "", spec.sourcePreservingYaml!.path, configPath, "nothing was removed"));
540
577
  }
541
578
  let doc: unknown;
542
579
  let removed: boolean;
@@ -559,11 +596,11 @@ export function disableIntegration(input: IntegrationWriteInput): WriteOutcome {
559
596
  }, doc);
560
597
  if (patched === null) {
561
598
  return refuse(clientId, "unsafe", "unsafe",
562
- `${configPath} uses YAML source opencodex cannot patch without risking unrelated comments or formatting, so nothing was removed`);
599
+ yamlRefusalReason(before, spec.sourcePreservingYaml.path, configPath, "nothing was removed"));
563
600
  }
564
601
  text = patched;
565
602
  } else {
566
- text = serializeDocument(doc, exportSpec.format);
603
+ text = clientId === "cline" ? serializeClineDocument(doc) : serializeDocument(doc, exportSpec.format);
567
604
  }
568
605
  } catch (error) {
569
606
  if (!(error instanceof UnserializableValueError)) throw error;
@@ -593,7 +630,7 @@ export function disableIntegration(input: IntegrationWriteInput): WriteOutcome {
593
630
 
594
631
  export function restoreIntegration(input: IntegrationRestoreInput): WriteOutcome {
595
632
  const store = input.store ?? createIntegrationStateStore();
596
- const io = input.io ?? defaultIntegrationIO(store);
633
+ let io = input.io ?? defaultIntegrationIO(store);
597
634
  const entry = store.findOperation(input.opId);
598
635
  if (!entry) throw new Error(`unknown operation ${input.opId}`);
599
636
  if (entry.clientId !== input.clientId) throw new Error("restore input names a different client than the operation");
@@ -609,6 +646,13 @@ export function restoreIntegration(input: IntegrationRestoreInput): WriteOutcome
609
646
  return refuse(clientId, "conflict", "conflict",
610
647
  `that operation was recorded for ${configPath}, but this client now resolves to ${resolvedPath}`);
611
648
  }
649
+ if (clientId === "cline") {
650
+ try { io = createClineIO(io, configPath, store, true); }
651
+ catch (error) {
652
+ if (!(error instanceof ClineTransactionError)) throw error;
653
+ return { ...refuse(clientId, "unsafe", "unsafe", error.message, error.snapshotPath), residual: true };
654
+ }
655
+ }
612
656
  const snapshot = store.readSnapshot(entry);
613
657
  if (snapshot.kind === "expired") {
614
658
  return refuse(clientId, "snapshot_expired", "absent", "that backup has expired");
@@ -3,7 +3,7 @@ import { jcsStringify } from "../digest";
3
3
  import type { LabBehaviorSource, LabBehaviorValues } from "../live/types";
4
4
 
5
5
  const CLOSED_KEYS = new Set([
6
- "wire.adapter", "wire.upstreamProtocol", "wire.responsesPath", "wire.commandCodeVersion", "wire.modelSuffixMode",
6
+ "wire.adapter", "wire.upstreamProtocol", "wire.responsesPath", "wire.chatCompletionsPath", "wire.commandCodeVersion", "wire.modelSuffixMode",
7
7
  "auth.mode", "auth.transport",
8
8
  "responses.stateful", "responses.upstreamStreaming", "responses.serviceTier", "responses.fastWireKind", "responses.fastWireValue", "responses.snapshotRepair", "responses.itemIdRepair",
9
9
  "limits.contextWindow", "limits.maxInputTokens", "limits.maxOutputTokens",
package/src/lib/abort.ts CHANGED
@@ -144,3 +144,39 @@ export function cancelBodyOnAbort(body: ReadableStream<Uint8Array> | null, signa
144
144
  signal.addEventListener("abort", onAbort, { once: true });
145
145
  return () => signal.removeEventListener("abort", onAbort);
146
146
  }
147
+
148
+ /**
149
+ * Compose multiple AbortSignals into a single signal that aborts when ANY input
150
+ * aborts. Uses `AbortSignal.any` when available (Node >=20.3 / Bun >=1.0);
151
+ * falls back to a manual implementation for older runtimes.
152
+ *
153
+ * The caller gets a `cleanup` alongside the signal and must call it once the
154
+ * work it guards has settled. `{ once: true }` only removes a listener that
155
+ * actually fired, so on the polyfill path a long-lived parent signal - a proxy
156
+ * session's, say - accumulates one listener per request until it aborts or the
157
+ * process exits. `signalWithTimeout` in this file has always had that
158
+ * discipline; this function did not.
159
+ */
160
+ export function anySignal(signals: AbortSignal[]): { signal: AbortSignal; cleanup: () => void } {
161
+ const builtin = (AbortSignal as unknown as { any?: (s: AbortSignal[]) => AbortSignal }).any;
162
+ if (typeof builtin === "function") return { signal: builtin(signals), cleanup: () => {} };
163
+ const controller = new AbortController();
164
+ const listeners: Array<[AbortSignal, () => void]> = [];
165
+ const cleanup = (): void => {
166
+ for (const [source, handler] of listeners.splice(0)) source.removeEventListener("abort", handler);
167
+ };
168
+ const onAbort = (reason: unknown): void => {
169
+ if (!controller.signal.aborted) controller.abort(reason);
170
+ cleanup();
171
+ };
172
+ for (const s of signals) {
173
+ if (s.aborted) {
174
+ onAbort(s.reason);
175
+ break;
176
+ }
177
+ const handler = () => onAbort(s.reason);
178
+ listeners.push([s, handler]);
179
+ s.addEventListener("abort", handler);
180
+ }
181
+ return { signal: controller.signal, cleanup };
182
+ }
@@ -7,7 +7,7 @@
7
7
  * 1. `localManagementOrigin` — authenticated management discovery/state (`/api/*`). It is
8
8
  * served by the public listener and, on a hub, additionally by the loopback-only
9
9
  * `hub.managementIngress`. Callers must still send a management credential: management
10
- * authentication has no loopback bypass (structure/05), and the unauthenticated loopback
10
+ * authentication has no loopback bypass (structure/gui-and-management-api.md), and the unauthenticated loopback
11
11
  * listener deliberately does not serve `/api/*` at all.
12
12
  * 2. `localInferenceDestination` — the data plane a client wire actually speaks.
13
13
  *
@@ -16,6 +16,39 @@
16
16
  */
17
17
  import { clearableDeadline } from "./abort";
18
18
 
19
+ /**
20
+ * Responses the origin may already be executing. RFC 9110 §9.2.2 forbids an intermediary
21
+ * from automatically repeating a non-idempotent request; when a post-send transport (the
22
+ * Codex WebSocket relay) settles a gateway status because the origin never acknowledged the
23
+ * turn, the request body must not be sent again by this process — not by the transient-5xx
24
+ * layer below, not by a pool account rotation, not by a combo hop. The status is returned
25
+ * to the caller so the user agent can apply its own retry policy, exactly as it does on the
26
+ * direct path. The WeakSet is the in-process marker; the structured error codes are the
27
+ * marker that survives body re-wrapping (combo failure consumption re-parses the JSON).
28
+ */
29
+ const nonReplayableResponses = new WeakSet<Response>();
30
+
31
+ export function markResponseNonReplayable(response: Response): void {
32
+ nonReplayableResponses.add(response);
33
+ }
34
+
35
+ export function isNonReplayableResponse(response: Response): boolean {
36
+ return nonReplayableResponses.has(response);
37
+ }
38
+
39
+ /** Origin never produced a response event; the turn may still be executing. */
40
+ export const UPSTREAM_NO_RESPONSE_CODE = "upstream_no_response";
41
+ /** Transport closed after the send, before any response event. */
42
+ export const UPSTREAM_CLOSED_BEFORE_RESPONSE_CODE = "upstream_closed_before_response";
43
+ const NON_REPLAYABLE_UPSTREAM_CODES: ReadonlySet<string> = new Set([
44
+ UPSTREAM_NO_RESPONSE_CODE,
45
+ UPSTREAM_CLOSED_BEFORE_RESPONSE_CODE,
46
+ ]);
47
+
48
+ export function isNonReplayableUpstreamCode(code: unknown): boolean {
49
+ return typeof code === "string" && NON_REPLAYABLE_UPSTREAM_CODES.has(code);
50
+ }
51
+
19
52
  // 1 initial + 2 retries: the pool may hold more than one stale socket.
20
53
  const RESET_RETRY_MAX_ATTEMPTS = 3;
21
54
  const RESET_RETRY_BASE_DELAY_MS = 150;
@@ -395,7 +428,9 @@ export async function fetchWithTransientRetry(
395
428
  let attemptStart = Date.now();
396
429
  let res = await fetchWithResetRetry(countedFetch, { ...opts, attempts: remaining() });
397
430
  for (let attempt = 0; sent < budget; attempt++) {
398
- if (res.ok || !isTransientUpstreamStatus(res.status)) return res;
431
+ // A non-replayable gateway status was settled after the request body had already left
432
+ // for the origin; retrying it here is the automatic resend the marker exists to forbid.
433
+ if (res.ok || !isTransientUpstreamStatus(res.status) || isNonReplayableResponse(res)) return res;
399
434
  // Checked before cancelResponseBodyBestEffort so an already-aborted caller never receives
400
435
  // a response whose body we just cancelled.
401
436
  if (opts.abortSignal?.aborted) return res;
@@ -67,6 +67,17 @@ function headroomOf(provider: string, accountId: string): number | null {
67
67
  return 100 - Math.max(...percents);
68
68
  }
69
69
 
70
+ /**
71
+ * Remaining headroom percent for one account, or null when nothing has measured it.
72
+ *
73
+ * Exported for the generic fill-first threshold, which needs the measurement itself rather
74
+ * than an ordering. Null stays null all the way out: a caller must decide what "unmeasured"
75
+ * means for its own rule instead of being handed a fabricated 0 or 100.
76
+ */
77
+ export function accountHeadroomPercent(provider: string, accountId: string): number | null {
78
+ return headroomOf(provider, accountId);
79
+ }
80
+
70
81
  /** Unknown usage is not exhaustion; Kiro's explicit overage verdict is authoritative. */
71
82
  export function isAccountQuotaExhausted(provider: string, accountId: string): boolean {
72
83
  const exhaustion = provider === "kiro" ? getKiroAccountExhaustion(`${provider}\u0000${accountId}`) : null;
@@ -37,6 +37,14 @@ function errorHtml(message: string): string {
37
37
 
38
38
  export type CallbackResult = { code: string; state: string };
39
39
 
40
+ /** Close every response so pooled sockets cannot send the next login to a retired flow. */
41
+ function closingResponse(body: string, status: number, contentType = "text/html"): Response {
42
+ return new Response(body, {
43
+ status,
44
+ headers: { "Content-Type": contentType, "Connection": "close" },
45
+ });
46
+ }
47
+
40
48
  /**
41
49
  * The redirect URI advertised to providers must stay `localhost` (it is what the OAuth
42
50
  * apps have registered), but Windows commonly resolves `localhost` to `::1` first while
@@ -177,7 +185,7 @@ export abstract class OAuthCallbackFlow {
177
185
  #handleCallback(req: Request, expectedState: string): Response {
178
186
  const url = new URL(req.url);
179
187
  if (url.pathname !== this.callbackPath) {
180
- return new Response("Not Found", { status: 404 });
188
+ return closingResponse("Not Found", 404, "text/plain");
181
189
  }
182
190
 
183
191
  const code = url.searchParams.get("code");
@@ -214,10 +222,7 @@ export abstract class OAuthCallbackFlow {
214
222
  });
215
223
  }
216
224
 
217
- return new Response(ok ? SUCCESS_HTML : errorHtml(errMessage), {
218
- status: ok ? 200 : consumeFlow ? 500 : 400,
219
- headers: { "Content-Type": "text/html" },
220
- });
225
+ return closingResponse(ok ? SUCCESS_HTML : errorHtml(errMessage), ok ? 200 : consumeFlow ? 500 : 400);
221
226
  }
222
227
 
223
228
  #waitForCallback(expectedState: string): Promise<CallbackResult> {
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Allowlist for the Cognition/Devin api-server origin.
3
+ *
4
+ * RegisterUser returns the tenant's api-server host, and that host then receives
5
+ * GetUserJwt, GetCascadeModelConfigs and GetChatMessage - the first of which
6
+ * carries the long-lived api_key. A host taken from the network without
7
+ * validation turns a spoofed or compromised RegisterUser response into
8
+ * credential exfiltration, so every value that reaches a request URL or the
9
+ * credential store passes through here first.
10
+ *
11
+ * This lives in its own module rather than in `../devin.ts` because the
12
+ * credential store imports the validator and `../devin.ts` imports the store's
13
+ * sibling types; a shared leaf keeps that from becoming a cycle.
14
+ */
15
+
16
+ export const DEVIN_DEFAULT_API_SERVER = "https://server.codeium.com";
17
+
18
+ /**
19
+ * Return the normalized api-server base URL, or undefined when the input is not
20
+ * an allowlisted Cognition host.
21
+ *
22
+ * Unlike the Copilot equivalent this keeps the path. EU and FedStart tenants are
23
+ * reached at `https://eu.windsurf.com/_route/api_server`, so the path prefix is
24
+ * part of the address rather than decoration, and normalizing to the origin
25
+ * would silently point those accounts at the wrong service.
26
+ */
27
+ export function validateDevinApiBaseUrl(raw: string | undefined | null): string | undefined {
28
+ if (raw === undefined || raw === null) return undefined;
29
+ const trimmed = String(raw).trim();
30
+ if (!trimmed) return undefined;
31
+ let parsed: URL;
32
+ try {
33
+ parsed = new URL(trimmed);
34
+ } catch {
35
+ return undefined;
36
+ }
37
+ if (parsed.protocol !== "https:") return undefined;
38
+ if (parsed.username || parsed.password) return undefined;
39
+ if (parsed.port && parsed.port !== "443") return undefined;
40
+ if (parsed.search || parsed.hash) return undefined;
41
+ const host = parsed.hostname.toLowerCase();
42
+ if (host === "localhost" || host.endsWith(".localhost")) return undefined;
43
+ if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host) || host.includes(":")) return undefined;
44
+ const allowed =
45
+ host === "server.codeium.com" ||
46
+ // The shipped client (Devin Desktop 3.9.19,
47
+ // Contents/Resources/app/extensions/windsurf/dist/extension.js) also names
48
+ // these two, and a beta account's RegisterUser can return one.
49
+ host === "server-staging.codeium.com" ||
50
+ host === "server-beta.codeium.com" ||
51
+ host === "windsurf.com" ||
52
+ host.endsWith(".windsurf.com") ||
53
+ host === "windsurf.fedstart.com";
54
+ if (!allowed) return undefined;
55
+ const path = parsed.pathname.replace(/\/+$/, "");
56
+ if (path && !/^(\/[A-Za-z0-9._-]+)+$/.test(path)) return undefined;
57
+ return `https://${host}${path}`;
58
+ }
59
+
60
+ /** Same check, falling back to the default US host when the input is unusable. */
61
+ export function resolveDevinApiBaseUrl(raw: string | undefined | null): string {
62
+ return validateDevinApiBaseUrl(raw) ?? DEVIN_DEFAULT_API_SERVER;
63
+ }
@@ -0,0 +1 @@
1
+ export { loginDevin } from "../devin";
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Exchange a Firebase ID token for a long-lived Cognition/Devin API key.
3
+ *
4
+ * This calls the same Connect-RPC endpoint the Devin desktop client uses
5
+ * after browser sign-in completes:
6
+ *
7
+ * POST https://register.windsurf.com/exa.seat_management_pb.SeatManagementService/RegisterUser
8
+ * Content-Type: application/json
9
+ * Body: { "firebase_id_token": "<jwt>" }
10
+ *
11
+ * Connect-RPC happily accepts plain JSON over HTTPS (no gRPC framing required),
12
+ * so we skip @connectrpc/connect entirely and use `fetch`. The response shape
13
+ * matches `exa.seat_management_pb.RegisterUserResponse`:
14
+ *
15
+ * { api_key, name, api_server_url, redirect_url, team_options[] }
16
+ */
17
+
18
+ import type { OAuthLoginResult, WindsurfRegion } from './types.js';
19
+ import { anySignal } from '../../lib/abort.js';
20
+ import { validateDevinApiBaseUrl } from './api-base.js';
21
+
22
+ interface RegisterUserResponseJson {
23
+ api_key?: string;
24
+ name?: string;
25
+ api_server_url?: string;
26
+ redirect_url?: string;
27
+ team_options?: unknown[];
28
+ }
29
+
30
+ interface ConnectErrorJson {
31
+ code?: string;
32
+ message?: string;
33
+ }
34
+
35
+ export class WindsurfRegistrationError extends Error {
36
+ readonly status: number;
37
+ readonly connectCode?: string;
38
+ readonly traceId?: string;
39
+
40
+ constructor(message: string, status: number, connectCode?: string, traceId?: string) {
41
+ super(message);
42
+ this.name = 'WindsurfRegistrationError';
43
+ this.status = status;
44
+ this.connectCode = connectCode;
45
+ this.traceId = traceId;
46
+ }
47
+ }
48
+
49
+ const TRACE_ID_RE = /\(trace ID: ([0-9a-f]+)\)/i;
50
+
51
+ /**
52
+ * Connect error codes that are safe to repeat to the user.
53
+ *
54
+ * The message body is not: a Connect error can echo the request, and the
55
+ * request here is the Firebase ID token. That message reaches CLI output and
56
+ * /api/logs, and redactSecretString does not recognise a bare JWT, so the code
57
+ * is the only part of an error body that leaves this function.
58
+ */
59
+ const SAFE_CONNECT_CODES = new Set([
60
+ 'canceled', 'unknown', 'invalid_argument', 'deadline_exceeded', 'not_found', 'already_exists',
61
+ 'permission_denied', 'resource_exhausted', 'failed_precondition', 'aborted', 'out_of_range',
62
+ 'unimplemented', 'internal', 'unavailable', 'data_loss', 'unauthenticated',
63
+ ]);
64
+
65
+ function safeConnectCode(value: unknown): string | undefined {
66
+ return typeof value === 'string' && SAFE_CONNECT_CODES.has(value) ? value : undefined;
67
+ }
68
+
69
+ /**
70
+ * Exchange the Firebase ID token for a Windsurf API key.
71
+ *
72
+ * `firebaseIdToken` is the `access_token` (or `firebase_id_token`) value the
73
+ * Windsurf sign-in page returns in the OAuth callback URL — we treat it as
74
+ * opaque.
75
+ */
76
+ export async function registerUser(
77
+ firebaseIdToken: string,
78
+ region: WindsurfRegion,
79
+ abortSignal?: AbortSignal,
80
+ ): Promise<OAuthLoginResult> {
81
+ if (!firebaseIdToken) {
82
+ throw new WindsurfRegistrationError('Empty firebase_id_token', 0, 'invalid_argument');
83
+ }
84
+
85
+ // The register host reaches the network holding the Firebase ID token, so it
86
+ // passes the same allowlist as the api-server host rather than being trusted
87
+ // because it came from a config object.
88
+ const registerBase = validateDevinApiBaseUrl(region.registerApiServerUrl);
89
+ if (!registerBase) {
90
+ throw new WindsurfRegistrationError(
91
+ 'Refusing to send the sign-in token to a non-Cognition register host.',
92
+ 0,
93
+ 'permission_denied',
94
+ );
95
+ }
96
+ const url = `${registerBase}/exa.seat_management_pb.SeatManagementService/RegisterUser`;
97
+
98
+ // 30s internal timeout — RegisterUser responds in ~200ms in steady state.
99
+ // CLI users on flaky networks need bounded waits or the sign-in command
100
+ // hangs forever. Compose with the caller's signal via a small polyfill
101
+ // (`anySignal`) because Node 18 / older Bun lack AbortSignal.any; the
102
+ // previous fallback `combinedSignal = abortSignal` would drop the
103
+ // timeout entirely on those runtimes.
104
+ const timeoutSignal = AbortSignal.timeout(30_000);
105
+ const composed = abortSignal ? anySignal([abortSignal, timeoutSignal]) : undefined;
106
+ const combinedSignal: AbortSignal = composed?.signal ?? timeoutSignal;
107
+
108
+ let response: Response;
109
+ try {
110
+ response = await fetch(url, {
111
+ method: 'POST',
112
+ headers: {
113
+ 'Content-Type': 'application/json',
114
+ // Connect protocol version header — not strictly required for JSON, but
115
+ // matches what the official Connect clients send and avoids accidental
116
+ // routing into a non-Connect HTTP handler.
117
+ 'Connect-Protocol-Version': '1',
118
+ },
119
+ body: JSON.stringify({ firebase_id_token: firebaseIdToken }),
120
+ // A 307/308 would replay this POST, and its body is the sign-in token, at
121
+ // whatever host Location names. Fail instead of following.
122
+ redirect: 'error',
123
+ signal: combinedSignal,
124
+ });
125
+ } finally {
126
+ // Detach from the caller's signal; it can outlive this one exchange.
127
+ composed?.cleanup();
128
+ }
129
+
130
+ const text = await response.text();
131
+
132
+ if (!response.ok) {
133
+ let connectCode: string | undefined;
134
+ let traceId: string | undefined;
135
+ try {
136
+ const errJson = JSON.parse(text) as ConnectErrorJson;
137
+ connectCode = safeConnectCode(errJson.code);
138
+ // The trace id is an opaque server identifier and is the one part of the
139
+ // message worth keeping for a support conversation.
140
+ traceId = typeof errJson.message === 'string' ? errJson.message.match(TRACE_ID_RE)?.[1] : undefined;
141
+ } catch {
142
+ // Non-JSON error body. It stays unread; only the status is reported.
143
+ }
144
+ const message = `RegisterUser failed (HTTP ${response.status}${connectCode ? `, ${connectCode}` : ''}${traceId ? `, trace ${traceId}` : ''})`;
145
+ throw new WindsurfRegistrationError(message, response.status, connectCode, traceId);
146
+ }
147
+
148
+ let parsed: RegisterUserResponseJson;
149
+ try {
150
+ parsed = JSON.parse(text) as RegisterUserResponseJson;
151
+ } catch {
152
+ throw new WindsurfRegistrationError(
153
+ // The body is not echoed: a 200 that fails to parse can still contain the
154
+ // key or the token that produced it.
155
+ `RegisterUser returned 200 with a body that is not JSON (${text.length} bytes)`,
156
+ response.status,
157
+ 'internal',
158
+ );
159
+ }
160
+
161
+ const apiKey = parsed.api_key;
162
+ // Empty `api_server_url` is normal for single-tenant accounts — the desktop
163
+ // extension's `getApiServerUrl` helper falls back to the configured default
164
+ // when this is empty/missing. We mirror that behavior here.
165
+ const apiServerUrl = parsed.api_server_url && parsed.api_server_url.length > 0
166
+ ? parsed.api_server_url
167
+ : 'https://server.codeium.com';
168
+
169
+ if (!apiKey) {
170
+ throw new WindsurfRegistrationError(
171
+ 'RegisterUser returned 200 but api_key was empty',
172
+ response.status,
173
+ 'malformed_response',
174
+ );
175
+ }
176
+ // `name` is optional in the response — default it instead of failing login.
177
+ // src/oauth/devin.ts uses it only as a display label for the account email.
178
+ const name = parsed.name && parsed.name.length > 0 ? parsed.name : 'Devin account';
179
+
180
+ return {
181
+ apiKey,
182
+ name,
183
+ apiServerUrl,
184
+ redirectUrl: parsed.redirect_url,
185
+ };
186
+ }