@bitkyc08/opencodex 2.52.0 → 2.53.0-preview.20260913

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 (236) hide show
  1. package/gui/dist/assets/index-BBOZWGB6.css +1 -0
  2. package/gui/dist/assets/index-D7ynYo2K.js +128 -0
  3. package/gui/dist/index.html +2 -2
  4. package/native/remote-workspace-helper/Cargo.lock +130 -0
  5. package/native/remote-workspace-helper/Cargo.toml +24 -0
  6. package/native/remote-workspace-helper/src/main.rs +49 -0
  7. package/native/remote-workspace-helper/src/protocol.rs +246 -0
  8. package/native/remote-workspace-helper/src/sandbox/macos.rs +19 -0
  9. package/native/remote-workspace-helper/src/sandbox/mod.rs +77 -0
  10. package/native/remote-workspace-helper/src/sandbox/windows.rs +15 -0
  11. package/package.json +6 -1
  12. package/src/adapters/anthropic-image-normalize.ts +30 -2
  13. package/src/adapters/anthropic.ts +1 -1
  14. package/src/adapters/base.ts +8 -2
  15. package/src/adapters/cursor/cursor-errors.ts +12 -0
  16. package/src/adapters/cursor/thread-continuity.ts +93 -0
  17. package/src/adapters/cursor.ts +104 -73
  18. package/src/adapters/devin/cloud-direct/chat.ts +312 -23
  19. package/src/adapters/devin/cloud-direct/metadata.ts +31 -2
  20. package/src/adapters/devin/live-models.ts +70 -3
  21. package/src/adapters/devin.ts +281 -21
  22. package/src/adapters/google-wire-compiler.ts +14 -6
  23. package/src/adapters/google.ts +22 -8
  24. package/src/adapters/kiro/adapter.ts +316 -0
  25. package/src/adapters/kiro/conversation.ts +136 -0
  26. package/src/adapters/kiro/payload.ts +432 -0
  27. package/src/adapters/kiro/reasoning.ts +56 -0
  28. package/src/adapters/kiro/stream.ts +1153 -0
  29. package/src/adapters/kiro/usage.ts +223 -0
  30. package/src/adapters/kiro/wire.ts +76 -0
  31. package/src/adapters/kiro.ts +8 -2319
  32. package/src/adapters/mimo-free.ts +1 -1
  33. package/src/adapters/openai-chat-images.ts +101 -0
  34. package/src/adapters/openai-chat.ts +201 -181
  35. package/src/adapters/openai-responses.ts +92 -224
  36. package/src/adapters/registry.ts +0 -7
  37. package/src/adapters/run-turn-queue.ts +13 -6
  38. package/src/bridge.ts +14 -15
  39. package/src/chat/inbound.ts +29 -4
  40. package/src/chat/outbound.ts +145 -107
  41. package/src/claude/desktop-profile.ts +4 -6
  42. package/src/cli/account-api.ts +14 -0
  43. package/src/cli/account-extended.ts +1 -1
  44. package/src/cli/account-history.ts +60 -0
  45. package/src/cli/account-main.ts +80 -0
  46. package/src/cli/account.ts +11 -3
  47. package/src/cli/capabilities.ts +113 -0
  48. package/src/cli/catalog.ts +109 -0
  49. package/src/cli/dispatch.ts +9 -0
  50. package/src/cli/help.ts +2 -0
  51. package/src/cli/index.ts +2 -2
  52. package/src/cli/observe.ts +28 -1
  53. package/src/cli/opencode.ts +42 -8
  54. package/src/cli/provider-runtime.ts +11 -1
  55. package/src/cli/provider.ts +22 -2
  56. package/src/cli/registry.ts +21 -0
  57. package/src/cli/remote-workspace.ts +154 -0
  58. package/src/cli/status.ts +39 -7
  59. package/src/cli/usage-report.ts +14 -2
  60. package/src/client/hub-client.ts +34 -0
  61. package/src/client/hub-state.ts +9 -1
  62. package/src/codex/account-store.ts +78 -0
  63. package/src/codex/auth-api.ts +81 -54
  64. package/src/codex/auth-context.ts +45 -16
  65. package/src/codex/catalog/effort.ts +1 -1
  66. package/src/codex/catalog/metadata.ts +3 -6
  67. package/src/codex/catalog/native-models.ts +4 -4
  68. package/src/codex/catalog/parsing.ts +2 -20
  69. package/src/codex/catalog/provider-fetch.ts +10 -1
  70. package/src/codex/catalog/remote.ts +233 -0
  71. package/src/codex/catalog/sync.ts +403 -35
  72. package/src/codex/convergence.ts +1 -1
  73. package/src/codex/history-manifest.ts +36 -0
  74. package/src/codex/history-provider.ts +32 -5
  75. package/src/codex/inject.ts +9 -0
  76. package/src/codex/main-account.ts +113 -0
  77. package/src/codex/main-device-reauth-api.ts +89 -0
  78. package/src/codex/main-device-reauth.ts +217 -0
  79. package/src/codex/native-residue.ts +9 -2
  80. package/src/codex/quota-auto-refresh.ts +3 -2
  81. package/src/codex/quota-capacity.ts +98 -0
  82. package/src/codex/quota-history.ts +160 -0
  83. package/src/codex/quota-types.ts +8 -0
  84. package/src/codex/quota.ts +118 -91
  85. package/src/codex/refresh.ts +2 -1
  86. package/src/codex/routing.ts +90 -17
  87. package/src/codex/sync.ts +33 -4
  88. package/src/combos/request.ts +19 -1
  89. package/src/config/multi-agent-surface.ts +61 -0
  90. package/src/config/provider-validation.ts +176 -0
  91. package/src/config.ts +213 -11
  92. package/src/generated/compatibility-version.json +436 -168
  93. package/src/images/loop.ts +119 -36
  94. package/src/lib/admission.ts +12 -6
  95. package/src/lib/redact.ts +7 -0
  96. package/src/lib/translator-budget.ts +4 -3
  97. package/src/lib/windows-atomic-replace.ts +1 -0
  98. package/src/lib/windows-elevation.ts +1 -1
  99. package/src/oauth/chatgpt-device.ts +62 -5
  100. package/src/oauth/devin/cli-import.ts +130 -0
  101. package/src/oauth/devin.ts +63 -8
  102. package/src/oauth/index.ts +29 -14
  103. package/src/oauth/kiro.ts +18 -6
  104. package/src/oauth/login-cli.ts +9 -1
  105. package/src/oauth/meta-muse-device.ts +464 -0
  106. package/src/oauth/meta-muse.ts +123 -32
  107. package/src/oauth/pool-kernel.ts +9 -0
  108. package/src/oauth/pool-settings-capability.ts +2 -2
  109. package/src/oauth/store.ts +57 -0
  110. package/src/oauth/types.ts +31 -0
  111. package/src/providers/derive.ts +13 -3
  112. package/src/providers/devin-cli-authmode-migration.ts +57 -35
  113. package/src/providers/devin-provider-merge-migration.ts +240 -0
  114. package/src/providers/muse-key-quota.ts +117 -0
  115. package/src/providers/muse-subscription-usage.ts +14 -2
  116. package/src/providers/openai-sidecar.ts +25 -3
  117. package/src/providers/opencode-zen-rate-limit.ts +58 -0
  118. package/src/providers/provider-id-rewrite.ts +20 -5
  119. package/src/providers/quota-types.ts +12 -0
  120. package/src/providers/quota.ts +143 -102
  121. package/src/providers/reasoning-metadata.ts +543 -0
  122. package/src/providers/registry.ts +80 -49
  123. package/src/reasoning-effort.ts +26 -2
  124. package/src/remote/hub-usage.ts +32 -0
  125. package/src/remote-control/index.ts +192 -41
  126. package/src/remote-control/workspace-activation.ts +9 -0
  127. package/src/remote-control/workspace-agent-connection.ts +366 -0
  128. package/src/remote-control/workspace-claude-runtime.ts +243 -0
  129. package/src/remote-control/workspace-codex-runtime.ts +531 -0
  130. package/src/remote-control/workspace-codex-sandbox.ts +115 -0
  131. package/src/remote-control/workspace-command-runner.ts +748 -0
  132. package/src/remote-control/workspace-coordinator.ts +231 -0
  133. package/src/remote-control/workspace-device.ts +585 -0
  134. package/src/remote-control/workspace-executable.ts +43 -0
  135. package/src/remote-control/workspace-executor.ts +397 -0
  136. package/src/remote-control/workspace-hub.ts +519 -0
  137. package/src/remote-control/workspace-pi-runtime.ts +382 -0
  138. package/src/remote-control/workspace-process.ts +129 -0
  139. package/src/remote-control/workspace-rpc.ts +304 -0
  140. package/src/remote-control/workspace-runtime.ts +60 -0
  141. package/src/remote-control/workspace-secret-store.ts +39 -0
  142. package/src/remote-control/workspace-sessions.ts +799 -0
  143. package/src/remote-control/workspace-tool-bridge.ts +192 -0
  144. package/src/responses/code-mode-helper-compat.ts +22 -3
  145. package/src/responses/hosted-tool-policy.ts +0 -1
  146. package/src/responses/muse-tool-name-alias.ts +379 -0
  147. package/src/responses/plaintext-v2-agent-messages.ts +902 -0
  148. package/src/router.ts +7 -0
  149. package/src/routing/compatibility/behavior.ts +0 -1
  150. package/src/server/audio-client.ts +64 -0
  151. package/src/server/audio-dictation.ts +91 -0
  152. package/src/server/audio-live.ts +185 -0
  153. package/src/server/audio-transcriptions.ts +183 -0
  154. package/src/server/audio-upstream.ts +153 -0
  155. package/src/server/auth-cors.ts +61 -2
  156. package/src/server/chat-completions.ts +1 -1
  157. package/src/server/chat-native-sse.ts +92 -48
  158. package/src/server/chat-native.ts +37 -15
  159. package/src/server/hub-usage.ts +57 -0
  160. package/src/server/images.ts +4 -0
  161. package/src/server/index.ts +722 -57
  162. package/src/server/lifecycle.ts +5 -6
  163. package/src/server/live-call-bindings.ts +60 -0
  164. package/src/server/live.ts +12 -1
  165. package/src/server/management/agent-settings-routes.ts +25 -4
  166. package/src/server/management/api-access.ts +37 -0
  167. package/src/server/management/api-key-usage.ts +7 -2
  168. package/src/server/management/config-routes.ts +1 -18
  169. package/src/server/management/context.ts +15 -0
  170. package/src/server/management/logs-usage-routes.ts +2 -0
  171. package/src/server/management/oauth-account-routes.ts +39 -12
  172. package/src/server/management/provider-routes.ts +125 -2
  173. package/src/server/management/remote-workspace-routes.ts +140 -0
  174. package/src/server/management/route-registry.ts +15 -0
  175. package/src/server/management/usage-aggregate-cache.ts +14 -15
  176. package/src/server/management/usage-summary-cache.ts +2 -0
  177. package/src/server/management-api.ts +23 -0
  178. package/src/server/ports.ts +17 -0
  179. package/src/server/relay-eager.ts +4 -1
  180. package/src/server/relay.ts +70 -10
  181. package/src/server/request-decompress.ts +6 -3
  182. package/src/server/responses/agent-task-recovery.ts +25 -32
  183. package/src/server/responses/codex-auth-error.ts +11 -0
  184. package/src/server/responses/codex-ws-exchange.ts +52 -3
  185. package/src/server/responses/codex-ws-wire.ts +55 -0
  186. package/src/server/responses/compact.ts +9 -1
  187. package/src/server/responses/core.ts +337 -73
  188. package/src/server/responses/encrypted-payload.ts +45 -2
  189. package/src/server/responses/ws-upstream.ts +4 -1
  190. package/src/server/responses-self-named-namespace-scrub.ts +1 -3
  191. package/src/server/responses-undeclared-tool-guard.ts +1 -1
  192. package/src/server/search.ts +3 -0
  193. package/src/server/sse-payload-rewrite.ts +136 -51
  194. package/src/server/ws-bridge.ts +35 -1
  195. package/src/service/cli.ts +372 -0
  196. package/src/service/diagnostics.ts +340 -0
  197. package/src/service/guards.ts +303 -0
  198. package/src/service/health.ts +222 -0
  199. package/src/service/launchd.ts +853 -0
  200. package/src/service/orchestration.ts +617 -0
  201. package/src/service/repair.ts +334 -0
  202. package/src/service/state.ts +363 -0
  203. package/src/service/systemd.ts +229 -0
  204. package/src/service/windows-ops.ts +690 -0
  205. package/src/service/windows-scheduler.ts +769 -0
  206. package/src/service/windows-taskxml.ts +613 -0
  207. package/src/service.ts +22 -5550
  208. package/src/storage/cleanup/db.ts +258 -0
  209. package/src/storage/cleanup/execute.ts +358 -0
  210. package/src/storage/cleanup/paths.ts +189 -0
  211. package/src/storage/cleanup/pending.ts +140 -0
  212. package/src/storage/cleanup/preview.ts +292 -0
  213. package/src/storage/cleanup/reconcile.ts +347 -0
  214. package/src/storage/cleanup/restore.ts +932 -0
  215. package/src/storage/cleanup/satellite.ts +474 -0
  216. package/src/storage/cleanup/staging.ts +129 -0
  217. package/src/storage/cleanup/types.ts +98 -0
  218. package/src/storage/cleanup.ts +49 -3127
  219. package/src/types/accounts.ts +2 -0
  220. package/src/types/config.ts +13 -12
  221. package/src/types/provider.ts +37 -0
  222. package/src/types/request.ts +2 -0
  223. package/src/types/tools.ts +17 -5
  224. package/src/types.ts +1 -0
  225. package/src/usage/expected-prices.ts +127 -0
  226. package/src/usage/log.ts +58 -1
  227. package/src/vision/eligibility.ts +13 -2
  228. package/src/web-search/loop.ts +56 -3
  229. package/gui/dist/assets/index-CWXut3rG.js +0 -115
  230. package/gui/dist/assets/index-EdoPnm9_.css +0 -1
  231. package/src/adapters/devin-cli/acp.ts +0 -204
  232. package/src/adapters/devin-cli/adapter.ts +0 -345
  233. package/src/adapters/devin-cli/binary.ts +0 -69
  234. package/src/adapters/devin-cli/models.ts +0 -57
  235. package/src/oauth/devin-cli.ts +0 -149
  236. package/src/server/responses-reasoning-summary-rewrite.ts +0 -178
@@ -24,7 +24,9 @@ import { readBoundedResponseBody } from "../lib/bounded-body";
24
24
  import { applyUpstreamRecoveryInit, fetchWithResetRetry, prepareSameTarget429Wait } from "../lib/upstream-retry";
25
25
  import { rateLimitRetryDelayMs } from "../providers/key-failover";
26
26
  import {
27
+ createTranslatorBudget,
27
28
  isTranslatorBudgetExceededError,
29
+ TRANSLATOR_MAX_CALL_ARGUMENT_BYTES,
28
30
  TRANSLATOR_MAX_TURN_BYTES,
29
31
  TranslatorBudgetExceededError,
30
32
  } from "../lib/translator-budget";
@@ -101,6 +103,57 @@ interface ImageCall {
101
103
  providerMetadata?: OcxProviderOpaqueToolCallMetadata;
102
104
  }
103
105
 
106
+ /** Independent retention owner: adapter leases and final SSE buffers have separate lifetimes. */
107
+ function createIterationEventBudget() {
108
+ const budget = createTranslatorBudget();
109
+ let firstEvent = true;
110
+ let argumentBytes: number | undefined;
111
+ let trailingHighSurrogate = false;
112
+ return {
113
+ retain(event: AdapterEvent, coalescedIntoTail = false): void {
114
+ // Heartbeats are never retained or passed to scanEventsForImageCall.
115
+ if (event.type === "heartbeat") return;
116
+ if (event.type === "tool_call_start") {
117
+ argumentBytes = 0;
118
+ trailingHighSurrogate = false;
119
+ } else if (event.type === "tool_call_delta" && argumentBytes !== undefined) {
120
+ const chunk = event.arguments;
121
+ argumentBytes += Buffer.byteLength(chunk);
122
+ // A surrogate pair may straddle adapter deltas; count the concatenated UTF-8 string.
123
+ if (trailingHighSurrogate && /^[\uDC00-\uDFFF]/.test(chunk)) argumentBytes -= 2;
124
+ if (chunk.length > 0) trailingHighSurrogate = /[\uD800-\uDBFF]$/.test(chunk);
125
+ if (argumentBytes > TRANSLATOR_MAX_CALL_ARGUMENT_BYTES) {
126
+ throw new TranslatorBudgetExceededError("tool_args", TRANSLATOR_MAX_CALL_ARGUMENT_BYTES);
127
+ }
128
+ } else {
129
+ argumentBytes = undefined;
130
+ trailingHighSurrogate = false;
131
+ }
132
+ // A delta the queue merged into its buffered tail leaves one object behind, not two,
133
+ // so it costs the appended payload rather than another envelope. Charging the whole
134
+ // event here would bill over 32 MiB for the ~1 MiB that a million one-character text
135
+ // deltas actually retain, and abort a turn far below the documented limit.
136
+ const appended = coalescedIntoTail
137
+ ? event.type === "text_delta" ? event.text : event.type === "thinking_delta" ? event.thinking : undefined
138
+ : undefined;
139
+ if (appended !== undefined) {
140
+ // JSON escaping is per character, so the tail grows by the quoted form minus its
141
+ // quotes. A surrogate pair split across two deltas is the one over-count, by eight
142
+ // bytes, which bounds memory conservatively and never under-charges.
143
+ budget.chargeRetained(Buffer.byteLength(JSON.stringify(appended)) - 2, {
144
+ kind: "retained_collectors",
145
+ });
146
+ return;
147
+ }
148
+ budget.chargeRetained(Buffer.byteLength(JSON.stringify(event)) + (firstEvent ? 2 : 1), {
149
+ kind: "retained_collectors",
150
+ });
151
+ firstEvent = false;
152
+ },
153
+ dispose: () => budget.dispose(),
154
+ };
155
+ }
156
+
104
157
  /**
105
158
  * Split an iteration's adapter events into (a) the image-generation tool calls to intercept and
106
159
  * (b) the events to pass through to Codex. An image tool-call's own start/delta/end events are
@@ -370,6 +423,8 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise<Respons
370
423
  interface IterationResponse {
371
424
  response: Response;
372
425
  responseAdapter: ProviderAdapter;
426
+ /** runTurn events are already bounded; consume them without replaying or charging twice. */
427
+ collectedEvents?: AdapterEvent[];
373
428
  }
374
429
  type IterationSplit = ReturnType<typeof scanEventsForImageCall>;
375
430
 
@@ -397,29 +452,31 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise<Respons
397
452
 
398
453
  // runTurn adapters (Cursor) own all upstream communication via an emit callback. They don't
399
454
  // expose buildRequest/fetchResponse/parseStream to the bridge, so collect their events through
400
- // an AdapterEventQueue and wrap them in a pseudo-response whose parseStream replays them.
455
+ // an AdapterEventQueue and pass the bounded collection directly to the common scanner.
401
456
  if (adapter.runTurn) {
402
457
  await deps.waitForRequestSlot?.(signal);
403
458
  const queue = createAdapterEventQueue({
404
459
  onBacklogExceeded: () => internalAbort.abort("runTurn backlog exceeded"),
405
460
  });
406
- // Attempt telemetry must fire at dispatch time (parity with fetchOnce), not after collect.
407
- deps.onAttemptSend?.();
408
- void adapter
409
- .runTurn(
410
- iterParsed,
411
- {
412
- headers: deps.forwardHeaders ? new Headers(deps.forwardHeaders) : new Headers(),
413
- abortSignal: signal,
414
- translatorBudget,
415
- },
416
- queue.push,
417
- )
418
- .then(() => queue.close())
419
- .catch(err => {
420
- queue.push({ type: "error", message: err instanceof Error ? err.message : String(err) });
421
- queue.close();
422
- });
461
+ const iterationBudget = createIterationEventBudget();
462
+ let accepting = true;
463
+ let collectionError: unknown;
464
+ const closeOnAbort = (): void => { accepting = false; queue.close(); };
465
+ signal.addEventListener("abort", closeOnAbort, { once: true });
466
+ const emit = (event: AdapterEvent): void => {
467
+ if (!accepting || signal.aborted) return;
468
+ try {
469
+ // Check at emission, before even a synchronous producer can fill the queue. push
470
+ // reports whether it merged this delta into the buffered tail, which is what the
471
+ // iteration actually retains once the consumer drains it.
472
+ iterationBudget.retain(event, queue.push(event));
473
+ } catch (error) {
474
+ collectionError = error;
475
+ closeOnAbort();
476
+ internalAbort.abort(error);
477
+ throw error;
478
+ }
479
+ };
423
480
 
424
481
  // Bound collect with a real *idle* deadline that resets on each emitted event.
425
482
  // A fixed wall-clock race would abort legitimate long Cursor turns that keep
@@ -439,14 +496,34 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise<Respons
439
496
  });
440
497
  const events: AdapterEvent[] = [];
441
498
  try {
499
+ // Attempt telemetry must fire at dispatch time, not after collection.
500
+ deps.onAttemptSend?.();
501
+ void adapter.runTurn(iterParsed, {
502
+ headers: deps.forwardHeaders ? new Headers(deps.forwardHeaders) : new Headers(),
503
+ abortSignal: signal,
504
+ translatorBudget,
505
+ }, emit).then(closeOnAbort).catch(err => {
506
+ if (accepting) {
507
+ collectionError = err;
508
+ if (isTranslatorBudgetExceededError(err)) internalAbort.abort(err);
509
+ }
510
+ closeOnAbort();
511
+ });
442
512
  idle.reset();
443
513
  for await (const event of queue.stream()) {
444
514
  if (timedOut) break;
445
515
  idle.reset();
446
- events.push(event);
516
+ if (event.type !== "heartbeat") events.push(event);
447
517
  }
448
518
  } finally {
519
+ accepting = false;
449
520
  idle.cancel();
521
+ signal.removeEventListener("abort", closeOnAbort);
522
+ iterationBudget.dispose();
523
+ }
524
+ if (collectionError) {
525
+ if (isTranslatorBudgetExceededError(collectionError)) throw collectionError;
526
+ throw new LoopError(502, collectionError instanceof Error ? collectionError.message : String(collectionError));
450
527
  }
451
528
  if (timedOut) {
452
529
  throw new LoopError(504, `runTurn inactivity timeout after ${stallTimeoutMs}ms during image-bridge`);
@@ -466,14 +543,9 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise<Respons
466
543
  }
467
544
  throw new LoopError(502, errorEvent.message);
468
545
  }
546
+ if (signal.aborted) throw new LoopError(499, "client closed request during image-bridge");
469
547
 
470
- const wrappedAdapter: ProviderAdapter = {
471
- ...adapter,
472
- async *parseStream() {
473
- for (const e of events) yield e;
474
- },
475
- };
476
- return { response: new Response(new Uint8Array(0), { status: 200 }), responseAdapter: wrappedAdapter };
548
+ return { response: new Response(new Uint8Array(0), { status: 200 }), responseAdapter: adapter, collectedEvents: events };
477
549
  }
478
550
 
479
551
  let headerDeadline = clearableDeadline(connectTimeoutMs, signal);
@@ -643,23 +715,34 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise<Respons
643
715
  // Consume and validate one successful response body. Only invisible heartbeat events escape while
644
716
  // semantic output remains buffered for safe scanning.
645
717
  const consumeIterationEvents = async function* (prepared: IterationResponse): AsyncGenerator<AdapterEvent, IterationSplit> {
646
- const events: AdapterEvent[] = [];
718
+ const events: AdapterEvent[] = prepared.collectedEvents ?? [];
719
+ const iterationBudget = prepared.collectedEvents ? undefined : createIterationEventBudget();
647
720
  try {
648
- const parse = prepared.responseAdapter.parseStream.bind(prepared.responseAdapter);
649
- for await (const event of parseStreamWithProgress(prepared.response, parse, {
650
- signal,
651
- inactivityTimeoutMs: stallTimeoutMs,
652
- translatorBudget,
653
- })) {
654
- if (event.type === "heartbeat") yield event;
655
- else events.push(event);
721
+ if (iterationBudget) {
722
+ const parse = prepared.responseAdapter.parseStream.bind(prepared.responseAdapter);
723
+ for await (const event of parseStreamWithProgress(prepared.response, parse, {
724
+ signal,
725
+ inactivityTimeoutMs: stallTimeoutMs,
726
+ translatorBudget,
727
+ })) {
728
+ if (event.type === "heartbeat") yield event;
729
+ else {
730
+ iterationBudget.retain(event);
731
+ events.push(event);
732
+ }
733
+ }
656
734
  }
657
735
  } catch (error) {
658
- if (isTranslatorBudgetExceededError(error)) throw error;
736
+ if (isTranslatorBudgetExceededError(error)) {
737
+ internalAbort.abort(error);
738
+ throw error;
739
+ }
659
740
  if (signal.aborted) throw new LoopError(499, "client closed request during image-bridge");
660
741
  if (error instanceof RoutedModelInactivityError) throw new LoopError(504, error.message);
661
742
  if (error instanceof WebSearchStreamProtocolError) throw new LoopError(502, error.message);
662
743
  throw new LoopError(502, `Provider stream error: ${error instanceof Error ? error.message : String(error)}`);
744
+ } finally {
745
+ iterationBudget?.dispose();
663
746
  }
664
747
 
665
748
  const terminalIndexes = events.flatMap((event, index) =>
@@ -58,20 +58,26 @@ export function createAdmissionGate(name: string, limit: number): {
58
58
  }
59
59
 
60
60
  export function retainedUtf8Bytes(value: string): number {
61
- return new TextEncoder().encode(value).byteLength;
61
+ // Keep TextEncoder's runtime coercion for legacy callers outside the string-typed contract.
62
+ // Template coercion rejects Symbols; String(value) would silently accept them.
63
+ return Buffer.byteLength(typeof value === "string" ? value : value === undefined ? "" : `${value}`, "utf8");
62
64
  }
63
65
 
64
66
  function utf8Prefix(value: string, maxBytes: number): string {
65
67
  if (maxBytes <= 0) return "";
66
68
  let bytes = 0;
67
- let result = "";
68
- for (const character of value) {
69
- const size = retainedUtf8Bytes(character);
69
+ let end = 0;
70
+ while (end < value.length) {
71
+ const code = value.charCodeAt(end);
72
+ const next = value.charCodeAt(end + 1);
73
+ const pair = code >= 0xd800 && code <= 0xdbff && next >= 0xdc00 && next <= 0xdfff;
74
+ // An unpaired surrogate encodes as a three-byte replacement, like TextEncoder.
75
+ const size = code <= 0x7f ? 1 : code <= 0x7ff ? 2 : pair ? 4 : 3;
70
76
  if (bytes + size > maxBytes) break;
71
- result += character;
72
77
  bytes += size;
78
+ end += pair ? 2 : 1;
73
79
  }
74
- return result;
80
+ return value.slice(0, end);
75
81
  }
76
82
 
77
83
  export function truncateRetainedUtf8(value: string, maxBytes: number): string {
package/src/lib/redact.ts CHANGED
@@ -252,6 +252,13 @@ const SECRET_VALUE_PATTERNS: Array<[RegExp, string]> = [
252
252
  [/((?:"(?:api[_-]?key|access[_-]?token|refresh[_-]?token|id[_-]?token|client[_-]?secret|refreshToken|accessToken|clientSecret|apiKey)"\s*:\s*"))([^"]+)(")/gi, `$1${REDACTED_SECRET}$3`],
253
253
  // Raw JSON "token" field values (Copilot token exchange bodies echo the credential here).
254
254
  [/(("token"\s*:\s*"))([^"]+)(")/gi, `$1${REDACTED_SECRET}$4`],
255
+ // Cognition/Devin session keys, and the bare JWTs several providers hand out.
256
+ // A Connect EOS trailer can quote the request that carried the key, and the
257
+ // rules above only fire on a label — `Bearer`, `api_key=`, `"token":` — which
258
+ // a quoted proto field does not have. `eyJ` is the base64url of `{"`, so the
259
+ // JWT rule needs a real three-segment shape and does not match ordinary prose.
260
+ [/\bdevin-session-token\$[A-Za-z0-9._~+/=-]{8,}/g, REDACTED_SECRET],
261
+ [/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]*/g, REDACTED_SECRET],
255
262
  [/\b(arn:aws:[A-Za-z0-9_-]+:[A-Za-z0-9-]*:\d{12}:[A-Za-z0-9_/:+=,.@-]+)\b/g, REDACTED_SECRET],
256
263
  ];
257
264
 
@@ -112,14 +112,15 @@ export function retainTranslatedEvent<T extends object>(
112
112
  */
113
113
  export function retainTranslatedEventBatch<T extends object>(events: T[], budget: TranslatorBudget): void {
114
114
  if (events.length === 0) return;
115
- const serialized = events.map(event => JSON.stringify(event));
116
- const totalBytes = Buffer.byteLength(`[${serialized.join(",")}]`);
115
+ // Preserve atomic batch admission without retaining serialized strings or joining a second copy.
116
+ const eventBytes = events.map(event => Buffer.byteLength(JSON.stringify(event)));
117
+ const totalBytes = eventBytes.reduce((total, bytes) => total + bytes, events.length + 1);
117
118
  budget.chargeRetained(totalBytes, { kind: "retained_collectors" });
118
119
  for (let index = 0; index < events.length; index++) {
119
120
  const delimiterBytes = index === events.length - 1 ? 2 : 1;
120
121
  retainedEventOwnership.set(events[index]!, {
121
122
  budget,
122
- bytes: Buffer.byteLength(serialized[index]!) + delimiterBytes,
123
+ bytes: eventBytes[index]! + delimiterBytes,
123
124
  });
124
125
  }
125
126
  }
@@ -33,6 +33,7 @@ export type ReplacePublisher =
33
33
  | "claude-agents"
34
34
  | "lab-automation"
35
35
  | "lab-ledger"
36
+ | "remote-workspace"
36
37
  | "storage-cleanup"
37
38
  | "tray";
38
39
 
@@ -397,7 +397,7 @@ export function formatWindowsSchtasksError(error: unknown, args: string[]): stri
397
397
  const guidance = [
398
398
  "Windows access denied while running Task Scheduler.",
399
399
  `Command: schtasks ${argsText}`,
400
- "Approve the Windows UAC prompt to install the background service, or run `ocx service install` from an elevated PowerShell window.",
400
+ "The OpenCodex task definition is scoped to the installing account and normally registers without elevation, so this denial is not fixed by approving a UAC prompt. Check `ocx service status`: if an existing `opencodex-proxy` task belongs to a different account, remove it from that account and retry `ocx service install`.",
401
401
  ].join(" ");
402
402
  if (operation === "create" && ownedCreateAccessDenied) {
403
403
  return `${guidance}\n${WINDOWS_SCHTASKS_CREATE_ACCESS_DENIED_MARKER}`;
@@ -23,6 +23,19 @@ export const DEVICE_VERIFICATION_URL = "https://auth.openai.com/codex/device";
23
23
 
24
24
  /** The grant's own lifetime. Polling past this only produces a worse error message. */
25
25
  const DEVICE_FLOW_TTL_MS = 15 * 60 * 1000;
26
+ /**
27
+ * Per-fetch deadline for every device-flow HTTP call (#3898). Until this
28
+ * existed the only bounds were the 15-minute grant TTL and the caller's
29
+ * abort, so one stuck TCP connection could hold the login slot for the whole
30
+ * grant. A FRESH timeout per fetch attempt is required — a single timeout
31
+ * shared across the poll loop would kill the 15-minute grant.
32
+ */
33
+ export const DEVICE_FETCH_TIMEOUT_MS = 30_000;
34
+
35
+ function deviceFetchSignal(signal?: AbortSignal): AbortSignal {
36
+ const timeout = AbortSignal.timeout(DEVICE_FETCH_TIMEOUT_MS);
37
+ return signal ? AbortSignal.any([signal, timeout]) : timeout;
38
+ }
26
39
  const DEFAULT_POLL_INTERVAL_MS = 5_000;
27
40
  const MIN_POLL_INTERVAL_MS = 1_000;
28
41
  /**
@@ -86,7 +99,7 @@ async function requestUserCode(signal?: AbortSignal): Promise<DeviceUserCode> {
86
99
  method: "POST",
87
100
  headers: { "Content-Type": "application/json" },
88
101
  body: JSON.stringify({ client_id: CHATGPT_CLIENT_ID }),
89
- signal,
102
+ signal: deviceFetchSignal(signal),
90
103
  });
91
104
  if (!response.ok) throw deviceError("request", response.status);
92
105
  const payload = (await response.json()) as Record<string, unknown>;
@@ -123,7 +136,7 @@ async function pollForGrant(
123
136
  method: "POST",
124
137
  headers: { "Content-Type": "application/json" },
125
138
  body: JSON.stringify({ device_auth_id: device.deviceAuthId, user_code: device.userCode }),
126
- signal,
139
+ signal: deviceFetchSignal(signal),
127
140
  });
128
141
  if (response.status === 403 || response.status === 404) {
129
142
  // Cap the wait at the time actually left. Sleeping a full interval past
@@ -149,7 +162,7 @@ async function pollForGrant(
149
162
  throw new Error("ChatGPT device authorization expired");
150
163
  }
151
164
 
152
- async function exchangeGrant(grant: DeviceGrant, signal?: AbortSignal): Promise<OAuthCredentials> {
165
+ async function exchangeGrantRaw(grant: DeviceGrant, signal?: AbortSignal): Promise<Record<string, unknown>> {
153
166
  const response = await fetch(CHATGPT_TOKEN_URL, {
154
167
  method: "POST",
155
168
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
@@ -160,10 +173,54 @@ async function exchangeGrant(grant: DeviceGrant, signal?: AbortSignal): Promise<
160
173
  code_verifier: grant.codeVerifier,
161
174
  redirect_uri: DEVICE_REDIRECT_URI,
162
175
  }).toString(),
163
- signal,
176
+ signal: deviceFetchSignal(signal),
164
177
  });
165
178
  if (!response.ok) throw deviceError("token exchange", response.status);
166
- return credsFromToken((await response.json()) as Record<string, unknown>);
179
+ return (await response.json()) as Record<string, unknown>;
180
+ }
181
+
182
+ async function exchangeGrant(grant: DeviceGrant, signal?: AbortSignal): Promise<OAuthCredentials> {
183
+ return credsFromToken(await exchangeGrantRaw(grant, signal));
184
+ }
185
+
186
+ /**
187
+ * The native-main reauth result (#3898): the projected credential PLUS the
188
+ * id_token the pool projection deliberately drops. The id_token is the
189
+ * identity document the native auth.json requires
190
+ * (native-profile-store.ts), and it never leaves this process — it is
191
+ * written to the native main slot by the caller, never serialized into a
192
+ * DTO, log, or error.
193
+ */
194
+ export interface NativeDeviceLogin {
195
+ credential: OAuthCredentials;
196
+ idToken: string;
197
+ }
198
+
199
+ async function exchangeGrantNative(grant: DeviceGrant, signal?: AbortSignal): Promise<NativeDeviceLogin> {
200
+ const payload = await exchangeGrantRaw(grant, signal);
201
+ const credential = credsFromToken(payload);
202
+ const idToken = nonEmptyString(payload.id_token);
203
+ if (!idToken) throw new Error("ChatGPT device token response missing id_token");
204
+ if (!credential.refresh) throw new Error("ChatGPT device token response missing refresh token");
205
+ if (!credential.accountId) throw new Error("ChatGPT device token response missing account identity");
206
+ return { credential, idToken };
207
+ }
208
+
209
+ /**
210
+ * Device flow for the native __main__ slot. Same grant as the pool flow, but
211
+ * nothing is persisted here and no OAuth store is touched: the caller
212
+ * (main-device-reauth service) owns the fenced commit into CODEX_HOME
213
+ * auth.json.
214
+ */
215
+ export async function loginChatGPTNativeDevice(ctrl: OAuthController): Promise<NativeDeviceLogin> {
216
+ const device = await requestUserCode(ctrl.signal);
217
+ ctrl.onAuth?.({
218
+ url: DEVICE_VERIFICATION_URL,
219
+ instructions: `Enter code: ${device.userCode}`,
220
+ deviceCode: device.userCode,
221
+ });
222
+ const grant = await pollForGrant(device, ctrl.signal);
223
+ return exchangeGrantNative(grant, ctrl.signal);
167
224
  }
168
225
 
169
226
  /**
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Devin CLI credential-file import.
3
+ *
4
+ * The installed CLI writes `credentials.toml` after `devin auth login`, and the
5
+ * `windsurf_api_key` in it is an ordinary `devin-session-token$<JWT>` — the same
6
+ * shape RegisterUser returns for the browser sign-in, and the same one the
7
+ * cloud-direct client already speaks. Measured against a signed-in CLI: it mints
8
+ * a user_jwt, opens the full model catalog, and streams chat.
9
+ *
10
+ * This module is only the file-reading half of that. The login decision —
11
+ * adopt the CLI session when present, fall back to the Auth0 browser flow when
12
+ * the file is absent — lives in `loginDevin` in `../devin.ts`, because the
13
+ * merged `devin` provider owns both paths (`devin-cli` survives only as a
14
+ * deprecated alias for unmigrated configs).
15
+ *
16
+ * The file also carries `devin_webapp_host` and `devin_api_url`, which belong to
17
+ * the Devin *session* product (`cog_` keys, agent VMs) rather than to model
18
+ * inference. Neither is read here.
19
+ */
20
+ import { existsSync, readFileSync } from "node:fs";
21
+ import { homedir } from "node:os";
22
+ import { posix, win32 } from "node:path";
23
+
24
+ /** Absolute-path override, for a CLI installed somewhere this resolver does not model. */
25
+ export const DEVIN_CLI_CREDENTIALS_ENV = "OPENCODEX_DEVIN_CLI_CREDENTIALS";
26
+
27
+ export interface DevinCliLoginDeps {
28
+ env?: NodeJS.ProcessEnv;
29
+ platform?: NodeJS.Platform;
30
+ exists?: (path: string) => boolean;
31
+ read?: (path: string) => string;
32
+ }
33
+
34
+ /**
35
+ * Where the CLI keeps its own credential.
36
+ *
37
+ * Measured on a live install: `$XDG_DATA_HOME/devin/credentials.toml`, i.e.
38
+ * `~/.local/share/devin/...`, which `devin auth status` prints. Note this is the
39
+ * DATA dir, not the config dir — an earlier draft guessed `~/.config` and was
40
+ * wrong. The Windows branch mirrors the CLI's own installer.
41
+ */
42
+ export function devinCliCredentialsPath(
43
+ env: NodeJS.ProcessEnv = process.env,
44
+ platform: NodeJS.Platform = process.platform,
45
+ ): string {
46
+ const override = env[DEVIN_CLI_CREDENTIALS_ENV]?.trim();
47
+ // Absolute only. A relative override would resolve against whatever directory
48
+ // the proxy happens to be running in, which is not a location a user can mean.
49
+ if (override && (override.startsWith("/") || /^[A-Za-z]:[\\/]/.test(override))) return override;
50
+ const paths = platform === "win32" ? win32 : posix;
51
+ if (platform === "win32") {
52
+ // `??` treats an empty APPDATA as set, and join("", "devin", …) is a path
53
+ // relative to whatever directory the proxy was started in — so a file planted
54
+ // there would import as the operator's own CLI session. An empty or
55
+ // whitespace-only value is an absent value.
56
+ const appData = env.APPDATA?.trim() || paths.join(homedir(), "AppData", "Roaming");
57
+ return paths.join(appData, "devin", "credentials.toml");
58
+ }
59
+ const dataHome = env.XDG_DATA_HOME?.trim() || paths.join(homedir(), ".local", "share");
60
+ return paths.join(dataHome, "devin", "credentials.toml");
61
+ }
62
+
63
+ export interface DevinCliCredentialFile {
64
+ apiKey: string;
65
+ apiServerUrl: string;
66
+ }
67
+
68
+ /**
69
+ * Upper bound on the credential file we are willing to parse.
70
+ *
71
+ * The measured file is four short lines. Reading an arbitrarily large file into
72
+ * a string and running two global-ish regexes over it is work we never need to
73
+ * do, and a file this size is not the CLI's.
74
+ */
75
+ const DEVIN_CLI_CREDENTIALS_MAX_BYTES = 64 * 1024;
76
+
77
+ /**
78
+ * Why the import has no credential, for the caller that owns the response.
79
+ *
80
+ * `missing` and `unreadable` used to collapse into the same `undefined`, so a
81
+ * permission error on an existing file was reported as "not signed in" and sent
82
+ * the operator to `devin auth login`, which does not fix it. The distinction
83
+ * matters even more now that `missing` is the browser-fallback branch: only an
84
+ * absent file may fall through.
85
+ */
86
+ export type DevinCliCredentialOutcome =
87
+ | { kind: "ok"; file: DevinCliCredentialFile }
88
+ | { kind: "missing" }
89
+ | { kind: "unreadable" }
90
+ | { kind: "incomplete" };
91
+
92
+ export function readDevinCliCredentialOutcome(deps: DevinCliLoginDeps = {}): DevinCliCredentialOutcome {
93
+ const path = devinCliCredentialsPath(deps.env, deps.platform);
94
+ const exists = deps.exists ?? existsSync;
95
+ if (!exists(path)) return { kind: "missing" };
96
+ let raw: string;
97
+ try {
98
+ raw = (deps.read ?? ((p: string) => readFileSync(p, "utf8")))(path);
99
+ } catch {
100
+ // Nothing from the error is repeated: it carries the path, and an EACCES
101
+ // message is not worth the risk of echoing anything read off disk.
102
+ return { kind: "unreadable" };
103
+ }
104
+ if (raw.length > DEVIN_CLI_CREDENTIALS_MAX_BYTES) return { kind: "unreadable" };
105
+ const apiKey = raw.match(/^\s*windsurf_api_key\s*=\s*"([^"]+)"/m)?.[1]?.trim();
106
+ const apiServerUrl = raw.match(/^\s*api_server_url\s*=\s*"([^"]+)"/m)?.[1]?.trim();
107
+ if (!apiKey || !apiServerUrl) return { kind: "incomplete" };
108
+ return { kind: "ok", file: { apiKey, apiServerUrl } };
109
+ }
110
+
111
+ /**
112
+ * Read the two keys that matter, and nothing else.
113
+ *
114
+ * The measured file is four flat `key = "value"` lines: no tables, no comments,
115
+ * no single quotes. A line matcher is therefore enough and a TOML dependency is
116
+ * not, and the quoted form is required rather than optional — an unquoted
117
+ * matcher would pass its own fixtures and miss the real file.
118
+ *
119
+ * Returns undefined rather than throwing so the caller owns the outcome.
120
+ * Nothing here ever puts the file's contents into a thrown value.
121
+ */
122
+ export function readDevinCliCredentialFile(deps: DevinCliLoginDeps = {}): DevinCliCredentialFile | undefined {
123
+ const outcome = readDevinCliCredentialOutcome(deps);
124
+ return outcome.kind === "ok" ? outcome.file : undefined;
125
+ }
126
+
127
+ /** True when a signed-in CLI credential is readable. Used for status, never for auth. */
128
+ export function devinCliSignedIn(deps: DevinCliLoginDeps = {}): boolean {
129
+ return readDevinCliCredentialFile(deps) !== undefined;
130
+ }
@@ -1,15 +1,24 @@
1
1
  /**
2
2
  * Devin / Cognition OAuth.
3
3
  *
4
- * Login opens the Auth0 browser sign-in flow (windsurf.com/windsurf/signin
5
- * with redirect_uri=show-auth-token), then exchanges the pasted Firebase ID
6
- * token via Cognition's RegisterUser for a long-lived API key.
4
+ * Login is import-first: a signed-in Devin CLI has already completed PKCE, so
5
+ * its credentials.toml session is adopted without opening a browser (the kiro
6
+ * shape). Only when no CLI credential exists does login fall back to the Auth0
7
+ * browser sign-in flow (windsurf.com/windsurf/signin with
8
+ * redirect_uri=show-auth-token), which exchanges the pasted Firebase ID token
9
+ * via Cognition's RegisterUser for a long-lived API key. The browser path is
10
+ * kept because it is the only login route for a user without the CLI.
11
+ *
12
+ * `devin-cli` used to be a second provider id owning the import half; it is
13
+ * now a deprecated alias for `devin` (see DEPRECATED_OAUTH_PROVIDER_ALIASES in
14
+ * ./index), and this module is the single login/refresh owner for both.
7
15
  */
8
16
  import { randomUUID } from "node:crypto";
9
17
  import type { OAuthController, OAuthCredentials } from "./types";
10
18
  import { DEFAULT_REGION, type WindsurfRegion } from "./devin/types";
11
19
  import { registerUser } from "./devin/register-user";
12
20
  import { DEVIN_DEFAULT_API_SERVER, resolveDevinApiBaseUrl, validateDevinApiBaseUrl } from "./devin/api-base";
21
+ import { readDevinCliCredentialOutcome } from "./devin/cli-import";
13
22
  import { getCredential } from "./store";
14
23
 
15
24
  export { DEVIN_DEFAULT_API_SERVER } from "./devin/api-base";
@@ -25,10 +34,13 @@ export { DEVIN_DEFAULT_API_SERVER } from "./devin/api-base";
25
34
  */
26
35
  export function resolveDevinApiServer(configuredBaseUrl?: string, providerId = "devin"): string {
27
36
  return (
28
- // Provider-scoped. `devin` and `devin-cli` share this transport and this token
29
- // format but sign in to different accounts, and an EU or FedStart tenant is
30
- // recorded on the credential rather than in the registry. Reading a fixed
31
- // "devin" slot sent one provider's key to the other's host.
37
+ // Provider-scoped, keyed by the configured provider id verbatim. `devin-cli`
38
+ // is a deprecated alias for `devin`, but an unmigrated config row still owns
39
+ // its old credential slot until the startup migration rekeys the row and the
40
+ // slot together — normalizing the id here would read the wrong slot for that
41
+ // window. An EU or FedStart tenant is recorded on the credential rather than
42
+ // in the registry, so a fixed "devin" slot would send the key to the wrong
43
+ // host either way.
32
44
  validateDevinApiBaseUrl(getCredential(providerId)?.apiBaseUrl) ??
33
45
  validateDevinApiBaseUrl(configuredBaseUrl) ??
34
46
  DEVIN_DEFAULT_API_SERVER
@@ -153,7 +165,50 @@ async function loginDevinBrowser(ctrl: OAuthController, region: WindsurfRegion):
153
165
  return credentials;
154
166
  }
155
167
 
156
- export async function loginDevin(ctrl: OAuthController): Promise<OAuthCredentials> {
168
+ /**
169
+ * Import-first login for the merged `devin` provider.
170
+ *
171
+ * A signed-in CLI already completed PKCE, so its session is adopted directly
172
+ * and no browser opens. Only `missing` falls through to the browser flow:
173
+ * `unreadable` and `incomplete` describe a file that exists but is broken,
174
+ * and a browser sign-in would not repair it, so they throw rather than
175
+ * silently routing around the real problem. `forceLogin` skips the import
176
+ * outright — reauth and add-account must be able to reach a different account
177
+ * than the CLI's, and the management routes already set it for both.
178
+ */
179
+ export async function loginDevin(
180
+ ctrl: OAuthController,
181
+ opts?: { forceLogin?: boolean },
182
+ ): Promise<OAuthCredentials> {
183
+ if (opts?.forceLogin !== true) {
184
+ const outcome = readDevinCliCredentialOutcome();
185
+ // No branch names path contents or parsed values. A Connect error can echo
186
+ // a request, and redactSecretString does not recognise a bare JWT or a
187
+ // devin-session-token — the same caution register-user.ts applies to error
188
+ // bodies applies to anything thrown here.
189
+ if (outcome.kind === "unreadable") {
190
+ // The file is there and we could not read it, so `devin auth login` is
191
+ // the wrong instruction: it would succeed and change nothing.
192
+ throw new Error(
193
+ "Found a Devin CLI credential file but could not read it. Check its permissions and size, then try again.",
194
+ );
195
+ }
196
+ if (outcome.kind === "incomplete") {
197
+ throw new Error(
198
+ "The Devin CLI credential file is missing its session key or API server URL. Run `devin auth login` again to rewrite it.",
199
+ );
200
+ }
201
+ if (outcome.kind === "ok") {
202
+ // The host comes off disk and then receives the key, so it passes the
203
+ // same allowlist as the RegisterUser host. An unallowlisted value falls
204
+ // back to the default rather than becoming an exfiltration target.
205
+ const apiBaseUrl = resolveDevinApiBaseUrl(outcome.file.apiServerUrl);
206
+ ctrl.onProgress?.("Imported the signed-in Devin CLI session.");
207
+ return credentialsFromApiKey(outcome.file.apiKey, apiBaseUrl, "local-cli");
208
+ }
209
+ // "missing": no CLI session to adopt. The browser flow below is the only
210
+ // login path left for a user without the CLI installed.
211
+ }
157
212
  return loginDevinBrowser(ctrl, DEFAULT_REGION);
158
213
  }
159
214