@bitkyc08/opencodex 2.52.0 → 2.53.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 (236) hide show
  1. package/gui/dist/assets/index-BBOZWGB6.css +1 -0
  2. package/gui/dist/assets/index-BlO4Yl6q.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
@@ -39,7 +39,6 @@ import { loginChatGPT, refreshChatGPTToken, type ChatGPTLoginFlow } from "./chat
39
39
  import { loginAntigravity, refreshAntigravityToken } from "./google-antigravity";
40
40
  import { loginCursor, refreshCursorToken } from "./cursor";
41
41
  import { loginDevin, refreshDevinToken } from "./devin";
42
- import { loginDevinCli, refreshDevinCliToken } from "./devin-cli";
43
42
  import { loginGithubCopilot, refreshGithubCopilotToken, validateCopilotApiBaseUrl } from "./github-copilot";
44
43
  import { loginCommandCode, refreshCommandCodeToken } from "./command-code";
45
44
  import { loginMetaMuse, refreshMetaMuseToken } from "./meta-muse";
@@ -267,7 +266,9 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderDef> = {
267
266
  defaultModel: oauthDefaultModel("kimi"),
268
267
  },
269
268
  "meta-muse": {
270
- login: ctrl => loginMetaMuse(ctrl),
269
+ // Add-account/reauth must not reimport the credential already on disk; it starts the
270
+ // device grant instead, the same mapping command-code uses above.
271
+ login: (ctrl, opts) => loginMetaMuse(ctrl, {}, { importLocal: opts?.forceLogin ? "off" : "fallback" }),
271
272
  refresh: refreshMetaMuseToken,
272
273
  providerConfig: oauthConfig("meta-muse"),
273
274
  defaultModel: oauthDefaultModel("meta-muse"),
@@ -311,22 +312,15 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderDef> = {
311
312
  defaultModel: oauthDefaultModel("cursor"),
312
313
  },
313
314
  devin: {
314
- login: (ctrl) => loginDevin(ctrl),
315
+ // Import-first: adopts a signed-in Devin CLI credential when one exists and
316
+ // only then falls back to the Auth0 browser flow. forceLogin skips the
317
+ // import so reauth/add-account can reach a different account than the CLI's.
318
+ login: (ctrl, opts) => loginDevin(ctrl, opts),
315
319
  refresh: refreshDevinToken,
316
320
  providerConfig: oauthConfig("devin"),
317
321
  defaultModel: oauthDefaultModel("devin"),
318
322
  defaultRefreshPolicy: "disabled",
319
323
  },
320
- "devin-cli": {
321
- // Import-first, the kiro shape: adopt the credential the installed CLI
322
- // already holds instead of starting a browser flow it has already completed.
323
- login: (ctrl, opts) => loginDevinCli(ctrl, opts),
324
- refresh: refreshDevinCliToken,
325
- providerConfig: oauthConfig("devin-cli"),
326
- defaultModel: oauthDefaultModel("devin-cli"),
327
- // The CLI owns the session and Cognition exposes no refresh endpoint.
328
- defaultRefreshPolicy: "disabled",
329
- },
330
324
  "github-copilot": {
331
325
  login: (ctrl) => loginGithubCopilot(ctrl),
332
326
  refresh: (rt, signal) => refreshGithubCopilotToken(rt, signal),
@@ -343,6 +337,23 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderDef> = {
343
337
  },
344
338
  };
345
339
 
340
+ /**
341
+ * Removed provider ids that still name a live successor.
342
+ *
343
+ * `devin-cli` was merged into `devin` (import-first login absorbed the CLI
344
+ * credential import; devlog/_plan/260913_devin_provider_merge). The id can
345
+ * still arrive here from a saved config row or a stored credential slot that
346
+ * the startup migration has not rekeyed yet, and from a user typing the old
347
+ * name at `ocx login`. It is deliberately NOT an OAUTH_PROVIDERS entry:
348
+ * keeping one would re-expose it as a separate dashboard/login row, and its
349
+ * `oauthConfig("devin-cli")` would throw at module load once the registry row
350
+ * is gone. The alias map covers the paths that must keep working — refresh
351
+ * policy resolution below, and the login-cli dispatch that warns and reroutes.
352
+ */
353
+ export const DEPRECATED_OAUTH_PROVIDER_ALIASES: Record<string, string> = {
354
+ "devin-cli": "devin",
355
+ };
356
+
346
357
  export function isOAuthProvider(name: string): boolean {
347
358
  return name in OAUTH_PROVIDERS;
348
359
  }
@@ -363,7 +374,11 @@ function isRefreshPolicy(value: unknown): value is RefreshPolicy {
363
374
  export function resolveRefreshPolicy(provider: string, config: OcxConfig): RefreshPolicy {
364
375
  const override = config.providers[provider]?.refreshPolicy;
365
376
  if (isRefreshPolicy(override)) return override;
366
- const def = OAUTH_PROVIDERS[provider];
377
+ // Resolve through the alias map so a lingering `devin-cli` row inherits
378
+ // devin's "disabled" policy. Without it the row would fall to "lazy-only"
379
+ // and the guardian would attempt refreshes Cognition has no endpoint for,
380
+ // marking the account needsReauth on a durable key that cannot refresh.
381
+ const def = OAUTH_PROVIDERS[DEPRECATED_OAUTH_PROVIDER_ALIASES[provider] ?? provider];
367
382
  return def?.defaultRefreshPolicy ?? "lazy-only";
368
383
  }
369
384
 
package/src/oauth/kiro.ts CHANGED
@@ -184,6 +184,12 @@ function parseKiroProfileArn(value: unknown): string | undefined {
184
184
  return KIRO_PROFILE_ARN_PATTERN.test(trimmed) ? trimmed : undefined;
185
185
  }
186
186
 
187
+ /** Account identity must never use the request-scoped Builder ID service profile. */
188
+ function accountScopedProfileArn(value: unknown): string | undefined {
189
+ const parsed = parseKiroProfileArn(value);
190
+ return parsed && parsed !== KIRO_BUILDER_ID_SERVICE_PROFILE_ARN ? parsed : undefined;
191
+ }
192
+
187
193
  function profileArnFromWhoami(parsed: Record<string, unknown>): string | undefined {
188
194
  // Only narrowly-named documented-ish shapes; never invent an ARN (#993).
189
195
  return parseKiroProfileArn(parsed.profileArn)
@@ -210,8 +216,9 @@ async function readKiroCliIdentity(runner: KiroCliRunner, signal?: AbortSignal):
210
216
  }
211
217
 
212
218
  function metadataFromImported(imported: ImportedKiroCredential): KiroOAuthMetadata | undefined {
219
+ const profileArn = accountScopedProfileArn(imported.profileArn);
213
220
  const metadata: KiroOAuthMetadata = {
214
- ...(imported.profileArn ? { profileArn: imported.profileArn } : {}),
221
+ ...(profileArn ? { profileArn } : {}),
215
222
  ...(imported.ssoRegion ? { ssoRegion: imported.ssoRegion } : {}),
216
223
  ...(imported.apiRegion ? { apiRegion: imported.apiRegion } : {}),
217
224
  ...(imported.clientId ? { clientId: imported.clientId } : {}),
@@ -283,19 +290,24 @@ async function oauthCredentialFromImported(
283
290
  // active Kiro CLI session between the SQLite read and whoami. Accept
284
291
  // whoami's identity only when the session token STILL matches the import —
285
292
  // refresh token, or access token when refresh is absent.
286
- if (identity.profileArn !== undefined) {
293
+ if (identity.profileArn !== undefined || identity.email !== undefined) {
287
294
  const current = readKiroCliSqliteCredential();
288
295
  const importedKey = imported.refresh || imported.access;
289
296
  const currentKey = current ? current.refresh || current.access : "";
290
297
  if (!current || currentKey !== importedKey) identity = {};
291
298
  }
292
299
  }
293
- // Builder ID imports often lack a profileArn in SQLite; whoami against the
294
- // SAME active CLI session can supply it (#993). Imported stays authoritative.
295
- const resolvedProfileArn = imported.profileArn ?? identity.profileArn;
300
+ // Same-session whoami is the live identity. SQLite state can still hold the
301
+ // previous account's profile after a CLI switch; treating that leftover ARN
302
+ // as accountId upserts the new login onto the old slot (#4435). The #993
303
+ // session-switch guard above already discards whoami when the token changed,
304
+ // so imported remains the fallback when whoami is unavailable.
305
+ const resolvedProfileArn = accountScopedProfileArn(identity.profileArn)
306
+ ?? accountScopedProfileArn(imported.profileArn);
296
307
  const metadata: KiroOAuthMetadata | undefined = (() => {
297
308
  const base = metadataFromImported(imported) ?? {};
298
- if (resolvedProfileArn && !base.profileArn) base.profileArn = resolvedProfileArn;
309
+ if (resolvedProfileArn) base.profileArn = resolvedProfileArn;
310
+ else delete base.profileArn;
299
311
  return Object.keys(base).length > 0 ? base : undefined;
300
312
  })();
301
313
  return {
@@ -8,7 +8,7 @@ import {
8
8
  requestBoundLocalProviderReload,
9
9
  type LocalProviderReloadResult,
10
10
  } from "../server/local-provider-reload-client";
11
- import { isPublicOAuthProvider, listOAuthProviders, runLogin } from "./index";
11
+ import { DEPRECATED_OAUTH_PROVIDER_ALIASES, isPublicOAuthProvider, listOAuthProviders, runLogin } from "./index";
12
12
  import { KEY_LOGIN_PROVIDERS, isKeyLoginProvider, validateApiKey, type KeyLoginProvider } from "./key-providers";
13
13
  import type { OcxConfig, OcxProviderConfig } from "../types";
14
14
  import { configuredAdminToken } from "../lib/admin-secrets";
@@ -85,6 +85,14 @@ export function loginUsageMessage(): string {
85
85
 
86
86
  export async function handleLogin(provider?: string): Promise<void> {
87
87
  const name = (provider ?? "").trim().toLowerCase();
88
+ // A removed provider id reached through its alias still logs in — the merged
89
+ // successor owns the flow. Warn rather than silently reroute so scripts and
90
+ // docs that still say `devin-cli` surface the rename to whoever runs them.
91
+ const alias = DEPRECATED_OAUTH_PROVIDER_ALIASES[name];
92
+ if (alias) {
93
+ console.error(`${name} is deprecated; logging in as ${alias}`);
94
+ return handleOAuthLogin(alias);
95
+ }
88
96
  if (isPublicOAuthProvider(name)) return handleOAuthLogin(name);
89
97
  if (isKeyLoginProvider(name)) return handleKeyLogin(name);
90
98
  console.error(loginUsageMessage());
@@ -0,0 +1,464 @@
1
+ /**
2
+ * Meta Muse Code device-authorization login.
3
+ *
4
+ * `./meta-muse` imports the credential the vendor's CLI already minted. This module
5
+ * produces one: Meta's OIDC device grant, then the subscription key mint that turns the
6
+ * resulting account token into the `LLM|` Model API key our request path sends as a
7
+ * bearer.
8
+ *
9
+ * Protocol source: devlog/_plan/260912_muse_device_oauth/001_reference_measurements.md.
10
+ * It is second-party, not vendor documentation, so every response is parsed defensively
11
+ * and every failure names a kind instead of throwing a bare string.
12
+ *
13
+ * Two properties are load-bearing and easy to lose in a later edit:
14
+ *
15
+ * 1. NO RESPONSE BODY REACHES AN ERROR MESSAGE. These endpoints can echo request
16
+ * material, the mint response literally contains the API key, and these messages reach
17
+ * CLI output, the dashboard and issue reports. Status codes only — the same discipline
18
+ * as `deviceError` in ./chatgpt-device.
19
+ * 2. THE ACCOUNT TOKEN IS NOT A BEARER FOR THE MODEL API. Measured 2026-09-03: it 401s
20
+ * with `invalid_api_key` while the sibling key returns 200
21
+ * (devlog/_fin/260903_muse_spark_plan_oauth/003 §B). It exists here only to mint and
22
+ * to read subscription usage.
23
+ */
24
+ import type { OAuthController, OAuthCredentials } from "./types";
25
+ import { sanitizeApiKeyValue } from "../providers/api-keys";
26
+
27
+ /** Meta's own Muse Code client. Public in its device-approval URL; not a secret. */
28
+ const CLIENT_ID = "1031625952748946";
29
+ const DEVICE_AUTHORIZATION_URL = "https://auth.meta.com/oidc/device/authorization/";
30
+ const DEVICE_TOKEN_URL = "https://auth.meta.com/oidc/device/token/";
31
+ const MUSE_KEY_URL = "https://api.meta.ai/muse-code/key";
32
+ const DEVICE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
33
+ const API_VERSION = "1.0.0";
34
+
35
+ /** Shown when the response omits a verification URI. Observed 2026-09-03 from `muse login`. */
36
+ const VERIFICATION_FALLBACK_URL = "https://auth.meta.com/oauth/device/";
37
+
38
+ const REQUEST_TIMEOUT_MS = 20_000;
39
+ const DEFAULT_FLOW_TTL_MS = 15 * 60_000;
40
+ /** A hostile or corrupt `expires_in` must not park a login for hours. */
41
+ const MAX_FLOW_TTL_MS = 30 * 60_000;
42
+ const DEFAULT_POLL_INTERVAL_MS = 5_000;
43
+ /** Floor: a zero or string interval would otherwise hot-loop an auth endpoint. */
44
+ const MIN_POLL_INTERVAL_MS = 1_000;
45
+ const SLOW_DOWN_INCREMENT_MS = 5_000;
46
+ const MAX_POLL_INTERVAL_MS = 60_000;
47
+
48
+ export type MuseDeviceErrorKind =
49
+ | "device-authorization"
50
+ | "device-token"
51
+ | "device-denied"
52
+ | "device-expired"
53
+ | "cancelled"
54
+ | "mint-http"
55
+ | "mint-rate-limited"
56
+ | "mint-invalid"
57
+ | "subscription-inactive"
58
+ | "entitlement-required"
59
+ | "missing-api-key"
60
+ | "missing-identity";
61
+
62
+ export class MuseDeviceLoginError extends Error {
63
+ readonly kind: MuseDeviceErrorKind;
64
+ readonly status?: number;
65
+ /** Where the user resolves an entitlement problem. Vendor-supplied, never a local path. */
66
+ readonly actionUrl?: string;
67
+ readonly retryAfterMs?: number;
68
+ constructor(
69
+ kind: MuseDeviceErrorKind,
70
+ message: string,
71
+ extra: { status?: number; actionUrl?: string; retryAfterMs?: number; cause?: unknown } = {},
72
+ ) {
73
+ super(message, extra.cause === undefined ? undefined : { cause: extra.cause });
74
+ // Set here rather than as a class field, matching src/oauth/nous.ts:160 and :383.
75
+ this.name = "MuseDeviceLoginError";
76
+ this.kind = kind;
77
+ if (extra.status !== undefined) this.status = extra.status;
78
+ if (extra.actionUrl !== undefined) this.actionUrl = extra.actionUrl;
79
+ if (extra.retryAfterMs !== undefined) this.retryAfterMs = extra.retryAfterMs;
80
+ }
81
+ }
82
+
83
+ /** Injected so tests never touch the network or a real clock. */
84
+ export interface MuseDeviceDeps {
85
+ fetchImpl?: typeof fetch;
86
+ sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
87
+ now?: () => number;
88
+ }
89
+
90
+ export interface MuseDeviceAuthorization {
91
+ deviceCode: string;
92
+ userCode: string;
93
+ verificationUri: string;
94
+ verificationUriComplete?: string;
95
+ intervalMs: number;
96
+ expiresAtMs: number;
97
+ }
98
+
99
+ export interface MuseKeyPayload {
100
+ apiKey?: string;
101
+ requirePayment?: boolean;
102
+ actionUrl?: string;
103
+ userEmail?: string;
104
+ userId?: string;
105
+ isSubsActive?: boolean;
106
+ subsTierName?: string;
107
+ /** Raw `subs_usage` object, api_key-free by construction. Parsed by muse-key-quota. */
108
+ subsUsage?: Record<string, unknown>;
109
+ }
110
+
111
+ function text(value: unknown): string | undefined {
112
+ if (typeof value !== "string") return undefined;
113
+ const trimmed = value.trim();
114
+ return trimmed.length > 0 ? trimmed : undefined;
115
+ }
116
+
117
+ function record(value: unknown): Record<string, unknown> | undefined {
118
+ return value && typeof value === "object" && !Array.isArray(value)
119
+ ? (value as Record<string, unknown>)
120
+ : undefined;
121
+ }
122
+
123
+ /**
124
+ * Upstream may send `interval` as a number or a string. A string reaches `setTimeout` as
125
+ * 0 and turns the poll into a hot loop, so coerce, floor and cap it.
126
+ */
127
+ function normalizeIntervalMs(raw: unknown): number {
128
+ const seconds = typeof raw === "number" ? raw : typeof raw === "string" ? Number(raw) : NaN;
129
+ if (!Number.isFinite(seconds) || seconds <= 0) return DEFAULT_POLL_INTERVAL_MS;
130
+ return Math.min(MAX_POLL_INTERVAL_MS, Math.max(MIN_POLL_INTERVAL_MS, Math.round(seconds * 1000)));
131
+ }
132
+
133
+ function normalizeTtlMs(raw: unknown): number {
134
+ const seconds = typeof raw === "number" ? raw : typeof raw === "string" ? Number(raw) : NaN;
135
+ if (!Number.isFinite(seconds) || seconds <= 0) return DEFAULT_FLOW_TTL_MS;
136
+ return Math.min(MAX_FLOW_TTL_MS, Math.round(seconds * 1000));
137
+ }
138
+
139
+ /** RFC 7231 `Retry-After`: delta-seconds or an HTTP date. Clamped to the poll ceiling. */
140
+ function retryAfterMs(header: string | null, now: number): number | undefined {
141
+ const raw = text(header ?? undefined);
142
+ if (!raw) return undefined;
143
+ const seconds = Number(raw);
144
+ if (Number.isFinite(seconds) && seconds >= 0) {
145
+ return Math.min(MAX_POLL_INTERVAL_MS, Math.round(seconds * 1000));
146
+ }
147
+ const at = Date.parse(raw);
148
+ if (!Number.isFinite(at)) return undefined;
149
+ return Math.min(MAX_POLL_INTERVAL_MS, Math.max(0, at - now));
150
+ }
151
+
152
+ async function defaultSleep(ms: number, signal?: AbortSignal): Promise<void> {
153
+ if (signal?.aborted) throw cancelled();
154
+ await new Promise<void>((resolve, reject) => {
155
+ const timer = setTimeout(() => {
156
+ signal?.removeEventListener("abort", onAbort);
157
+ resolve();
158
+ }, ms);
159
+ const onAbort = (): void => {
160
+ clearTimeout(timer);
161
+ reject(cancelled());
162
+ };
163
+ signal?.addEventListener("abort", onAbort, { once: true });
164
+ });
165
+ }
166
+
167
+ function cancelled(): MuseDeviceLoginError {
168
+ return new MuseDeviceLoginError("cancelled", "Muse Code login cancelled");
169
+ }
170
+
171
+ function requestSignal(signal: AbortSignal | undefined): AbortSignal {
172
+ const timeout = AbortSignal.timeout(REQUEST_TIMEOUT_MS);
173
+ return signal ? AbortSignal.any([signal, timeout]) : timeout;
174
+ }
175
+
176
+ /** Step 1: ask Meta for a user code. */
177
+ export async function requestMuseDeviceAuthorization(
178
+ deps: MuseDeviceDeps = {},
179
+ signal?: AbortSignal,
180
+ ): Promise<MuseDeviceAuthorization> {
181
+ const now = deps.now ?? Date.now;
182
+ const response = await (deps.fetchImpl ?? fetch)(DEVICE_AUTHORIZATION_URL, {
183
+ method: "POST",
184
+ headers: {
185
+ Accept: "application/json",
186
+ "Content-Type": "application/x-www-form-urlencoded",
187
+ "x-api-version": API_VERSION,
188
+ },
189
+ body: new URLSearchParams({ client_id: CLIENT_ID }).toString(),
190
+ redirect: "error",
191
+ signal: requestSignal(signal),
192
+ });
193
+ if (!response.ok) {
194
+ throw new MuseDeviceLoginError(
195
+ "device-authorization",
196
+ `Muse Code device authorization request failed: HTTP ${response.status}`,
197
+ { status: response.status },
198
+ );
199
+ }
200
+ const payload = record(await response.json().catch(() => undefined));
201
+ const deviceCode = text(payload?.device_code);
202
+ const userCode = text(payload?.user_code);
203
+ if (!deviceCode || !userCode) {
204
+ throw new MuseDeviceLoginError(
205
+ "device-authorization",
206
+ "Muse Code device authorization response is missing the device or user code",
207
+ { status: response.status },
208
+ );
209
+ }
210
+ return {
211
+ deviceCode,
212
+ userCode,
213
+ verificationUri: text(payload?.verification_uri) ?? VERIFICATION_FALLBACK_URL,
214
+ ...(text(payload?.verification_uri_complete)
215
+ ? { verificationUriComplete: text(payload?.verification_uri_complete) as string }
216
+ : {}),
217
+ intervalMs: normalizeIntervalMs(payload?.interval),
218
+ expiresAtMs: now() + normalizeTtlMs(payload?.expires_in),
219
+ };
220
+ }
221
+
222
+ /**
223
+ * Step 2: poll until approval.
224
+ *
225
+ * RFC 8628 signals state with an `error` code in a non-2xx body, so the CODE decides, not
226
+ * the status. An unrecognized code is terminal: retrying a permanent failure just hammers
227
+ * an auth endpoint until the grant expires. A 429 is treated as `slow_down` with
228
+ * `Retry-After` honoured, because that is what it means here.
229
+ */
230
+ export async function pollMuseDeviceToken(
231
+ authorization: MuseDeviceAuthorization,
232
+ deps: MuseDeviceDeps = {},
233
+ signal?: AbortSignal,
234
+ ): Promise<string> {
235
+ const now = deps.now ?? Date.now;
236
+ const sleep = deps.sleep ?? defaultSleep;
237
+ let intervalMs = authorization.intervalMs;
238
+ while (true) {
239
+ if (signal?.aborted) throw cancelled();
240
+ // [W4] Poll FIRST, then decide whether there is time to sleep again. The previous
241
+ // shape checked the deadline at the top, so a sleep ending exactly at the deadline
242
+ // skipped the final poll and discarded an approval the user had already completed
243
+ // inside that window.
244
+ const response = await (deps.fetchImpl ?? fetch)(DEVICE_TOKEN_URL, {
245
+ method: "POST",
246
+ headers: {
247
+ Accept: "application/json",
248
+ "Content-Type": "application/x-www-form-urlencoded",
249
+ "x-api-version": API_VERSION,
250
+ },
251
+ body: new URLSearchParams({
252
+ client_id: CLIENT_ID,
253
+ device_code: authorization.deviceCode,
254
+ grant_type: DEVICE_GRANT_TYPE,
255
+ }).toString(),
256
+ redirect: "error",
257
+ signal: requestSignal(signal),
258
+ });
259
+ const payload = record(await response.json().catch(() => undefined));
260
+ if (response.ok) {
261
+ // [W3] No deadline re-check here. If Meta answered 200 with a token, Meta accepted
262
+ // the device code; its clock is authoritative and ours is not. Discarding an issued
263
+ // token because a local deadline just passed would force the user to redo an
264
+ // approval that already succeeded.
265
+ const accessToken = text(payload?.access_token);
266
+ if (!accessToken) {
267
+ throw new MuseDeviceLoginError(
268
+ "device-token",
269
+ "Muse Code device token response is missing an access token",
270
+ { status: response.status },
271
+ );
272
+ }
273
+ return accessToken;
274
+ }
275
+ const code = text(payload?.error)?.toLowerCase();
276
+ if (code === "access_denied") {
277
+ throw new MuseDeviceLoginError("device-denied", "Muse Code login was denied in the browser", {
278
+ status: response.status,
279
+ });
280
+ }
281
+ if (code === "expired_token") {
282
+ throw new MuseDeviceLoginError("device-expired", "Muse Code device code expired; start the login again", {
283
+ status: response.status,
284
+ });
285
+ }
286
+ if (code === "slow_down" || response.status === 429) {
287
+ const advised = retryAfterMs(response.headers.get("retry-after"), now());
288
+ intervalMs = Math.min(
289
+ MAX_POLL_INTERVAL_MS,
290
+ Math.max(advised ?? 0, intervalMs + SLOW_DOWN_INCREMENT_MS),
291
+ );
292
+ } else if (code !== "authorization_pending") {
293
+ throw new MuseDeviceLoginError(
294
+ "device-token",
295
+ `Muse Code device token poll failed: HTTP ${response.status}`,
296
+ { status: response.status },
297
+ );
298
+ }
299
+ // [W4] The deadline is checked ONLY here, before sleeping. Reaching it means the
300
+ // grant is spent: the loop has just polled and been told to wait longer than the
301
+ // grant has left. Never sleep past it, which is how a 15-minute grant becomes a
302
+ // 20-minute wait.
303
+ const remaining = authorization.expiresAtMs - now();
304
+ if (remaining <= 0) {
305
+ throw new MuseDeviceLoginError("device-expired", "Muse Code device authorization expired before approval");
306
+ }
307
+ await sleep(Math.min(intervalMs, remaining), signal);
308
+ }
309
+ }
310
+
311
+ /**
312
+ * Step 3: mint the subscription key.
313
+ *
314
+ * `onboard` is sent only during an interactive login. Meta's key endpoint is rate-limited
315
+ * and returns the SAME key for an account, so any caller that already holds one must not
316
+ * come back here (`020` enforces that on refresh; `030` reuses this function read-only).
317
+ */
318
+ export async function mintMuseApiKey(
319
+ accountAccessToken: string,
320
+ options: { onboard?: boolean } = {},
321
+ deps: MuseDeviceDeps = {},
322
+ signal?: AbortSignal,
323
+ ): Promise<MuseKeyPayload> {
324
+ const now = deps.now ?? Date.now;
325
+ const response = await (deps.fetchImpl ?? fetch)(MUSE_KEY_URL, {
326
+ method: "POST",
327
+ headers: {
328
+ Accept: "application/json",
329
+ Authorization: `Bearer ${accountAccessToken}`,
330
+ "Content-Type": "application/json",
331
+ "x-api-version": API_VERSION,
332
+ },
333
+ body: JSON.stringify(options.onboard ? { onboard: true } : {}),
334
+ redirect: "error",
335
+ signal: requestSignal(signal),
336
+ });
337
+ if (response.status === 429) {
338
+ const wait = retryAfterMs(response.headers.get("retry-after"), now());
339
+ throw new MuseDeviceLoginError(
340
+ "mint-rate-limited",
341
+ wait === undefined
342
+ ? "Meta rate-limited the Muse Code key request; wait a minute and retry"
343
+ : `Meta rate-limited the Muse Code key request; retry in about ${Math.ceil(wait / 1000)}s`,
344
+ { status: 429, ...(wait === undefined ? {} : { retryAfterMs: wait }) },
345
+ );
346
+ }
347
+ if (!response.ok) {
348
+ // Status only. The body of this endpoint can carry the key itself.
349
+ throw new MuseDeviceLoginError(
350
+ "mint-http",
351
+ `Muse Code key exchange failed: HTTP ${response.status}`,
352
+ { status: response.status },
353
+ );
354
+ }
355
+ const payload = record(await response.json().catch(() => undefined));
356
+ if (!payload) {
357
+ throw new MuseDeviceLoginError("mint-invalid", "Muse Code key exchange returned an unreadable response", {
358
+ status: response.status,
359
+ });
360
+ }
361
+ return {
362
+ ...(text(payload.api_key) ? { apiKey: text(payload.api_key) as string } : {}),
363
+ ...(typeof payload.require_payment === "boolean" ? { requirePayment: payload.require_payment } : {}),
364
+ ...(text(payload.action_url) ?? text(payload.require_payment_action_url)
365
+ ? { actionUrl: (text(payload.action_url) ?? text(payload.require_payment_action_url)) as string }
366
+ : {}),
367
+ ...(text(payload.user_email) ? { userEmail: (text(payload.user_email) as string).toLowerCase() } : {}),
368
+ ...(text(payload.user_id) ? { userId: text(payload.user_id) as string } : {}),
369
+ ...(typeof payload.is_subs_active === "boolean" ? { isSubsActive: payload.is_subs_active } : {}),
370
+ ...(text(payload.subs_tier_name) ? { subsTierName: text(payload.subs_tier_name) as string } : {}),
371
+ ...(record(payload.subs_usage) ? { subsUsage: record(payload.subs_usage) as Record<string, unknown> } : {}),
372
+ };
373
+ }
374
+
375
+ /**
376
+ * Turn a mint payload into the error it deserves, or return the validated key.
377
+ *
378
+ * Four distinct outcomes the reference collapses into fewer: an inactive subscription is
379
+ * not a missing key, a payment requirement is not an auth failure, and a key that fails
380
+ * the `LLM|` grammar is not a server error.
381
+ */
382
+ export function museApiKeyFromPayload(payload: MuseKeyPayload): string {
383
+ if (payload.isSubsActive === false) {
384
+ throw new MuseDeviceLoginError(
385
+ "subscription-inactive",
386
+ "This Meta account has no active Muse Code subscription. Subscribe at https://dev.meta.ai, then log in again.",
387
+ { status: 403 },
388
+ );
389
+ }
390
+ const apiKey = sanitizeApiKeyValue(payload.apiKey);
391
+ if (!apiKey) {
392
+ if (payload.requirePayment === true || payload.actionUrl) {
393
+ throw new MuseDeviceLoginError(
394
+ "entitlement-required",
395
+ payload.actionUrl
396
+ ? `Meta requires a subscription or payment method before it will issue a Muse Code key: ${payload.actionUrl}`
397
+ : "Meta requires a subscription or payment method before it will issue a Muse Code key.",
398
+ { ...(payload.actionUrl ? { actionUrl: payload.actionUrl } : {}) },
399
+ );
400
+ }
401
+ throw new MuseDeviceLoginError("missing-api-key", "Meta returned no Muse Code API key for this account");
402
+ }
403
+ if (!/^LLM\|\d+\|[A-Za-z0-9_-]{10,}$/.test(apiKey)) {
404
+ throw new MuseDeviceLoginError(
405
+ "mint-invalid",
406
+ "Meta returned a Muse Code key in an unexpected format; log in again",
407
+ );
408
+ }
409
+ return apiKey;
410
+ }
411
+
412
+ /** Run the whole grant. */
413
+ export async function loginMetaMuseDevice(
414
+ ctrl: OAuthController = {},
415
+ deps: MuseDeviceDeps = {},
416
+ ): Promise<OAuthCredentials> {
417
+ const authorization = await requestMuseDeviceAuthorization(deps, ctrl.signal);
418
+ ctrl.onAuth?.({
419
+ url: authorization.verificationUriComplete ?? authorization.verificationUri,
420
+ instructions: `Enter code: ${authorization.userCode}`,
421
+ deviceCode: authorization.userCode,
422
+ });
423
+ const accountAccessToken = await pollMuseDeviceToken(authorization, deps, ctrl.signal);
424
+ ctrl.onProgress?.("Approved. Requesting the Muse Code subscription key...");
425
+ const payload = await mintMuseApiKey(accountAccessToken, { onboard: true }, deps, ctrl.signal);
426
+ const apiKey = museApiKeyFromPayload(payload);
427
+ if (payload.requirePayment === true || payload.actionUrl) {
428
+ // [W5] A usable key AND a payment signal. Meta issued a credential but is saying the
429
+ // plan does not cover it. The key is returned, because refusing a working credential
430
+ // would be worse, but the warning is not swallowed: this is the difference between a
431
+ // user who knows calls may be billed per token and one who finds out on an invoice.
432
+ ctrl.onProgress?.(payload.actionUrl
433
+ ? `Meta reports this account needs a subscription or payment method: ${payload.actionUrl}`
434
+ : "Meta reports this account needs a subscription or payment method; treat every call as billable.");
435
+ }
436
+ const email = payload.userEmail;
437
+ // [W2] email FIRST, user_id only as a fallback. The store keys a slot on
438
+ // `accountId ?? email` (src/oauth/store.ts:744,752), and this provider import path has
439
+ // always supplied email alone, so promoting user_id to accountId here would make a
440
+ // device login MISS the row an imported login already created and hand one human two
441
+ // accounts. user_id is still retained, in muse.userId, where it identifies the account
442
+ // for the quota probe without participating in slot identity.
443
+ if (!email && !payload.userId) {
444
+ throw new MuseDeviceLoginError(
445
+ "missing-identity",
446
+ "Meta returned no stable account identity for this Muse Code key",
447
+ );
448
+ }
449
+ return {
450
+ access: apiKey,
451
+ // Static key: there is nothing to exchange, so refresh carries the same value. Meta
452
+ // rejects refresh_token grants on this client (001 §A).
453
+ refresh: apiKey,
454
+ expires: Number.MAX_SAFE_INTEGER,
455
+ ...(email ? { email } : { accountId: payload.userId as string }),
456
+ source: "oauth",
457
+ muse: {
458
+ oauthAccessToken: accountAccessToken,
459
+ ...(payload.userId ? { userId: payload.userId } : {}),
460
+ mintedAt: (deps.now ?? Date.now)(),
461
+ ...(payload.subsTierName ? { tierName: payload.subsTierName } : {}),
462
+ },
463
+ };
464
+ }