@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.
- package/README.md +49 -21
- package/gui/dist/assets/index-BHe2rl_C.js +112 -0
- package/gui/dist/assets/index-CJSb3HPe.css +1 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/cursor/catalog.ts +140 -7
- package/src/adapters/cursor/discovery.ts +79 -87
- package/src/adapters/cursor/effort-map.ts +11 -0
- package/src/adapters/cursor/request-builder.ts +31 -4
- package/src/adapters/cursor.ts +17 -0
- package/src/adapters/openai-chat.ts +95 -9
- package/src/adapters/openai-responses.ts +73 -9
- package/src/bridge.ts +58 -13
- package/src/claude/auth-mode.ts +6 -9
- package/src/claude/gateway-cache.ts +23 -3
- package/src/claude/model-info.ts +13 -2
- package/src/cli/access.ts +30 -0
- package/src/cli/account-extended.ts +19 -10
- package/src/cli/capabilities.ts +40 -1
- package/src/cli/claude-agent-startup-sync.ts +3 -0
- package/src/cli/claude.ts +134 -36
- package/src/cli/connect.ts +226 -0
- package/src/cli/dispatch.ts +126 -21
- package/src/cli/gui-pair-client.ts +170 -0
- package/src/cli/gui.ts +87 -0
- package/src/cli/help.ts +4 -1
- package/src/cli/index.ts +76 -22
- package/src/cli/models-runtime-subcommands.ts +34 -0
- package/src/cli/models-runtime.ts +4 -0
- package/src/cli/models.ts +2 -1
- package/src/cli/opencode.ts +2 -1
- package/src/cli/provider-runtime.ts +31 -1
- package/src/cli/registry.ts +32 -2
- package/src/cli/runtime-api.ts +10 -1
- package/src/cli/status.ts +26 -0
- package/src/cli/system-command.ts +9 -3
- package/src/client/connect.ts +650 -0
- package/src/client/hub-client.ts +457 -0
- package/src/client/hub-relay.ts +288 -0
- package/src/client/machine-api.ts +139 -0
- package/src/client/machine-auth.ts +54 -0
- package/src/client/machine-listener.ts +143 -0
- package/src/client/runtime.ts +93 -0
- package/src/client/state.ts +175 -0
- package/src/clients/config-export.ts +16 -5
- package/src/codex/auth-api.ts +169 -15
- package/src/codex/auth-context.ts +61 -6
- package/src/codex/catalog/aggregation.ts +17 -13
- package/src/codex/catalog/metadata.ts +28 -0
- package/src/codex/catalog/provider-fetch.ts +38 -5
- package/src/codex/catalog.ts +1 -1
- package/src/codex/desired-state.ts +13 -3
- package/src/codex/inject.ts +212 -31
- package/src/codex/journal.ts +54 -6
- package/src/codex/main-account.ts +105 -33
- package/src/codex/native-main-claim.ts +23 -1
- package/src/codex/native-residue.ts +7 -0
- package/src/codex/reset-credit-auto-redeem.ts +237 -0
- package/src/combos/request.ts +15 -3
- package/src/combos/types.ts +12 -0
- package/src/config/paths.ts +15 -1
- package/src/config/provider-validation.ts +40 -0
- package/src/config.ts +447 -3
- package/src/generated/compatibility-version.json +224 -108
- package/src/images/artifacts.ts +73 -37
- package/src/images/fulfill.ts +6 -1
- package/src/images/index.ts +1 -1
- package/src/images/plan.ts +16 -2
- package/src/images/synthetic-tool.ts +5 -0
- package/src/images/xai-client.ts +34 -1
- package/src/integrations/cursor-detect.ts +133 -0
- package/src/integrations/cursor-seen.ts +31 -0
- package/src/lib/errors.ts +18 -0
- package/src/lib/gui-pair-capability.ts +104 -0
- package/src/lib/lab-live-route-production.ts +2 -1
- package/src/lib/process-control.ts +4 -3
- package/src/lib/service-secrets.ts +161 -1
- package/src/lib/windows-system-proxy.ts +115 -0
- package/src/oauth/index.ts +3 -2
- package/src/oauth/pool-settings-capability.ts +55 -0
- package/src/providers/api-keys.ts +3 -1
- package/src/providers/codex-capacity.ts +34 -4
- package/src/providers/derive.ts +10 -0
- package/src/providers/fastwire.ts +10 -2
- package/src/providers/key-store.ts +197 -0
- package/src/providers/model-rename-migration.ts +3 -0
- package/src/providers/openai-sidecar.ts +3 -3
- package/src/providers/quota.ts +91 -42
- package/src/providers/registry.ts +33 -3
- package/src/reasoning-effort.ts +32 -0
- package/src/remote/protocol.ts +109 -0
- package/src/responses/citation-markers.ts +101 -0
- package/src/responses/parser.ts +43 -3
- package/src/responses/spill-store.ts +9 -4
- package/src/router.ts +72 -8
- package/src/server/auth-cors.ts +37 -1
- package/src/server/gui-session.ts +449 -0
- package/src/server/gui-static.ts +24 -7
- package/src/server/images.ts +237 -10
- package/src/server/index.ts +356 -30
- package/src/server/management/agent-settings-routes.ts +7 -1
- package/src/server/management/api-key-rotation.ts +74 -0
- package/src/server/management/combo-routes.ts +18 -6
- package/src/server/management/config-routes.ts +22 -3
- package/src/server/management/context.ts +5 -1
- package/src/server/management/cursor-integration-routes.ts +98 -0
- package/src/server/management/logs-usage-routes.ts +2 -1
- package/src/server/management/model-routes.ts +79 -1
- package/src/server/management/model-rows.ts +23 -1
- package/src/server/management/oauth-account-routes.ts +135 -2
- package/src/server/management/provider-routes.ts +143 -9
- package/src/server/management/route-registry.ts +10 -0
- package/src/server/management/session-routes.ts +13 -0
- package/src/server/management/system-routes.ts +13 -1
- package/src/server/management-api.ts +8 -3
- package/src/server/management-auth.ts +124 -82
- package/src/server/models-capabilities.ts +124 -0
- package/src/server/ports.ts +7 -0
- package/src/server/proxy-liveness.ts +7 -0
- package/src/server/request-log.ts +11 -5
- package/src/server/responses/agent-task-recovery.ts +1 -0
- package/src/server/responses/codex-auth-error.ts +3 -1
- package/src/server/responses/collaboration.ts +26 -1
- package/src/server/responses/combo-stream-preflight.ts +25 -6
- package/src/server/responses/compact.ts +32 -12
- package/src/server/responses/context-overflow.ts +49 -0
- package/src/server/responses/core.ts +196 -12
- package/src/server/responses/fetch-helpers.ts +2 -1
- package/src/server/responses/outbound-body-guard.ts +110 -0
- package/src/server/responses/ws-upstream.ts +87 -6
- package/src/server/responses-self-named-namespace-scrub.ts +181 -0
- package/src/server/system-env.ts +78 -25
- package/src/service.ts +218 -42
- package/src/types/config.ts +134 -0
- package/src/types/provider.ts +54 -1
- package/src/types/request.ts +2 -0
- package/src/types.ts +5 -0
- package/src/update/job.ts +33 -5
- package/src/usage/cost.ts +32 -1
- package/src/usage/expected-prices.ts +16 -0
- package/src/usage/log.ts +6 -2
- package/src/usage/summary.ts +48 -6
- package/gui/dist/assets/index-D-lchsPw.js +0 -112
- package/gui/dist/assets/index-uvENYLin.css +0 -1
package/src/images/artifacts.ts
CHANGED
|
@@ -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
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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);
|
package/src/images/fulfill.ts
CHANGED
|
@@ -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,
|
package/src/images/index.ts
CHANGED
|
@@ -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";
|
package/src/images/plan.ts
CHANGED
|
@@ -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 {
|
|
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 =
|
|
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
|
},
|
package/src/images/xai-client.ts
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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<
|
|
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). */
|