@bitkyc08/opencodex 2.40.0 → 2.41.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 (72) hide show
  1. package/README.md +4 -0
  2. package/gui/dist/assets/index-B2YjLA-i.css +1 -0
  3. package/gui/dist/assets/{index-BHe2rl_C.js → index-aPup8CKb.js} +20 -20
  4. package/gui/dist/index.html +2 -2
  5. package/gui/dist/provider-icons/meta.svg +1 -0
  6. package/package.json +4 -3
  7. package/src/adapters/cursor/catalog.ts +71 -29
  8. package/src/adapters/cursor/claude-id.ts +76 -0
  9. package/src/adapters/cursor/discovery.ts +16 -3
  10. package/src/adapters/cursor/effort-map.ts +27 -12
  11. package/src/adapters/google.ts +39 -2
  12. package/src/adapters/openai-responses.ts +14 -1
  13. package/src/cli/claude.ts +11 -2
  14. package/src/cli/connect.ts +7 -1
  15. package/src/cli/registry.ts +1 -1
  16. package/src/cli/status.ts +19 -4
  17. package/src/client/connect.ts +5 -1
  18. package/src/client/hub-client.ts +29 -5
  19. package/src/clients/config-export.ts +12 -2
  20. package/src/codex/catalog/aggregation.ts +8 -0
  21. package/src/codex/catalog/metadata.ts +5 -0
  22. package/src/codex/catalog/parsing.ts +2 -0
  23. package/src/codex/catalog/provider-fetch.ts +163 -26
  24. package/src/codex/catalog.ts +1 -1
  25. package/src/codex/convergence-types.ts +1 -0
  26. package/src/codex/desired-state.ts +18 -11
  27. package/src/combos/failover.ts +185 -6
  28. package/src/combos/index.ts +6 -0
  29. package/src/combos/resolve.ts +43 -6
  30. package/src/config.ts +5 -1
  31. package/src/generated/compatibility-version.json +85 -61
  32. package/src/generated/model-metadata.ts +1 -1
  33. package/src/grok/sync.ts +10 -2
  34. package/src/integrations/cursor-effort-table.ts +143 -0
  35. package/src/integrations/state.ts +1 -1
  36. package/src/integrations/writer.ts +2 -2
  37. package/src/lib/app-owned-memory-stores.ts +27 -8
  38. package/src/lib/bounded-body.ts +16 -1
  39. package/src/oauth/generic-account-failover.ts +2 -2
  40. package/src/oauth/index.ts +11 -0
  41. package/src/oauth/meta-muse.ts +235 -0
  42. package/src/providers/antigravity-models.ts +71 -13
  43. package/src/providers/command-code-efforts.ts +15 -0
  44. package/src/providers/free-directory.ts +4 -1
  45. package/src/providers/registry.ts +116 -8
  46. package/src/responses/code-mode-helper-compat.ts +4 -1
  47. package/src/responses/state.ts +5 -4
  48. package/src/server/auth-cors.ts +241 -56
  49. package/src/server/chat-completions.ts +11 -2
  50. package/src/server/chat-native.ts +30 -4
  51. package/src/server/claude-messages.ts +17 -3
  52. package/src/server/effort-row.ts +131 -0
  53. package/src/server/index.ts +67 -38
  54. package/src/server/management/api-key-rotation.ts +2 -1
  55. package/src/server/management/api-key-usage.ts +97 -43
  56. package/src/server/management/context.ts +3 -0
  57. package/src/server/management/cursor-integration-routes.ts +36 -7
  58. package/src/server/management/logs-usage-routes.ts +64 -87
  59. package/src/server/management/provider-routes.ts +218 -1
  60. package/src/server/management/route-registry.ts +1 -0
  61. package/src/server/management/usage-aggregate-cache.ts +464 -0
  62. package/src/server/management/usage-summary-cache.ts +4 -0
  63. package/src/server/models-capabilities.ts +60 -5
  64. package/src/server/responses/core.ts +61 -7
  65. package/src/types/config.ts +10 -1
  66. package/src/types/tools.ts +12 -9
  67. package/src/usage/expected-prices.ts +43 -7
  68. package/src/usage/ledger-scanner.ts +448 -0
  69. package/src/usage/log.ts +1 -1
  70. package/src/usage/summary.ts +915 -655
  71. package/src/web-search/index.ts +1 -1
  72. package/gui/dist/assets/index-CJSb3HPe.css +0 -1
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Cursor's local-agent effort table, read from the installed bundle.
3
+ *
4
+ * Cursor Private Inference decides which model rows get a Reasoning control from a table
5
+ * compiled into extensions/cursor-agent-exec/dist/main.js, not from the gateway's
6
+ * reasoning_effort list (devlog 260902_cursor_bundle_effort_table/000). Reading that table
7
+ * from the install the dashboard already detects lets the prediction follow a Cursor update
8
+ * instead of a hand-copied mirror. Read-only, size-bounded, cached by (path, mtime, size);
9
+ * any parse failure yields null so the caller falls back to the static mirror.
10
+ */
11
+ import { readFileSync, statSync } from "node:fs";
12
+ import { join } from "node:path";
13
+ import type { CursorInstall } from "./cursor-detect";
14
+
15
+ export interface CursorEffortFamily {
16
+ id: string;
17
+ pattern: RegExp;
18
+ /** [] = family matched but Cursor shows no control. */
19
+ ladder: readonly string[];
20
+ param?: "reasoning_effort" | "output_config.effort";
21
+ defaultValue?: string;
22
+ outputCap?: number;
23
+ requiresReasoningCapability: boolean;
24
+ }
25
+
26
+ export interface CursorBareGpt5Rule {
27
+ pattern: RegExp;
28
+ ladder: readonly string[];
29
+ defaultValue: string;
30
+ }
31
+
32
+ export interface CursorEffortTable {
33
+ families: readonly CursorEffortFamily[];
34
+ /** The bare gpt-5 / gpt-5.x rule that runs when no family matched. */
35
+ bareGpt5: CursorBareGpt5Rule | null;
36
+ version: string | null;
37
+ bundlePath: string;
38
+ }
39
+
40
+ const BUNDLE_MAX_BYTES = 32 * 1024 * 1024;
41
+
42
+ /** Bundle path under the install root cursor-detect reports. */
43
+ export function cursorAgentBundlePath(install: Pick<CursorInstall, "path">, platform: string = process.platform): string {
44
+ const tail = ["extensions", "cursor-agent-exec", "dist", "main.js"];
45
+ return platform === "darwin"
46
+ ? join(install.path, "Contents", "Resources", "app", ...tail)
47
+ : join(install.path, "resources", "app", ...tail);
48
+ }
49
+
50
+ /**
51
+ * Parse the family table out of the minified source:
52
+ * const w={param:"reasoning_effort",values:[...],defaultValue:"medium"};
53
+ * ...const T={...},k={...},S={...},b=[{id:"...",matches:e=>/.../u.test(e),effort:k,outputCap:128e3},...];
54
+ * Identifier names are minifier-assigned, so binding is by structure: every
55
+ * <ident>={param:"...",values:[...],defaultValue:"..."} is an effort constant, and a family's
56
+ * effort: is either such an identifier or an inline object.
57
+ */
58
+ export function parseCursorEffortTable(source: string): Omit<CursorEffortTable, "version" | "bundlePath"> | null {
59
+ const constants = new Map<string, { param: string; values: string[]; defaultValue: string }>();
60
+ const constRe = /(?:const |,)([A-Za-z_$][\w$]*)=\{param:"(reasoning_effort|output_config\.effort)",values:\[([^\]]*)\],defaultValue:"([a-z]+)"\}/gu;
61
+ for (const m of source.matchAll(constRe)) {
62
+ constants.set(m[1]!, { param: m[2]!, values: splitStrings(m[3]!), defaultValue: m[4]! });
63
+ }
64
+ const tableStart = source.indexOf('=[{id:"anthropic-');
65
+ if (tableStart === -1) return null;
66
+ const tableEnd = source.indexOf("];", tableStart);
67
+ if (tableEnd === -1) return null;
68
+ const body = source.slice(tableStart + 2, tableEnd + 1);
69
+ const entryRe = /\{id:"([^"]+)",matches:e=>\/((?:\\\/|[^/])+)\/([a-z]*)\.test\(e\)((?:,(?:effort:(?:[A-Za-z_$][\w$]*|\{[^}]*\})|outputCap:[\de.]+|effortRequiresReasoningCapability:!0))*)\}/gu;
70
+ const families: CursorEffortFamily[] = [];
71
+ // Every "{id:" opener in the window must be consumed by entryRe. A build that adds a
72
+ // property to one family would otherwise drop that family silently and the caller would
73
+ // report a bundle-sourced "no control" for it instead of falling back to the mirror.
74
+ const openers = body.split('{id:"').length - 1;
75
+ for (const m of body.matchAll(entryRe)) {
76
+ let pattern: RegExp;
77
+ try { pattern = new RegExp(m[2]!, m[3]!); } catch { return null; }
78
+ const tail = m[4]!;
79
+ const effortRef = /effort:([A-Za-z_$][\w$]*)(?:,|$)/u.exec(tail)?.[1];
80
+ const inline = /effort:\{param:"([^"]+)",values:\[([^\]]*)\],defaultValue:"([a-z]+)"\}/u.exec(tail);
81
+ const effort = inline
82
+ ? { param: inline[1]!, values: splitStrings(inline[2]!), defaultValue: inline[3]! }
83
+ : effortRef ? constants.get(effortRef) : undefined;
84
+ if (effortRef && !inline && !effort) return null; // unknown constant: structure changed
85
+ const cap = /outputCap:([\de.]+)/u.exec(tail)?.[1];
86
+ families.push({
87
+ id: m[1]!,
88
+ pattern,
89
+ ladder: effort?.values ?? [],
90
+ ...(effort ? { param: effort.param as CursorEffortFamily["param"], defaultValue: effort.defaultValue } : {}),
91
+ ...(cap ? { outputCap: Number(cap) } : {}),
92
+ requiresReasoningCapability: tail.includes("effortRequiresReasoningCapability:!0"),
93
+ });
94
+ }
95
+ if (families.length === 0 || families.length !== openers) return null;
96
+ // The tested variable and the returned constant are minifier-assigned names; bind by shape.
97
+ const bareRe = /if\(\/(\^gpt-5[^/]+)\/([a-z]*)\.test\([A-Za-z_$][\w$]*\)\)return ([A-Za-z_$][\w$]*)\}/u.exec(source);
98
+ const bareConst = bareRe ? constants.get(bareRe[3]!) : undefined;
99
+ let bareGpt5: CursorBareGpt5Rule | null = null;
100
+ if (bareRe && bareConst) {
101
+ let pattern: RegExp;
102
+ try { pattern = new RegExp(bareRe[1]!, bareRe[2]!); } catch { return null; }
103
+ bareGpt5 = { pattern, ladder: bareConst.values, defaultValue: bareConst.defaultValue };
104
+ }
105
+ return { families, bareGpt5 };
106
+ }
107
+
108
+ function splitStrings(list: string): string[] {
109
+ return [...list.matchAll(/"([^"]+)"/gu)].map(m => m[1]!);
110
+ }
111
+
112
+ export interface CursorEffortTableDeps {
113
+ platform: string;
114
+ stat(path: string): { mtimeMs: number; size: number } | null;
115
+ readText(path: string): string | null;
116
+ }
117
+
118
+ export function realCursorEffortTableDeps(): CursorEffortTableDeps {
119
+ return {
120
+ platform: process.platform,
121
+ stat: path => { try { const s = statSync(path); return { mtimeMs: s.mtimeMs, size: s.size }; } catch { return null; } },
122
+ readText: path => { try { return readFileSync(path, "utf8"); } catch { return null; } },
123
+ };
124
+ }
125
+
126
+ let cache: { key: string; table: CursorEffortTable | null } | null = null;
127
+
128
+ /** Table from the Private Inference install, else null (caller falls back to the static mirror). */
129
+ export function loadCursorEffortTable(install: CursorInstall | undefined, deps: CursorEffortTableDeps = realCursorEffortTableDeps()): CursorEffortTable | null {
130
+ if (!install) return null;
131
+ const bundlePath = cursorAgentBundlePath(install, deps.platform);
132
+ const st = deps.stat(bundlePath);
133
+ if (!st || st.size > BUNDLE_MAX_BYTES) return null;
134
+ const key = `${bundlePath}|${st.mtimeMs}|${st.size}`;
135
+ if (cache?.key === key) return cache.table;
136
+ const text = deps.readText(bundlePath);
137
+ const parsed = text ? parseCursorEffortTable(text) : null;
138
+ const table = parsed ? { ...parsed, version: install.version, bundlePath } : null;
139
+ cache = { key, table };
140
+ return table;
141
+ }
142
+
143
+ export function resetCursorEffortTableCacheForTests(): void { cache = null; }
@@ -369,7 +369,7 @@ export function exportContextOf(input: {
369
369
  * loopback, and every client we write into deserves the same answer the
370
370
  * export command already gives.
371
371
  */
372
- baseUrl: opencodeProxyBaseUrl(input.port, input.config.hostname),
372
+ baseUrl: opencodeProxyBaseUrl(input.port, input.config.hostname, input.config),
373
373
  models: input.models,
374
374
  config: input.config,
375
375
  };
@@ -12,7 +12,7 @@
12
12
  import { homedir } from "node:os";
13
13
  import { dirname } from "node:path";
14
14
  import { EXPORT_CLIENTS, type ExportModel, type ManagedContribution } from "../clients/config-export";
15
- import { isLoopbackHostname } from "../codex/inject";
15
+ import { shouldInjectApiAuthHeader } from "../codex/inject";
16
16
  import type { OcxConfig } from "../types";
17
17
  import { PARSE_FAILED, defaultIntegrationIO, loadTarget, parseConfig, type IntegrationIO } from "./config-io";
18
18
  import {
@@ -290,7 +290,7 @@ function applyOrRefreshIntegration(
290
290
  if (io.statKind(detectDir) !== "dir") {
291
291
  return refuse(clientId, "not_installed", "absent", `${clientId} is not installed`);
292
292
  }
293
- if (isLoopbackOnly(clientId) && !isLoopbackHostname(input.config.hostname)) {
293
+ if (isLoopbackOnly(clientId) && shouldInjectApiAuthHeader(input.config)) {
294
294
  return refuse(clientId, "non_loopback", classified.state,
295
295
  `The generated ${clientId} integration is loopback-only and does not emit the admission header a non-loopback bind requires. Give it loopback access instead, through a tunnel or a local forwarder.`);
296
296
  }
@@ -38,6 +38,10 @@ import {
38
38
  discardRetainedUsageSnapshot,
39
39
  retainedUsageSnapshotStats,
40
40
  } from "../usage/log";
41
+ import {
42
+ discardRetainedUsageAggregate,
43
+ usageAggregateRetainedStats,
44
+ } from "../server/management/usage-aggregate-cache";
41
45
  import {
42
46
  cursorBlobRetainedStoreSnapshot,
43
47
  evictOldestCursorBlobForBudget,
@@ -61,18 +65,33 @@ function ringSnapshot(metrics: { entries: number; bytes: number; oldestAt: numbe
61
65
  };
62
66
  }
63
67
 
64
- /** The retained usage tail is a single all-or-nothing entry: evicting it drops the whole tail. */
68
+ /** Legacy parsed tail and streaming aggregate share one stable public store id. */
65
69
  function usageSnapshotRetainedStoreSnapshot(): RetainedStoreSnapshot {
66
- const stats = retainedUsageSnapshotStats();
70
+ const legacy = retainedUsageSnapshotStats();
71
+ const aggregate = usageAggregateRetainedStats();
72
+ const oldest = [legacy.oldestAt, aggregate.oldestAt]
73
+ .filter((value): value is number => value !== null)
74
+ .sort((a, b) => a - b)[0] ?? null;
67
75
  return {
68
- count: stats.count,
69
- bytes: stats.bytes,
70
- evictableBytes: stats.bytes,
71
- pinnedBytes: 0,
72
- oldestAt: stats.oldestAt,
76
+ count: legacy.count + aggregate.count,
77
+ bytes: legacy.bytes + aggregate.bytes,
78
+ evictableBytes: legacy.bytes + aggregate.evictableBytes,
79
+ pinnedBytes: aggregate.pinnedBytes,
80
+ oldestAt: oldest,
73
81
  };
74
82
  }
75
83
 
84
+ function evictOldestUsageSnapshot(): number {
85
+ const legacy = retainedUsageSnapshotStats();
86
+ const aggregate = usageAggregateRetainedStats();
87
+ if (legacy.bytes > 0
88
+ && (aggregate.evictableBytes === 0
89
+ || (legacy.oldestAt ?? Number.POSITIVE_INFINITY) <= (aggregate.oldestAt ?? Number.POSITIVE_INFINITY))) {
90
+ return discardRetainedUsageSnapshot();
91
+ }
92
+ return discardRetainedUsageAggregate();
93
+ }
94
+
76
95
  function providerDebugSnapshot(): RetainedStoreSnapshot {
77
96
  return ringSnapshot(debugBufferMetrics());
78
97
  }
@@ -154,7 +173,7 @@ export const APP_OWNED_RETAINED_STORE_REGISTRATIONS = [
154
173
  id: "usage_snapshot",
155
174
  category: "caches",
156
175
  snapshot: usageSnapshotRetainedStoreSnapshot,
157
- evictOldest: discardRetainedUsageSnapshot,
176
+ evictOldest: evictOldestUsageSnapshot,
158
177
  },
159
178
  {
160
179
  id: "cursor_blobs",
@@ -1,3 +1,5 @@
1
+ import { idleDeadline } from "./abort";
2
+
1
3
  /** Maximum number of response-body bytes that may be retained for an error. */
2
4
  export const BOUNDED_BODY_MAX_BYTES = 65_536;
3
5
 
@@ -49,6 +51,8 @@ export interface BoundedBytesOptions {
49
51
  signal?: AbortSignal;
50
52
  /** Maximum number of raw bytes retained from the response body. */
51
53
  maxBytes: number;
54
+ /** Deadline between non-empty raw chunks. Omitted means no body-read deadline. */
55
+ inactivityTimeoutMs?: number;
52
56
  }
53
57
 
54
58
  export interface BoundedBytesResult {
@@ -136,6 +140,15 @@ export async function readBoundedResponseBytes(
136
140
  let retainedBytes = 0;
137
141
  let mustCancel = false;
138
142
  let cancelReason: unknown;
143
+ const inactivityReason = new DOMException("Response body stalled", "TimeoutError");
144
+ let rejectForInactivity: ((reason: unknown) => void) | undefined;
145
+ const inactive = new Promise<never>((_resolve, reject) => {
146
+ rejectForInactivity = reject;
147
+ });
148
+ const inactivity = options.inactivityTimeoutMs === undefined
149
+ ? null
150
+ : idleDeadline(options.inactivityTimeoutMs, () => rejectForInactivity?.(inactivityReason));
151
+ inactivity?.reset();
139
152
 
140
153
  let rejectForAbort: ((reason: unknown) => void) | undefined;
141
154
  const aborted = new Promise<never>((_resolve, reject) => {
@@ -151,7 +164,7 @@ export async function readBoundedResponseBytes(
151
164
  const read = reader.read();
152
165
  // Observe a late read rejection when abort/cancellation wins the race.
153
166
  void read.catch(() => undefined);
154
- const outcome = await Promise.race([read, aborted]);
167
+ const outcome = await Promise.race([read, aborted, inactive]);
155
168
  if (signal?.aborted) {
156
169
  mustCancel = true;
157
170
  cancelReason = signal.reason;
@@ -163,6 +176,7 @@ export async function readBoundedResponseBytes(
163
176
  return { bytes: retained.subarray(0, retainedBytes), oversized: false };
164
177
  }
165
178
  if (!value || value.byteLength === 0) continue;
179
+ inactivity?.reset();
166
180
 
167
181
  if (value.byteLength > maxBytes - retainedBytes) {
168
182
  mustCancel = true;
@@ -187,6 +201,7 @@ export async function readBoundedResponseBytes(
187
201
  cancelReason = error;
188
202
  throw error;
189
203
  } finally {
204
+ inactivity?.cancel();
190
205
  signal?.removeEventListener("abort", onAbort);
191
206
  if (mustCancel) cancelWithoutWaiting(reader, cancelReason);
192
207
  try {
@@ -202,11 +202,11 @@ export function rotateGenericOAuthAccountOn429(
202
202
  // A single stored account has nowhere to go; rotating to itself would just replay the 429.
203
203
  if (!set || set.accounts.length < 2) return null;
204
204
 
205
- const parsed = parseRetryAfterMs(retryAfterHeader, now);
205
+ const parsed = parseRetryAfterMs(retryAfterHeader, now, { preserveImmediate: true });
206
206
  // An account whose allowance is provably spent gets a reset-aligned cooldown instead of
207
207
  // the default minute: retrying it every 60s until the window rolls over is pure waste.
208
208
  // A Retry-After from upstream still wins — it is the server's own instruction.
209
- const exhausted = parsed === null ? exhaustedCooldownMs(providerName, failedAccountId, now) : null;
209
+ const exhausted = parsed === undefined ? exhaustedCooldownMs(providerName, failedAccountId, now) : null;
210
210
  const cooldownMs = exhausted ?? Math.min(parsed ?? DEFAULT_COOLDOWN_MS, MAX_COOLDOWN_MS);
211
211
  health.set(healthKey(providerName, failedAccountId), {
212
212
  cooldownUntil: now + cooldownMs,
@@ -39,6 +39,7 @@ import { loginAntigravity, refreshAntigravityToken } from "./google-antigravity"
39
39
  import { loginCursor, refreshCursorToken } from "./cursor";
40
40
  import { loginGithubCopilot, refreshGithubCopilotToken, validateCopilotApiBaseUrl } from "./github-copilot";
41
41
  import { loginCommandCode, refreshCommandCodeToken } from "./command-code";
42
+ import { loginMetaMuse, refreshMetaMuseToken } from "./meta-muse";
42
43
  import { ANTIGRAVITY_REQUEST_UA } from "../adapters/google-antigravity-wire";
43
44
  import { deriveOAuthDefaultModel, deriveOAuthProviderConfig } from "../providers/derive";
44
45
  import { apiKeyPoolEntryId, sanitizeApiKeyValue } from "../providers/api-keys";
@@ -228,6 +229,16 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderDef> = {
228
229
  providerConfig: oauthConfig("kimi"),
229
230
  defaultModel: oauthDefaultModel("kimi"),
230
231
  },
232
+ "meta-muse": {
233
+ login: ctrl => loginMetaMuse(ctrl),
234
+ refresh: refreshMetaMuseToken,
235
+ providerConfig: oauthConfig("meta-muse"),
236
+ defaultModel: oauthDefaultModel("meta-muse"),
237
+ // Static API key that Meta scopes to its own CLI. Never generate unattended traffic
238
+ // on it — same posture as anthropic, for the same reason: the vendor restricts use
239
+ // outside its own client, so every exchange stays attributable to a user action.
240
+ defaultRefreshPolicy: "disabled",
241
+ },
231
242
  nous: {
232
243
  // Nous Portal device-grant login (RFC 8628) against portal.nousresearch.com.
233
244
  // The access token is the per-request inference JWT (scope inference:invoke).
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Meta Muse Code credential import.
3
+ *
4
+ * The Muse Code CLI signs in through a browser device-approval flow and stores the
5
+ * result in two places: `~/.config/muse/auth.json` is a POINTER carrying no secret, and
6
+ * the secret itself lives in the macOS Keychain under service
7
+ * `ai.meta.dev.credentials`, account `meta`.
8
+ *
9
+ * Two measured facts shape this module (devlog/_plan/260903_muse_spark_plan_oauth/003):
10
+ *
11
+ * 1. The Keychain payload holds BOTH an `access_token` and an `api_key`, and only the
12
+ * `api_key` authenticates the Model API — the OAuth access token returns 401
13
+ * `invalid_api_key`. So this is a static-key credential, not a refreshable one.
14
+ * 2. Meta scopes that credential to the Muse Code CLI in writing. Reusing it here is an
15
+ * UNSUPPORTED path the repository owner opted into deliberately, which is why the
16
+ * warning below fires before anything is read and why the provider sits in the GUI's
17
+ * HIGH_RISK ToS map.
18
+ *
19
+ * This module never spawns the CLI. A login that finds no credential explains what to
20
+ * run rather than running it: `muse login` is interactive with no machine-readable mode,
21
+ * so a spawned child could outlive cancellation, and polling for the pointer file would
22
+ * be satisfied instantly by the one already on disk — reimporting the OLD account on a
23
+ * force-login.
24
+ */
25
+ import { homedir } from "node:os";
26
+ import { join } from "node:path";
27
+ import { sanitizeApiKeyValue } from "../providers/api-keys";
28
+ import type { OAuthController, OAuthCredentials } from "./types";
29
+
30
+ const MUSE_POINTER_PATH = join(homedir(), ".config", "muse", "auth.json");
31
+ const KEYCHAIN_SERVICE = "ai.meta.dev.credentials";
32
+ const KEYCHAIN_ACCOUNT = "meta";
33
+ const MODELS_URL = "https://api.meta.ai/v1/models";
34
+ const VALIDATE_TIMEOUT_MS = 10_000;
35
+ const KEYCHAIN_TIMEOUT_MS = 5_000;
36
+
37
+ /**
38
+ * Shown BEFORE any credential is read.
39
+ *
40
+ * `login-cli.ts` passes `onProgress` straight to `console.log` and never reads the
41
+ * registry note, so this is the CLI's only warning surface. The GUI ignores it because
42
+ * `OAuthTosWarningModal` has already been acknowledged by then.
43
+ */
44
+ const CONSENT_WARNING = [
45
+ "Meta scopes the Muse Code credential to the Muse Code CLI.",
46
+ "Using it here is UNSUPPORTED: Meta does not authorize subscription coverage outside its own CLI,",
47
+ "how these calls settle is not observable from the API, and you should treat every call as billable.",
48
+ "The imported key is copied into OpenCodex's auth store (~/.opencodex/auth.json, 0600).",
49
+ "Supported alternative: the meta-model provider with your own key (META_MODEL_API_KEY).",
50
+ ].join(" ");
51
+
52
+ /** The Keychain payload. `access_token` is deliberately unused — it 401s (003 §B). */
53
+ interface MuseKeychainSecret {
54
+ api_key?: unknown;
55
+ access_token?: unknown;
56
+ }
57
+
58
+ interface MusePointer {
59
+ providers?: { meta?: { mechanism?: unknown; storage?: unknown; user_email?: unknown } };
60
+ }
61
+
62
+ /** Injected so tests never touch the real Keychain, filesystem, platform, or network. */
63
+ export interface MuseImportDeps {
64
+ platform?: string;
65
+ readPointer?: () => Promise<string | null>;
66
+ readKeychain?: (signal?: AbortSignal) => Promise<string | null>;
67
+ fetchImpl?: typeof fetch;
68
+ }
69
+
70
+ async function defaultReadPointer(): Promise<string | null> {
71
+ try {
72
+ return await Bun.file(MUSE_POINTER_PATH).text();
73
+ } catch {
74
+ return null;
75
+ }
76
+ }
77
+
78
+ /**
79
+ * `security` can block indefinitely — the Keychain may raise an interactive approval
80
+ * prompt, and on a headless or locked machine nobody answers it. Without a deadline the
81
+ * login would hang before the validation timeout below is even created, so the bound
82
+ * lives here rather than only around the fetch.
83
+ */
84
+ async function defaultReadKeychain(signal?: AbortSignal): Promise<string | null> {
85
+ const deadline = signal
86
+ ? AbortSignal.any([signal, AbortSignal.timeout(KEYCHAIN_TIMEOUT_MS)])
87
+ : AbortSignal.timeout(KEYCHAIN_TIMEOUT_MS);
88
+ let proc: Bun.Subprocess<"ignore", "pipe", "pipe"> | undefined;
89
+ try {
90
+ proc = Bun.spawn(
91
+ ["security", "find-generic-password", "-s", KEYCHAIN_SERVICE, "-a", KEYCHAIN_ACCOUNT, "-w"],
92
+ { stdout: "pipe", stderr: "pipe" },
93
+ );
94
+ const child = proc;
95
+ const finished = Promise.all([new Response(child.stdout).text(), child.exited]);
96
+ const timedOut = new Promise<null>((resolve) => {
97
+ if (deadline.aborted) { resolve(null); return; }
98
+ deadline.addEventListener("abort", () => resolve(null), { once: true });
99
+ });
100
+ const settled = await Promise.race([finished, timedOut]);
101
+ if (settled === null) return null;
102
+ const [out, code] = settled;
103
+ if (code !== 0) return null;
104
+ const trimmed = out.trim();
105
+ return trimmed.length > 0 ? trimmed : null;
106
+ } catch {
107
+ return null;
108
+ } finally {
109
+ // A prompt still on screen keeps the child alive after the race resolves.
110
+ if (proc && proc.exitCode === null) { try { proc.kill(); } catch { /* already gone */ } }
111
+ }
112
+ }
113
+
114
+ const INSTALL_HINT =
115
+ "Install it from https://dev.meta.ai/install.sh, run `muse login`, then retry.";
116
+
117
+ function normalizedEmail(value: unknown): string | undefined {
118
+ if (typeof value !== "string") return undefined;
119
+ const trimmed = value.trim().toLowerCase();
120
+ return trimmed.length > 0 ? trimmed : undefined;
121
+ }
122
+
123
+ /**
124
+ * Import the credential the Muse Code CLI already holds.
125
+ *
126
+ * Every refusal names what the user should do. None of them includes the credential.
127
+ */
128
+ export async function loginMetaMuse(
129
+ ctrl: OAuthController = {},
130
+ deps: MuseImportDeps = {},
131
+ ): Promise<OAuthCredentials> {
132
+ // Before ANY read: the CLI has no other warning surface.
133
+ ctrl.onProgress?.(CONSENT_WARNING);
134
+
135
+ const platform = deps.platform ?? process.platform;
136
+ if (platform !== "darwin") {
137
+ throw new Error(
138
+ "Meta Muse Code login is macOS-only: the CLI stores its credential in the macOS Keychain, "
139
+ + "and no other platform's storage has been verified. Use the meta-model provider with your own key instead.",
140
+ );
141
+ }
142
+
143
+ const pointerRaw = await (deps.readPointer ?? defaultReadPointer)();
144
+ if (pointerRaw === null) {
145
+ throw new Error(`Muse Code CLI credential not found at ${MUSE_POINTER_PATH}. ${INSTALL_HINT}`);
146
+ }
147
+
148
+ let pointer: MusePointer;
149
+ try {
150
+ pointer = JSON.parse(pointerRaw) as MusePointer;
151
+ } catch {
152
+ throw new Error(`Muse Code credential file at ${MUSE_POINTER_PATH} is not valid JSON. Run \`muse login\` to rewrite it.`);
153
+ }
154
+
155
+ const meta = pointer.providers?.meta;
156
+ if (!meta || meta.mechanism !== "oauth") {
157
+ throw new Error("The Muse Code credential file has no signed-in Meta account. Run `muse login`, then retry.");
158
+ }
159
+ // A different storage backend is a shape we have not measured; refuse rather than guess.
160
+ if (meta.storage !== "keychain") {
161
+ throw new Error(
162
+ `Muse Code stored its credential with an unsupported backend (${String(meta.storage)}); only the macOS Keychain is verified.`,
163
+ );
164
+ }
165
+
166
+ const secretRaw = await (deps.readKeychain ?? defaultReadKeychain)(ctrl.signal);
167
+ if (secretRaw === null) {
168
+ throw new Error(
169
+ "Could not read the Muse Code credential from the macOS Keychain within 5s. Approve the Keychain prompt, or run `muse login` again.",
170
+ );
171
+ }
172
+
173
+ let secret: MuseKeychainSecret;
174
+ try {
175
+ secret = JSON.parse(secretRaw) as MuseKeychainSecret;
176
+ } catch {
177
+ throw new Error("The Muse Code Keychain entry is not valid JSON. Run `muse login` to rewrite it.");
178
+ }
179
+
180
+ // access_token is present but 401s against the Model API (003 §B) — never fall back to it.
181
+ const apiKey = sanitizeApiKeyValue(secret.api_key);
182
+ if (!apiKey) {
183
+ throw new Error("The Muse Code Keychain entry carries no usable API key. Run `muse login` again.");
184
+ }
185
+ if (!/^LLM\|\d+\|[A-Za-z0-9_-]{10,}$/.test(apiKey)) {
186
+ throw new Error("The Muse Code credential is not in the expected Meta API key format. Run `muse login` again.");
187
+ }
188
+
189
+ ctrl.onProgress?.("Validating the imported Meta credential…");
190
+ const fetchImpl = deps.fetchImpl ?? fetch;
191
+ // ctrl.signal is OPTIONAL and the CLI controller supplies none: AbortSignal.any([undefined])
192
+ // throws a TypeError, which would fail every CLI login right after the warning printed.
193
+ const signal = ctrl.signal
194
+ ? AbortSignal.any([ctrl.signal, AbortSignal.timeout(VALIDATE_TIMEOUT_MS)])
195
+ : AbortSignal.timeout(VALIDATE_TIMEOUT_MS);
196
+ let response: Response;
197
+ try {
198
+ response = await fetchImpl(MODELS_URL, {
199
+ headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" },
200
+ signal,
201
+ });
202
+ } catch (error) {
203
+ if (ctrl.signal?.aborted) throw ctrl.signal.reason ?? new DOMException("Meta Muse login aborted", "AbortError");
204
+ throw new Error(`Could not reach the Meta Model API to validate the credential: ${(error as Error).message}`);
205
+ }
206
+ if (!response.ok) {
207
+ throw new Error(
208
+ `The Muse Code credential was rejected by the Meta Model API (HTTP ${response.status}). Run \`muse login\` again.`,
209
+ );
210
+ }
211
+
212
+ return {
213
+ access: apiKey,
214
+ // Static key: there is nothing to exchange, so refresh carries the same value.
215
+ refresh: apiKey,
216
+ expires: Number.MAX_SAFE_INTEGER,
217
+ // `email`, not `accountId`: the account list masks email for display, and store.ts
218
+ // already falls back to it for slot identity, so multi-account still works.
219
+ ...(normalizedEmail(meta.user_email) ? { email: normalizedEmail(meta.user_email) } : {}),
220
+ source: "local-cli",
221
+ };
222
+ }
223
+
224
+ /**
225
+ * Static-key refresh, exactly like Command Code's.
226
+ *
227
+ * This deliberately does NOT re-read the Keychain. Generic refresh writes its result into
228
+ * the slot being refreshed, so if the user ran `muse login` with a DIFFERENT account in
229
+ * between, a re-import would silently overwrite one stored identity with another. Only an
230
+ * explicit login may import.
231
+ */
232
+ export async function refreshMetaMuseToken(apiKey: string): Promise<OAuthCredentials> {
233
+ if (!apiKey) throw new Error("Meta Muse Code API key missing; run `ocx login meta-muse`");
234
+ return { access: apiKey, refresh: apiKey, expires: Number.MAX_SAFE_INTEGER, source: "local-cli" };
235
+ }