@bitkyc08/opencodex 2.39.0 → 2.40.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 (144) hide show
  1. package/README.md +49 -21
  2. package/gui/dist/assets/index-BHe2rl_C.js +112 -0
  3. package/gui/dist/assets/index-CJSb3HPe.css +1 -0
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +1 -1
  6. package/src/adapters/cursor/catalog.ts +140 -7
  7. package/src/adapters/cursor/discovery.ts +79 -87
  8. package/src/adapters/cursor/effort-map.ts +11 -0
  9. package/src/adapters/cursor/request-builder.ts +31 -4
  10. package/src/adapters/cursor.ts +17 -0
  11. package/src/adapters/openai-chat.ts +95 -9
  12. package/src/adapters/openai-responses.ts +73 -9
  13. package/src/bridge.ts +58 -13
  14. package/src/claude/auth-mode.ts +6 -9
  15. package/src/claude/gateway-cache.ts +23 -3
  16. package/src/claude/model-info.ts +13 -2
  17. package/src/cli/access.ts +30 -0
  18. package/src/cli/account-extended.ts +19 -10
  19. package/src/cli/capabilities.ts +40 -1
  20. package/src/cli/claude-agent-startup-sync.ts +3 -0
  21. package/src/cli/claude.ts +134 -36
  22. package/src/cli/connect.ts +226 -0
  23. package/src/cli/dispatch.ts +126 -21
  24. package/src/cli/gui-pair-client.ts +170 -0
  25. package/src/cli/gui.ts +87 -0
  26. package/src/cli/help.ts +4 -1
  27. package/src/cli/index.ts +76 -22
  28. package/src/cli/models-runtime-subcommands.ts +34 -0
  29. package/src/cli/models-runtime.ts +4 -0
  30. package/src/cli/models.ts +2 -1
  31. package/src/cli/opencode.ts +2 -1
  32. package/src/cli/provider-runtime.ts +31 -1
  33. package/src/cli/registry.ts +32 -2
  34. package/src/cli/runtime-api.ts +10 -1
  35. package/src/cli/status.ts +26 -0
  36. package/src/cli/system-command.ts +9 -3
  37. package/src/client/connect.ts +650 -0
  38. package/src/client/hub-client.ts +457 -0
  39. package/src/client/hub-relay.ts +288 -0
  40. package/src/client/machine-api.ts +139 -0
  41. package/src/client/machine-auth.ts +54 -0
  42. package/src/client/machine-listener.ts +143 -0
  43. package/src/client/runtime.ts +93 -0
  44. package/src/client/state.ts +175 -0
  45. package/src/clients/config-export.ts +16 -5
  46. package/src/codex/auth-api.ts +169 -15
  47. package/src/codex/auth-context.ts +61 -6
  48. package/src/codex/catalog/aggregation.ts +17 -13
  49. package/src/codex/catalog/metadata.ts +28 -0
  50. package/src/codex/catalog/provider-fetch.ts +38 -5
  51. package/src/codex/catalog.ts +1 -1
  52. package/src/codex/desired-state.ts +13 -3
  53. package/src/codex/inject.ts +212 -31
  54. package/src/codex/journal.ts +54 -6
  55. package/src/codex/main-account.ts +105 -33
  56. package/src/codex/native-main-claim.ts +23 -1
  57. package/src/codex/native-residue.ts +7 -0
  58. package/src/codex/reset-credit-auto-redeem.ts +237 -0
  59. package/src/combos/request.ts +15 -3
  60. package/src/combos/types.ts +12 -0
  61. package/src/config/paths.ts +15 -1
  62. package/src/config/provider-validation.ts +40 -0
  63. package/src/config.ts +447 -3
  64. package/src/generated/compatibility-version.json +224 -108
  65. package/src/images/artifacts.ts +73 -37
  66. package/src/images/fulfill.ts +6 -1
  67. package/src/images/index.ts +1 -1
  68. package/src/images/plan.ts +16 -2
  69. package/src/images/synthetic-tool.ts +5 -0
  70. package/src/images/xai-client.ts +34 -1
  71. package/src/integrations/cursor-detect.ts +133 -0
  72. package/src/integrations/cursor-seen.ts +31 -0
  73. package/src/lib/errors.ts +18 -0
  74. package/src/lib/gui-pair-capability.ts +104 -0
  75. package/src/lib/lab-live-route-production.ts +2 -1
  76. package/src/lib/process-control.ts +4 -3
  77. package/src/lib/service-secrets.ts +161 -1
  78. package/src/lib/windows-system-proxy.ts +115 -0
  79. package/src/oauth/index.ts +3 -2
  80. package/src/oauth/pool-settings-capability.ts +55 -0
  81. package/src/providers/api-keys.ts +3 -1
  82. package/src/providers/codex-capacity.ts +34 -4
  83. package/src/providers/derive.ts +10 -0
  84. package/src/providers/fastwire.ts +10 -2
  85. package/src/providers/key-store.ts +197 -0
  86. package/src/providers/model-rename-migration.ts +3 -0
  87. package/src/providers/openai-sidecar.ts +3 -3
  88. package/src/providers/quota.ts +91 -42
  89. package/src/providers/registry.ts +33 -3
  90. package/src/reasoning-effort.ts +32 -0
  91. package/src/remote/protocol.ts +109 -0
  92. package/src/responses/citation-markers.ts +101 -0
  93. package/src/responses/parser.ts +43 -3
  94. package/src/responses/spill-store.ts +9 -4
  95. package/src/router.ts +72 -8
  96. package/src/server/auth-cors.ts +37 -1
  97. package/src/server/gui-session.ts +449 -0
  98. package/src/server/gui-static.ts +24 -7
  99. package/src/server/images.ts +237 -10
  100. package/src/server/index.ts +356 -30
  101. package/src/server/management/agent-settings-routes.ts +7 -1
  102. package/src/server/management/api-key-rotation.ts +74 -0
  103. package/src/server/management/combo-routes.ts +18 -6
  104. package/src/server/management/config-routes.ts +22 -3
  105. package/src/server/management/context.ts +5 -1
  106. package/src/server/management/cursor-integration-routes.ts +98 -0
  107. package/src/server/management/logs-usage-routes.ts +2 -1
  108. package/src/server/management/model-routes.ts +79 -1
  109. package/src/server/management/model-rows.ts +23 -1
  110. package/src/server/management/oauth-account-routes.ts +135 -2
  111. package/src/server/management/provider-routes.ts +143 -9
  112. package/src/server/management/route-registry.ts +10 -0
  113. package/src/server/management/session-routes.ts +13 -0
  114. package/src/server/management/system-routes.ts +13 -1
  115. package/src/server/management-api.ts +8 -3
  116. package/src/server/management-auth.ts +124 -82
  117. package/src/server/models-capabilities.ts +124 -0
  118. package/src/server/ports.ts +7 -0
  119. package/src/server/proxy-liveness.ts +7 -0
  120. package/src/server/request-log.ts +11 -5
  121. package/src/server/responses/agent-task-recovery.ts +1 -0
  122. package/src/server/responses/codex-auth-error.ts +3 -1
  123. package/src/server/responses/collaboration.ts +26 -1
  124. package/src/server/responses/combo-stream-preflight.ts +25 -6
  125. package/src/server/responses/compact.ts +32 -12
  126. package/src/server/responses/context-overflow.ts +49 -0
  127. package/src/server/responses/core.ts +196 -12
  128. package/src/server/responses/fetch-helpers.ts +2 -1
  129. package/src/server/responses/outbound-body-guard.ts +110 -0
  130. package/src/server/responses/ws-upstream.ts +87 -6
  131. package/src/server/responses-self-named-namespace-scrub.ts +181 -0
  132. package/src/server/system-env.ts +78 -25
  133. package/src/service.ts +218 -42
  134. package/src/types/config.ts +134 -0
  135. package/src/types/provider.ts +54 -1
  136. package/src/types/request.ts +2 -0
  137. package/src/types.ts +5 -0
  138. package/src/update/job.ts +33 -5
  139. package/src/usage/cost.ts +32 -1
  140. package/src/usage/expected-prices.ts +16 -0
  141. package/src/usage/log.ts +6 -2
  142. package/src/usage/summary.ts +48 -6
  143. package/gui/dist/assets/index-D-lchsPw.js +0 -112
  144. package/gui/dist/assets/index-uvENYLin.css +0 -1
@@ -295,6 +295,69 @@ function pickPinnedAddress(addresses: PinnedAddress[]): PinnedAddress {
295
295
  return addresses.find(a => a.family === 4) ?? addresses[0]!;
296
296
  }
297
297
 
298
+ /**
299
+ * HTTPS-only destination check, public-address resolution, and pinned connect.
300
+ * Callers own the !ok / 3xx policy so image vs video error text can stay distinct.
301
+ */
302
+ async function connectPublicHttps(
303
+ url: string,
304
+ options: {
305
+ context: string;
306
+ signal?: AbortSignal;
307
+ pinnedDownload?: PinnedDownloadFn;
308
+ maxBytes?: number;
309
+ },
310
+ ): Promise<Response> {
311
+ let parsedUrl: URL;
312
+ try { parsedUrl = new URL(url); } catch { throw new Error(`${options.context} URL is not valid`); }
313
+ if (parsedUrl.protocol !== "https:") {
314
+ throw new Error(`${options.context} URL must use HTTPS, got ${parsedUrl.protocol}`);
315
+ }
316
+ const assessment = assessUrlDestination(url);
317
+ if (assessment && assessment.kind !== "public" && assessment.kind !== "hostname") {
318
+ throw new Error(`${options.context} URL targets ${assessment.detail}`);
319
+ }
320
+ const resolved = await resolvePublicAddresses(url, options.context);
321
+ const pinned = pickPinnedAddress(resolved.addresses);
322
+ const download = options.pinnedDownload ?? ((resource, peer, signal) =>
323
+ pinnedHttpGet(resource, peer, signal, {
324
+ // `maxBytes` is optional in pinnedHttpGet, so forwarding undefined removes the
325
+ // cap entirely instead of inheriting a default. Keep the 50 MiB ceiling when a
326
+ // caller omits a limit, and honour an explicit tighter one.
327
+ maxBytes: options.maxBytes ?? MAX_DOWNLOAD_BYTES,
328
+ context: `${options.context} download`,
329
+ }));
330
+ return download(url, pinned, options.signal);
331
+ }
332
+
333
+ /**
334
+ * Fetch a provider-returned image URL after destination-policy + pinned HTTPS.
335
+ * Redirects are not followed: the default pinned GET returns the status, and this
336
+ * helper rejects every non-2xx including 3xx. Throws a message that names the
337
+ * class of failure (scheme / destination kind / download) without reflecting the
338
+ * target URL.
339
+ */
340
+ export async function fetchPublicHttpsImage(
341
+ url: string,
342
+ options?: {
343
+ signal?: AbortSignal;
344
+ pinnedDownload?: PinnedDownloadFn;
345
+ maxBytes?: number;
346
+ },
347
+ ): Promise<Response> {
348
+ const resp = await connectPublicHttps(url, {
349
+ context: "image",
350
+ signal: options?.signal,
351
+ pinnedDownload: options?.pinnedDownload,
352
+ maxBytes: options?.maxBytes,
353
+ });
354
+ if (!resp.ok || (resp.status >= 300 && resp.status < 400)) {
355
+ try { await resp.body?.cancel(); } catch { /* ignore */ }
356
+ throw new Error("image download failed");
357
+ }
358
+ return resp;
359
+ }
360
+
298
361
  export async function downloadImageToArtifact(
299
362
  url: string,
300
363
  budget?: ImageBudget,
@@ -307,30 +370,11 @@ export async function downloadImageToArtifact(
307
370
  return materializeInlineImage(m[2], budget);
308
371
  }
309
372
 
310
- // SSRF protection: validate the provider-returned URL before fetching.
311
- // Require HTTPS strictly — plain HTTP and all other schemes (ftp, file, …) are rejected.
312
- // Resolve DNS once, then pin that public address for the HTTPS connect (SNI/Host keep
313
- // the original hostname) so a rebinding answer cannot retarget the TCP peer.
314
- let parsedUrl: URL;
315
- try { parsedUrl = new URL(url); } catch { throw new Error("image URL is not valid"); }
316
- if (parsedUrl.protocol !== "https:") {
317
- throw new Error(`image URL must use HTTPS, got ${parsedUrl.protocol}`);
318
- }
319
- // Reject literal private/loopback/link-local/metadata addresses.
320
- const assessment = assessUrlDestination(url);
321
- if (assessment && assessment.kind !== "public" && assessment.kind !== "hostname") {
322
- throw new Error(`image URL targets ${assessment.detail}`);
323
- }
324
- const resolved = await resolvePublicAddresses(url);
325
- const pinned = pickPinnedAddress(resolved.addresses);
326
- const download = options?.pinnedDownload ?? pinnedHttpsGet;
327
- const resp = await download(url, pinned, signal);
328
- if (!resp.ok) {
329
- // Custom `pinnedDownload` seams may still return a failed Response with a
330
- // live body; cancel it so unread error payloads cannot keep the socket warm.
331
- try { await resp.body?.cancel(); } catch { /* ignore */ }
332
- throw new Error("image download failed: " + resp.status);
333
- }
373
+ const resp = await fetchPublicHttpsImage(url, {
374
+ signal,
375
+ pinnedDownload: options?.pinnedDownload,
376
+ maxBytes: MAX_DOWNLOAD_BYTES,
377
+ });
334
378
 
335
379
  // Stream the body with a hard byte cap so a missing/lying Content-Length or a
336
380
  // compromised CDN URL cannot exhaust memory before the size check runs.
@@ -433,19 +477,11 @@ export async function downloadVideoToArtifact(
433
477
  return dest;
434
478
  }
435
479
 
436
- // SSRF protection: same validation as downloadImageToArtifact
437
- let parsedUrl: URL;
438
- try { parsedUrl = new URL(url); } catch { throw new Error("video URL is not valid"); }
439
- if (parsedUrl.protocol !== "https:") {
440
- throw new Error(`video URL must use HTTPS, got ${parsedUrl.protocol}`);
441
- }
442
- const assessment = assessUrlDestination(url);
443
- if (assessment && assessment.kind !== "public" && assessment.kind !== "hostname") {
444
- throw new Error(`video URL targets ${assessment.detail}`);
445
- }
446
- const resolved = await resolvePublicAddresses(url, "video");
447
- const pinned = pickPinnedAddress(resolved.addresses);
448
- const resp = await pinnedHttpsGet(url, pinned, signal, { maxBytes: MAX_VIDEO_DOWNLOAD_BYTES });
480
+ const resp = await connectPublicHttps(url, {
481
+ context: "video",
482
+ signal,
483
+ maxBytes: MAX_VIDEO_DOWNLOAD_BYTES,
484
+ });
449
485
  if (!resp.ok) {
450
486
  try { await resp.body?.cancel(); } catch { /* ignore */ }
451
487
  throw new Error("video download failed: " + resp.status);
@@ -91,11 +91,16 @@ export async function fulfillImageCall(
91
91
  typeof obj.image_url === "string" ? obj.image_url : typeof obj.image === "string" ? obj.image : undefined;
92
92
  const size = typeof obj.size === "string" ? obj.size : plan.defaultSize;
93
93
  const quality = typeof obj.quality === "string" ? obj.quality : plan.defaultQuality;
94
+ // Forward the raw literal and let callXaiImages own validation. Folding "auto"
95
+ // to undefined here would make the request look like it carried no ratio at
96
+ // all, so the client would derive one from `size` — the opposite of what an
97
+ // explicit Auto selection asks for.
98
+ const aspectRatio = typeof obj.aspect_ratio === "string" ? obj.aspect_ratio : undefined;
94
99
 
95
100
  let result;
96
101
  try {
97
102
  result = await callXaiImages(
98
- { prompt, model: plan.model, n, imageUrl, size, quality },
103
+ { prompt, model: plan.model, n, imageUrl, size, quality, aspectRatio },
99
104
  plan.auth,
100
105
  signal,
101
106
  plan.timeoutMs,
@@ -1,4 +1,4 @@
1
- export { planImageBridge, planVideoBridge, findXaiProvider, resolveXaiImageApiKey } from "./plan";
1
+ export { planImageBridge, planVideoBridge, findXaiProvider, resolveXaiImageApiKey, resolveXaiImageAuthToken } from "./plan";
2
2
  export { runWithImageBridge, clampImageMaxRounds, DEFAULT_MAX_ROUNDS, MAX_ROUNDS_HARD_LIMIT } from "./loop";
3
3
  export type { ImageBridgePlan, ImageCallResult, VideoBridgePlan, VideoCallResult } from "./types";
4
4
  export { buildImageTool, buildVideoTool, extractHostedImageGeneration, IMAGE_GEN_TOOL_NAME, VIDEO_GEN_TOOL_NAME, isImageGenName, isVideoGenName } from "./synthetic-tool";
@@ -1,7 +1,8 @@
1
1
  import type { OcxConfig, OcxParsedRequest, OcxProviderConfig } from "../types";
2
2
  import { toolChoiceToolPredicate } from "../types";
3
3
  import type { ImageBridgePlan, VideoBridgePlan } from "./types";
4
- import { resolveEnvValue } from "../config";
4
+ import { resolveProviderApiKey } from "../providers/key-store";
5
+ import { getValidAccessToken } from "../oauth/index";
5
6
  import { getProviderRegistryEntry } from "../providers/registry";
6
7
  import { IMAGE_GEN_TOOL_NAME, VIDEO_GEN_TOOL_NAME, isVideoGenName } from "./synthetic-tool";
7
8
 
@@ -36,10 +37,23 @@ export function findXaiProvider(config: OcxConfig): { name: string; provider: Oc
36
37
  */
37
38
  export function resolveXaiImageApiKey(provider: OcxProviderConfig): string | undefined {
38
39
  if (provider.authMode === "oauth") return undefined;
39
- const apiKey = resolveEnvValue(provider.apiKey)?.trim();
40
+ const apiKey = resolveProviderApiKey(provider.apiKey)?.trim();
40
41
  return apiKey || undefined;
41
42
  }
42
43
 
44
+ /** Token for the /v1/images → Imagine relay. OAuth reuses the Grok CLI grant. */
45
+ export async function resolveXaiImageAuthToken(provider: OcxProviderConfig): Promise<string | undefined> {
46
+ if (provider.authMode === "oauth") {
47
+ try {
48
+ const token = (await getValidAccessToken("xai"))?.trim();
49
+ return token || undefined;
50
+ } catch {
51
+ return undefined;
52
+ }
53
+ }
54
+ return resolveXaiImageApiKey(provider);
55
+ }
56
+
43
57
  export async function planImageBridge(
44
58
  config: OcxConfig,
45
59
  parsed: OcxParsedRequest,
@@ -80,6 +80,11 @@ export function buildImageTool(): OcxTool {
80
80
  properties: {
81
81
  prompt: { type: "string", description: "Detailed image generation prompt. Required." },
82
82
  n: { type: "integer", minimum: 1, maximum: 4 },
83
+ aspect_ratio: {
84
+ type: "string",
85
+ enum: ["1:1", "16:9", "9:16", "4:3", "3:4", "auto"],
86
+ description: "Image aspect ratio. Default auto.",
87
+ },
83
88
  },
84
89
  required: ["prompt"],
85
90
  },
@@ -14,6 +14,8 @@ export interface XaiImageRequest {
14
14
  n?: number; // 1-4
15
15
  size?: string;
16
16
  quality?: string;
17
+ /** Literal xAI aspect_ratio. Wins over `size` when both are present. */
18
+ aspectRatio?: string;
17
19
  imageUrl?: string; // if set → /images/edits
18
20
  }
19
21
 
@@ -35,6 +37,25 @@ const XAI_ASPECT_RATIOS: ReadonlyArray<readonly [string, number]> = [
35
37
  ["9:16", 0.5625],
36
38
  ["16:9", 16 / 9],
37
39
  ];
40
+ const XAI_ASPECT_RATIO_LITERALS = new Set(XAI_ASPECT_RATIOS.map(([label]) => label));
41
+
42
+ /** Accept a hosted/Codex `aspect_ratio` literal; `auto` and unknown values drop. */
43
+ export function resolveXaiAspectRatioLiteral(value: unknown): string | undefined {
44
+ if (typeof value !== "string") return undefined;
45
+ const literal = value.trim();
46
+ if (!literal || literal === "auto") return undefined;
47
+ return XAI_ASPECT_RATIO_LITERALS.has(literal) ? literal : undefined;
48
+ }
49
+
50
+ function resolveAspectRatio(req: XaiImageRequest): string | undefined {
51
+ // An explicit aspect_ratio owns the decision even when it resolves to nothing:
52
+ // "auto" means "let xAI choose", so falling back to a size-derived ratio would
53
+ // silently override the caller. Only an absent field consults `size`.
54
+ if (req.aspectRatio !== undefined && req.aspectRatio.trim()) {
55
+ return resolveXaiAspectRatioLiteral(req.aspectRatio);
56
+ }
57
+ return mapSizeToAspectRatio(req.size);
58
+ }
38
59
 
39
60
  function mapSizeToAspectRatio(size?: string): string | undefined {
40
61
  if (!size) return undefined;
@@ -75,7 +96,7 @@ export async function callXaiImages(
75
96
  prompt: req.prompt,
76
97
  n: req.n ?? 1,
77
98
  };
78
- const aspectRatio = mapSizeToAspectRatio(req.size);
99
+ const aspectRatio = resolveAspectRatio(req);
79
100
  const resolution = mapQualityToResolution(req.quality);
80
101
  if (aspectRatio) body.aspect_ratio = aspectRatio;
81
102
  if (resolution) body.resolution = resolution;
@@ -98,8 +119,20 @@ export async function callXaiImages(
98
119
  },
99
120
  body: JSON.stringify(body),
100
121
  signal: linkedSignal,
122
+ // Do not follow 3xx while carrying the xAI bearer. Bun may strip Authorization
123
+ // cross-origin but still leave the request on the redirect target.
124
+ redirect: "manual",
101
125
  });
102
126
 
127
+ const redirected = resp.type === "opaqueredirect" || (resp.status >= 300 && resp.status < 400);
128
+ if (redirected) {
129
+ try { await resp.body?.cancel(); } catch { /* ignore */ }
130
+ const status = resp.status >= 300 && resp.status < 400 ? resp.status : 302;
131
+ const err = new Error("xAI images API returned " + status) as Error & { status: number };
132
+ err.status = status;
133
+ throw err;
134
+ }
135
+
103
136
  if (!resp.ok) {
104
137
  try { await resp.body?.cancel(); } catch { /* ignore */ }
105
138
  const err = new Error("xAI images API returned " + resp.status) as Error & { status: number };
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Detect Cursor desktop installs and tell the two builds apart.
3
+ *
4
+ * Cursor ships a second desktop distribution, "Cursor Private Inference", whose agent loop
5
+ * runs locally and calls an OpenAI-compatible gateway the user configures. That build can
6
+ * reach opencodex on loopback. Regular Cursor cannot: its backend calls the custom base URL
7
+ * and rejects private addresses. The two share a bundle id, data folder and URL scheme, so
8
+ * the only reliable discriminator is `nameLong` in the app's `product.json`.
9
+ *
10
+ * Detection is read-only and injectable: the proxy never writes anything into a Cursor
11
+ * install, its state database, or its keychain entries (the T20 exclusion in
12
+ * devlog/_plan/260822_senpi_cursor_transfer/090), and the tests run against a temp tree.
13
+ */
14
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
15
+ import { homedir } from "node:os";
16
+ import { posix, win32 } from "node:path";
17
+
18
+ export type CursorBuild = "private-inference" | "regular";
19
+
20
+ export interface CursorInstall {
21
+ build: CursorBuild;
22
+ /** The install root (the .app bundle, install directory, or AppImage extraction root). */
23
+ path: string;
24
+ version: string | null;
25
+ }
26
+
27
+ export interface CursorDetectDeps {
28
+ platform: string;
29
+ homedir: string;
30
+ env: Record<string, string | undefined>;
31
+ readText(path: string): string | null;
32
+ listDir(path: string): string[];
33
+ }
34
+
35
+ export function realCursorDetectDeps(): CursorDetectDeps {
36
+ return {
37
+ platform: process.platform,
38
+ homedir: homedir(),
39
+ env: process.env,
40
+ readText: path => {
41
+ try {
42
+ return existsSync(path) ? readFileSync(path, "utf-8") : null;
43
+ } catch {
44
+ return null;
45
+ }
46
+ },
47
+ listDir: path => {
48
+ try {
49
+ return readdirSync(path);
50
+ } catch {
51
+ return [];
52
+ }
53
+ },
54
+ };
55
+ }
56
+
57
+ const PRIVATE_INFERENCE_NAME = "Cursor Private Inference";
58
+ const REGULAR_NAME = "Cursor";
59
+
60
+ /**
61
+ * Candidate `product.json` paths per platform, each paired with the install root it
62
+ * belongs to. Only well-known locations; a custom install path is the user's to name.
63
+ */
64
+ export function cursorProductJsonCandidates(deps: CursorDetectDeps): Array<{ root: string; productJson: string }> {
65
+ const out: Array<{ root: string; productJson: string }> = [];
66
+ // Join with the target platform's separator so the candidate list is stable in tests
67
+ // that describe another OS from this one.
68
+ const { join } = deps.platform === "win32" ? win32 : posix;
69
+ if (deps.platform === "darwin") {
70
+ for (const dir of ["/Applications", join(deps.homedir, "Applications")]) {
71
+ for (const entry of deps.listDir(dir)) {
72
+ if (!/^Cursor.*\.app$/i.test(entry)) continue;
73
+ const root = join(dir, entry);
74
+ out.push({ root, productJson: join(root, "Contents", "Resources", "app", "product.json") });
75
+ }
76
+ }
77
+ return out;
78
+ }
79
+ if (deps.platform === "win32") {
80
+ const bases = [
81
+ deps.env.LOCALAPPDATA ? join(deps.env.LOCALAPPDATA, "Programs") : null,
82
+ deps.env.ProgramFiles ?? null,
83
+ ].filter((value): value is string => value !== null);
84
+ for (const dir of bases) {
85
+ for (const entry of deps.listDir(dir)) {
86
+ if (!/^cursor/i.test(entry)) continue;
87
+ const root = join(dir, entry);
88
+ out.push({ root, productJson: join(root, "resources", "app", "product.json") });
89
+ }
90
+ }
91
+ return out;
92
+ }
93
+ // Linux: AppImages carry product.json only once extracted, so this covers the tarball /
94
+ // package layouts and stays best-effort.
95
+ for (const dir of ["/opt", join(deps.homedir, ".local", "share")]) {
96
+ for (const entry of deps.listDir(dir)) {
97
+ if (!/^cursor/i.test(entry)) continue;
98
+ const root = join(dir, entry);
99
+ out.push({ root, productJson: join(root, "resources", "app", "product.json") });
100
+ }
101
+ }
102
+ return out;
103
+ }
104
+
105
+ function classify(productJson: string): { build: CursorBuild; version: string | null } | null {
106
+ let parsed: unknown;
107
+ try {
108
+ parsed = JSON.parse(productJson);
109
+ } catch {
110
+ return null;
111
+ }
112
+ if (!parsed || typeof parsed !== "object") return null;
113
+ const record = parsed as { nameLong?: unknown; version?: unknown };
114
+ const version = typeof record.version === "string" ? record.version : null;
115
+ if (record.nameLong === PRIVATE_INFERENCE_NAME) return { build: "private-inference", version };
116
+ if (record.nameLong === REGULAR_NAME) return { build: "regular", version };
117
+ return null;
118
+ }
119
+
120
+ export function detectCursorInstalls(deps: CursorDetectDeps = realCursorDetectDeps()): CursorInstall[] {
121
+ const found: CursorInstall[] = [];
122
+ const seen = new Set<string>();
123
+ for (const candidate of cursorProductJsonCandidates(deps)) {
124
+ if (seen.has(candidate.root)) continue;
125
+ const text = deps.readText(candidate.productJson);
126
+ if (text === null) continue;
127
+ const classified = classify(text);
128
+ if (!classified) continue;
129
+ seen.add(candidate.root);
130
+ found.push({ build: classified.build, path: candidate.root, version: classified.version });
131
+ }
132
+ return found;
133
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Remember the last time a Cursor client asked this proxy for its model list.
3
+ *
4
+ * The Integrations page cannot read Cursor's own settings (and must not write them), so
5
+ * "is Cursor pointed at me?" is answered from our side: Cursor's local-agent runtime sends
6
+ * `User-Agent: Cursor/<version>` on `GET /v1/models`. Only that header value and a
7
+ * timestamp are kept, in memory, so a proxy restart forgets it and the card says so.
8
+ */
9
+ // Attacker-controlled header: accept only the shape Cursor sends and keep it short.
10
+ const CURSOR_USER_AGENT = /^Cursor\/[\w.+-]{1,40}$/;
11
+
12
+ export interface CursorSeen {
13
+ at: number;
14
+ userAgent: string;
15
+ }
16
+
17
+ let last: CursorSeen | null = null;
18
+
19
+ export function recordCursorSeen(headers: Headers, now = Date.now()): void {
20
+ const userAgent = headers.get("user-agent")?.trim() ?? "";
21
+ if (!CURSOR_USER_AGENT.test(userAgent)) return;
22
+ last = { at: now, userAgent };
23
+ }
24
+
25
+ export function cursorLastSeen(): CursorSeen | null {
26
+ return last ? { ...last } : null;
27
+ }
28
+
29
+ export function resetCursorSeenForTests(): void {
30
+ last = null;
31
+ }
package/src/lib/errors.ts CHANGED
@@ -4,6 +4,24 @@ export interface OcxErrorPayload {
4
4
  code: string | null;
5
5
  }
6
6
 
7
+ /** Canonical human-readable message paths used by Responses upstream failures. */
8
+ export function upstreamErrorMessageFromPayload(payload: unknown): string | undefined {
9
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) return undefined;
10
+ const json = payload as {
11
+ error?: { message?: unknown };
12
+ last_error?: { message?: unknown };
13
+ response?: {
14
+ error?: { message?: unknown };
15
+ incomplete_details?: { message?: unknown };
16
+ };
17
+ };
18
+ const message = json.error?.message
19
+ ?? json.last_error?.message
20
+ ?? json.response?.error?.message
21
+ ?? json.response?.incomplete_details?.message;
22
+ return typeof message === "string" ? message : undefined;
23
+ }
24
+
7
25
  /** OpenAI / Codex hard block for high-risk cybersecurity activity (HTTP 400 or mid-stream). */
8
26
  export const CYBER_POLICY_ERROR_CODE = "cyber_policy";
9
27
  export const CYBER_POLICY_FALLBACK_MESSAGE = "Request blocked by the upstream cybersecurity policy.";
@@ -0,0 +1,104 @@
1
+ import { createHmac, timingSafeEqual } from "node:crypto";
2
+ import { isLocalAttestationSecret } from "./local-management-attestation";
3
+
4
+ export const GUI_PAIR_METHOD = "POST";
5
+ export const GUI_PAIR_PATH = "/api/gui/pairing-grants";
6
+ export const GUI_PAIR_CAPABILITY_VERSION = "v1";
7
+ export const GUI_PAIR_EXPECTED_PID_HEADER = "x-opencodex-gui-pair-expected-pid";
8
+ export const GUI_PAIR_NONCE_HEADER = "x-opencodex-gui-pair-nonce";
9
+ export const GUI_PAIR_EXPIRES_AT_HEADER = "x-opencodex-gui-pair-expires-at";
10
+ export const GUI_PAIR_BROWSER_ORIGIN_HEADER = "x-opencodex-gui-pair-origin";
11
+ export const GUI_PAIR_CAPABILITY_HEADER = "x-opencodex-gui-pair-capability";
12
+ export const GUI_PAIR_CAPABILITY_TTL_MS = 10_000;
13
+
14
+ const BASE64URL_256 = /^[A-Za-z0-9_-]{43}$/;
15
+
16
+ export type ExpectedGuiPairPid =
17
+ | { kind: "absent" }
18
+ | { kind: "invalid" }
19
+ | { kind: "present"; pid: number };
20
+
21
+ export function parseExpectedGuiPairPid(value: string | null): ExpectedGuiPairPid {
22
+ if (value === null) return { kind: "absent" };
23
+ if (!/^[1-9]\d*$/.test(value)) return { kind: "invalid" };
24
+ const pid = Number(value);
25
+ return Number.isSafeInteger(pid) ? { kind: "present", pid } : { kind: "invalid" };
26
+ }
27
+
28
+ export function canonicalGuiBrowserOrigin(value: unknown): string | null {
29
+ if (typeof value !== "string" || value !== value.trim()) return null;
30
+ try {
31
+ const parsed = new URL(value);
32
+ if (!parsed.host || parsed.username || parsed.password || parsed.search || parsed.hash) return null;
33
+ if (parsed.pathname !== "" && parsed.pathname !== "/") return null;
34
+ if (parsed.protocol === "http:" || parsed.protocol === "https:") return parsed.origin;
35
+ return `${parsed.protocol}//${parsed.host}`;
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ function capabilityPayload(
42
+ nonce: string,
43
+ method: string,
44
+ path: string,
45
+ browserOrigin: string,
46
+ pid: number,
47
+ port: number,
48
+ expiresAt: number,
49
+ ): string | null {
50
+ if (!BASE64URL_256.test(nonce)) return null;
51
+ if (method !== GUI_PAIR_METHOD || path !== GUI_PAIR_PATH) return null;
52
+ const canonicalOrigin = canonicalGuiBrowserOrigin(browserOrigin);
53
+ if (!canonicalOrigin || canonicalOrigin !== browserOrigin) return null;
54
+ if (!Number.isSafeInteger(pid) || pid <= 0) return null;
55
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) return null;
56
+ if (!Number.isSafeInteger(expiresAt) || expiresAt <= 0) return null;
57
+ return `opencodex-gui-pair-v1\n${nonce}\n${method}\n${path}\n${browserOrigin}\n${pid}\n${port}\n${expiresAt}`;
58
+ }
59
+
60
+ export function createGuiPairCapability(
61
+ secret: string,
62
+ nonce: string,
63
+ method: string,
64
+ path: string,
65
+ browserOrigin: string,
66
+ pid: number,
67
+ port: number,
68
+ expiresAt: number,
69
+ ): string | null {
70
+ if (!isLocalAttestationSecret(secret)) return null;
71
+ const payload = capabilityPayload(nonce, method, path, browserOrigin, pid, port, expiresAt);
72
+ if (!payload) return null;
73
+ return createHmac("sha256", secret).update(payload).digest("base64url");
74
+ }
75
+
76
+ export function verifyGuiPairCapability(
77
+ secret: string,
78
+ nonce: string | null,
79
+ method: string,
80
+ path: string,
81
+ browserOrigin: string | null,
82
+ pid: number,
83
+ port: number,
84
+ expiresAt: number,
85
+ capability: string | null,
86
+ now = Date.now(),
87
+ ): boolean {
88
+ if (!nonce || !browserOrigin || !capability || !BASE64URL_256.test(capability)) return false;
89
+ if (!Number.isSafeInteger(now) || expiresAt <= now || expiresAt > now + GUI_PAIR_CAPABILITY_TTL_MS) return false;
90
+ const expected = createGuiPairCapability(
91
+ secret,
92
+ nonce,
93
+ method,
94
+ path,
95
+ browserOrigin,
96
+ pid,
97
+ port,
98
+ expiresAt,
99
+ );
100
+ if (!expected) return false;
101
+ const expectedBytes = Buffer.from(expected);
102
+ const actualBytes = Buffer.from(capability);
103
+ return expectedBytes.length === actualBytes.length && timingSafeEqual(expectedBytes, actualBytes);
104
+ }
@@ -8,6 +8,7 @@
8
8
  * @internal host integration only
9
9
  */
10
10
  import { resolveEnvValue } from "../config";
11
+ import { resolveProviderApiKey } from "../providers/key-store";
11
12
  import {
12
13
  getValidAccessTokenSnapshot,
13
14
  OAuthLoginRequiredError,
@@ -75,7 +76,7 @@ async function buildLabProviderAuthHeaders(
75
76
  throw new TransportError("harness_failure", "oauth refresh unavailable");
76
77
  }
77
78
  } else {
78
- const apiKey = resolveEnvValue(provider.apiKey)?.trim();
79
+ const apiKey = resolveProviderApiKey(provider.apiKey)?.trim();
79
80
  if (!apiKey) throw new TransportError("auth_blocked", "missing api key");
80
81
  if (provider.adapter === "anthropic" && provider.apiKeyTransport === "x-api-key") {
81
82
  headers["x-api-key"] = apiKey;
@@ -132,8 +132,8 @@ function drainDeadlineMs(): number {
132
132
  }
133
133
 
134
134
  /** Graceful-first stop: management-API drain, then the platform kill ladder. */
135
- export async function stopProxy(pid: number, io: GracefulStopIo = {}): Promise<void> {
136
- if (!isProcessAlive(pid)) return;
135
+ export async function stopProxy(pid: number, io: GracefulStopIo = {}): Promise<boolean> {
136
+ if (!isProcessAlive(pid)) return false;
137
137
  const runtime = io.runtimeEndpoint ?? readRuntimePort(pid);
138
138
  const graceful = await stopProxyGracefully(pid, io);
139
139
  if (graceful === "refused") {
@@ -146,10 +146,11 @@ export async function stopProxy(pid: number, io: GracefulStopIo = {}): Promise<v
146
146
  }
147
147
  if (graceful) {
148
148
  await waitForStoppedPort(runtime, pid);
149
- return;
149
+ return true;
150
150
  }
151
151
  killProxy(pid);
152
152
  await waitForStoppedPort(runtime, pid);
153
+ return false;
153
154
  }
154
155
 
155
156
  /** After stop/kill, wait for the former listen port to become bindable (Windows drain). */