@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.
- package/gui/dist/assets/index-BBOZWGB6.css +1 -0
- package/gui/dist/assets/index-BlO4Yl6q.js +128 -0
- package/gui/dist/index.html +2 -2
- package/native/remote-workspace-helper/Cargo.lock +130 -0
- package/native/remote-workspace-helper/Cargo.toml +24 -0
- package/native/remote-workspace-helper/src/main.rs +49 -0
- package/native/remote-workspace-helper/src/protocol.rs +246 -0
- package/native/remote-workspace-helper/src/sandbox/macos.rs +19 -0
- package/native/remote-workspace-helper/src/sandbox/mod.rs +77 -0
- package/native/remote-workspace-helper/src/sandbox/windows.rs +15 -0
- package/package.json +6 -1
- package/src/adapters/anthropic-image-normalize.ts +30 -2
- package/src/adapters/anthropic.ts +1 -1
- package/src/adapters/base.ts +8 -2
- package/src/adapters/cursor/cursor-errors.ts +12 -0
- package/src/adapters/cursor/thread-continuity.ts +93 -0
- package/src/adapters/cursor.ts +104 -73
- package/src/adapters/devin/cloud-direct/chat.ts +312 -23
- package/src/adapters/devin/cloud-direct/metadata.ts +31 -2
- package/src/adapters/devin/live-models.ts +70 -3
- package/src/adapters/devin.ts +281 -21
- package/src/adapters/google-wire-compiler.ts +14 -6
- package/src/adapters/google.ts +22 -8
- package/src/adapters/kiro/adapter.ts +316 -0
- package/src/adapters/kiro/conversation.ts +136 -0
- package/src/adapters/kiro/payload.ts +432 -0
- package/src/adapters/kiro/reasoning.ts +56 -0
- package/src/adapters/kiro/stream.ts +1153 -0
- package/src/adapters/kiro/usage.ts +223 -0
- package/src/adapters/kiro/wire.ts +76 -0
- package/src/adapters/kiro.ts +8 -2319
- package/src/adapters/mimo-free.ts +1 -1
- package/src/adapters/openai-chat-images.ts +101 -0
- package/src/adapters/openai-chat.ts +201 -181
- package/src/adapters/openai-responses.ts +92 -224
- package/src/adapters/registry.ts +0 -7
- package/src/adapters/run-turn-queue.ts +13 -6
- package/src/bridge.ts +14 -15
- package/src/chat/inbound.ts +29 -4
- package/src/chat/outbound.ts +145 -107
- package/src/claude/desktop-profile.ts +4 -6
- package/src/cli/account-api.ts +14 -0
- package/src/cli/account-extended.ts +1 -1
- package/src/cli/account-history.ts +60 -0
- package/src/cli/account-main.ts +80 -0
- package/src/cli/account.ts +11 -3
- package/src/cli/capabilities.ts +113 -0
- package/src/cli/catalog.ts +109 -0
- package/src/cli/dispatch.ts +9 -0
- package/src/cli/help.ts +2 -0
- package/src/cli/index.ts +2 -2
- package/src/cli/observe.ts +28 -1
- package/src/cli/opencode.ts +42 -8
- package/src/cli/provider-runtime.ts +11 -1
- package/src/cli/provider.ts +22 -2
- package/src/cli/registry.ts +21 -0
- package/src/cli/remote-workspace.ts +154 -0
- package/src/cli/status.ts +39 -7
- package/src/cli/usage-report.ts +14 -2
- package/src/client/hub-client.ts +34 -0
- package/src/client/hub-state.ts +9 -1
- package/src/codex/account-store.ts +78 -0
- package/src/codex/auth-api.ts +81 -54
- package/src/codex/auth-context.ts +45 -16
- package/src/codex/catalog/effort.ts +1 -1
- package/src/codex/catalog/metadata.ts +3 -6
- package/src/codex/catalog/native-models.ts +4 -4
- package/src/codex/catalog/parsing.ts +2 -20
- package/src/codex/catalog/provider-fetch.ts +10 -1
- package/src/codex/catalog/remote.ts +233 -0
- package/src/codex/catalog/sync.ts +403 -35
- package/src/codex/convergence.ts +1 -1
- package/src/codex/history-manifest.ts +36 -0
- package/src/codex/history-provider.ts +32 -5
- package/src/codex/inject.ts +9 -0
- package/src/codex/main-account.ts +113 -0
- package/src/codex/main-device-reauth-api.ts +89 -0
- package/src/codex/main-device-reauth.ts +217 -0
- package/src/codex/native-residue.ts +9 -2
- package/src/codex/quota-auto-refresh.ts +3 -2
- package/src/codex/quota-capacity.ts +98 -0
- package/src/codex/quota-history.ts +160 -0
- package/src/codex/quota-types.ts +8 -0
- package/src/codex/quota.ts +118 -91
- package/src/codex/refresh.ts +2 -1
- package/src/codex/routing.ts +90 -17
- package/src/codex/sync.ts +33 -4
- package/src/combos/request.ts +19 -1
- package/src/config/multi-agent-surface.ts +61 -0
- package/src/config/provider-validation.ts +176 -0
- package/src/config.ts +213 -11
- package/src/generated/compatibility-version.json +436 -168
- package/src/images/loop.ts +119 -36
- package/src/lib/admission.ts +12 -6
- package/src/lib/redact.ts +7 -0
- package/src/lib/translator-budget.ts +4 -3
- package/src/lib/windows-atomic-replace.ts +1 -0
- package/src/lib/windows-elevation.ts +1 -1
- package/src/oauth/chatgpt-device.ts +62 -5
- package/src/oauth/devin/cli-import.ts +130 -0
- package/src/oauth/devin.ts +63 -8
- package/src/oauth/index.ts +29 -14
- package/src/oauth/kiro.ts +18 -6
- package/src/oauth/login-cli.ts +9 -1
- package/src/oauth/meta-muse-device.ts +464 -0
- package/src/oauth/meta-muse.ts +123 -32
- package/src/oauth/pool-kernel.ts +9 -0
- package/src/oauth/pool-settings-capability.ts +2 -2
- package/src/oauth/store.ts +57 -0
- package/src/oauth/types.ts +31 -0
- package/src/providers/derive.ts +13 -3
- package/src/providers/devin-cli-authmode-migration.ts +57 -35
- package/src/providers/devin-provider-merge-migration.ts +240 -0
- package/src/providers/muse-key-quota.ts +117 -0
- package/src/providers/muse-subscription-usage.ts +14 -2
- package/src/providers/openai-sidecar.ts +25 -3
- package/src/providers/opencode-zen-rate-limit.ts +58 -0
- package/src/providers/provider-id-rewrite.ts +20 -5
- package/src/providers/quota-types.ts +12 -0
- package/src/providers/quota.ts +143 -102
- package/src/providers/reasoning-metadata.ts +543 -0
- package/src/providers/registry.ts +80 -49
- package/src/reasoning-effort.ts +26 -2
- package/src/remote/hub-usage.ts +32 -0
- package/src/remote-control/index.ts +192 -41
- package/src/remote-control/workspace-activation.ts +9 -0
- package/src/remote-control/workspace-agent-connection.ts +366 -0
- package/src/remote-control/workspace-claude-runtime.ts +243 -0
- package/src/remote-control/workspace-codex-runtime.ts +531 -0
- package/src/remote-control/workspace-codex-sandbox.ts +115 -0
- package/src/remote-control/workspace-command-runner.ts +748 -0
- package/src/remote-control/workspace-coordinator.ts +231 -0
- package/src/remote-control/workspace-device.ts +585 -0
- package/src/remote-control/workspace-executable.ts +43 -0
- package/src/remote-control/workspace-executor.ts +397 -0
- package/src/remote-control/workspace-hub.ts +519 -0
- package/src/remote-control/workspace-pi-runtime.ts +382 -0
- package/src/remote-control/workspace-process.ts +129 -0
- package/src/remote-control/workspace-rpc.ts +304 -0
- package/src/remote-control/workspace-runtime.ts +60 -0
- package/src/remote-control/workspace-secret-store.ts +39 -0
- package/src/remote-control/workspace-sessions.ts +799 -0
- package/src/remote-control/workspace-tool-bridge.ts +192 -0
- package/src/responses/code-mode-helper-compat.ts +22 -3
- package/src/responses/hosted-tool-policy.ts +0 -1
- package/src/responses/muse-tool-name-alias.ts +379 -0
- package/src/responses/plaintext-v2-agent-messages.ts +902 -0
- package/src/router.ts +7 -0
- package/src/routing/compatibility/behavior.ts +0 -1
- package/src/server/audio-client.ts +64 -0
- package/src/server/audio-dictation.ts +91 -0
- package/src/server/audio-live.ts +185 -0
- package/src/server/audio-transcriptions.ts +183 -0
- package/src/server/audio-upstream.ts +153 -0
- package/src/server/auth-cors.ts +61 -2
- package/src/server/chat-completions.ts +1 -1
- package/src/server/chat-native-sse.ts +92 -48
- package/src/server/chat-native.ts +37 -15
- package/src/server/hub-usage.ts +57 -0
- package/src/server/images.ts +4 -0
- package/src/server/index.ts +722 -57
- package/src/server/lifecycle.ts +5 -6
- package/src/server/live-call-bindings.ts +60 -0
- package/src/server/live.ts +12 -1
- package/src/server/management/agent-settings-routes.ts +25 -4
- package/src/server/management/api-access.ts +37 -0
- package/src/server/management/api-key-usage.ts +7 -2
- package/src/server/management/config-routes.ts +1 -18
- package/src/server/management/context.ts +15 -0
- package/src/server/management/logs-usage-routes.ts +2 -0
- package/src/server/management/oauth-account-routes.ts +39 -12
- package/src/server/management/provider-routes.ts +125 -2
- package/src/server/management/remote-workspace-routes.ts +140 -0
- package/src/server/management/route-registry.ts +15 -0
- package/src/server/management/usage-aggregate-cache.ts +14 -15
- package/src/server/management/usage-summary-cache.ts +2 -0
- package/src/server/management-api.ts +23 -0
- package/src/server/ports.ts +17 -0
- package/src/server/relay-eager.ts +4 -1
- package/src/server/relay.ts +70 -10
- package/src/server/request-decompress.ts +6 -3
- package/src/server/responses/agent-task-recovery.ts +25 -32
- package/src/server/responses/codex-auth-error.ts +11 -0
- package/src/server/responses/codex-ws-exchange.ts +52 -3
- package/src/server/responses/codex-ws-wire.ts +55 -0
- package/src/server/responses/compact.ts +9 -1
- package/src/server/responses/core.ts +337 -73
- package/src/server/responses/encrypted-payload.ts +45 -2
- package/src/server/responses/ws-upstream.ts +4 -1
- package/src/server/responses-self-named-namespace-scrub.ts +1 -3
- package/src/server/responses-undeclared-tool-guard.ts +1 -1
- package/src/server/search.ts +3 -0
- package/src/server/sse-payload-rewrite.ts +136 -51
- package/src/server/ws-bridge.ts +35 -1
- package/src/service/cli.ts +372 -0
- package/src/service/diagnostics.ts +340 -0
- package/src/service/guards.ts +303 -0
- package/src/service/health.ts +222 -0
- package/src/service/launchd.ts +853 -0
- package/src/service/orchestration.ts +617 -0
- package/src/service/repair.ts +334 -0
- package/src/service/state.ts +363 -0
- package/src/service/systemd.ts +229 -0
- package/src/service/windows-ops.ts +690 -0
- package/src/service/windows-scheduler.ts +769 -0
- package/src/service/windows-taskxml.ts +613 -0
- package/src/service.ts +22 -5550
- package/src/storage/cleanup/db.ts +258 -0
- package/src/storage/cleanup/execute.ts +358 -0
- package/src/storage/cleanup/paths.ts +189 -0
- package/src/storage/cleanup/pending.ts +140 -0
- package/src/storage/cleanup/preview.ts +292 -0
- package/src/storage/cleanup/reconcile.ts +347 -0
- package/src/storage/cleanup/restore.ts +932 -0
- package/src/storage/cleanup/satellite.ts +474 -0
- package/src/storage/cleanup/staging.ts +129 -0
- package/src/storage/cleanup/types.ts +98 -0
- package/src/storage/cleanup.ts +49 -3127
- package/src/types/accounts.ts +2 -0
- package/src/types/config.ts +13 -12
- package/src/types/provider.ts +37 -0
- package/src/types/request.ts +2 -0
- package/src/types/tools.ts +17 -5
- package/src/types.ts +1 -0
- package/src/usage/expected-prices.ts +127 -0
- package/src/usage/log.ts +58 -1
- package/src/vision/eligibility.ts +13 -2
- package/src/web-search/loop.ts +56 -3
- package/gui/dist/assets/index-CWXut3rG.js +0 -115
- package/gui/dist/assets/index-EdoPnm9_.css +0 -1
- package/src/adapters/devin-cli/acp.ts +0 -204
- package/src/adapters/devin-cli/adapter.ts +0 -345
- package/src/adapters/devin-cli/binary.ts +0 -69
- package/src/adapters/devin-cli/models.ts +0 -57
- package/src/oauth/devin-cli.ts +0 -149
- package/src/server/responses-reasoning-summary-rewrite.ts +0 -178
package/src/oauth/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
...(
|
|
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
|
-
//
|
|
294
|
-
//
|
|
295
|
-
|
|
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
|
|
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 {
|
package/src/oauth/login-cli.ts
CHANGED
|
@@ -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
|
+
}
|