@bitkyc08/opencodex 2.64.0-preview.20260923 → 2.65.0-preview.20260925
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 +28 -25
- package/bin/ocx.mjs +16 -2
- package/gui/dist/assets/App-8NMiZxT0.css +1 -0
- package/gui/dist/assets/App-CsYvvpr3.js +50 -0
- package/gui/dist/assets/Tray-DUvc_Wul.js +1 -0
- package/gui/dist/assets/{index-DiBRuK-d.css → index--EWgGQvZ.css} +1 -1
- package/gui/dist/assets/{index-BEq4OCOz.js → index-BB0iHG8-.js} +12 -12
- package/gui/dist/assets/tray-data-f0lTZ4sF.js +1 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -4
- package/src/adapters/anthropic.ts +30 -1
- package/src/adapters/claude-cli/adapter.ts +218 -0
- package/src/adapters/claude-cli/profiles.ts +38 -0
- package/src/adapters/codebuddy/profiles.ts +2 -0
- package/src/adapters/coding-agent/profile.ts +12 -4
- package/src/adapters/coding-agent/turn.ts +16 -6
- package/src/adapters/command-code-tool-text.ts +133 -23
- package/src/adapters/cursor/catalog.ts +4 -8
- package/src/adapters/cursor/discovery.ts +10 -5
- package/src/adapters/cursor/effort-map.ts +2 -6
- package/src/adapters/cursor/envelope-echo.ts +10 -5
- package/src/adapters/cursor/request-builder.ts +9 -4
- package/src/adapters/cursor/thread-continuity.ts +53 -0
- package/src/adapters/cursor.ts +29 -13
- package/src/adapters/devin/cloud-direct/chat.ts +3 -1
- package/src/adapters/google-tool-schema.ts +26 -3
- package/src/adapters/identity.ts +214 -2
- package/src/adapters/openai-chat/passthrough.ts +1 -0
- package/src/adapters/openai-chat/serialized-tool-call-content.ts +569 -0
- package/src/adapters/openai-chat.ts +43 -15
- package/src/adapters/openai-responses/canonical-forward.ts +17 -10
- package/src/adapters/openai-responses/passthrough.ts +19 -4
- package/src/adapters/openai-responses/request-strips.ts +25 -0
- package/src/adapters/openai-responses/web-search.ts +22 -4
- package/src/adapters/qoder/profiles.ts +2 -0
- package/src/adapters/registry.ts +8 -0
- package/src/adapters/run-turn-queue.ts +7 -0
- package/src/bridge/response-json.ts +6 -4
- package/src/bridge/sse.ts +7 -5
- package/src/claude/alias.ts +73 -26
- package/src/claude/context-windows.ts +15 -6
- package/src/claude/desktop-3p-library.ts +3 -2
- package/src/claude/desktop-3p.ts +1 -1
- package/src/claude/desktop-first-party.ts +63 -22
- package/src/claude/desktop-picker-profile.ts +302 -0
- package/src/claude/desktop-picker.ts +365 -0
- package/src/claude/desktop-risk.ts +20 -0
- package/src/claude/gateway-cache.ts +13 -4
- package/src/claude/inbound.ts +5 -2
- package/src/claude/intercept/connect-proxy.ts +106 -29
- package/src/claude/intercept/local-ca.ts +76 -18
- package/src/claude/intercept/model-bindings.ts +145 -0
- package/src/claude/intercept/picker-bootstrap.ts +141 -0
- package/src/claude/intercept/picker-ca.ts +126 -0
- package/src/claude/intercept/picker-listener.ts +213 -0
- package/src/claude/intercept/picker-models.ts +101 -0
- package/src/claude/intercept/picker-runtime.ts +330 -0
- package/src/claude/intercept/picker-trust.ts +125 -0
- package/src/claude/intercept/runtime.ts +149 -2
- package/src/claude/model-info.ts +18 -4
- package/src/cli/agent.ts +16 -2
- package/src/cli/capabilities.ts +71 -2
- package/src/cli/claude-desktop.ts +319 -33
- package/src/cli/claude.ts +15 -7
- package/src/cli/codex-shim-autorestore.ts +1 -0
- package/src/cli/dispatch.ts +29 -23
- package/src/cli/effort.ts +16 -6
- package/src/cli/ensure-desired-integrations.ts +29 -4
- package/src/cli/ready.ts +1 -1
- package/src/cli/registry.ts +8 -0
- package/src/cli/restart-scope.ts +9 -1
- package/src/cli/runtime-api.ts +17 -0
- package/src/cli/system-command.ts +11 -12
- package/src/client/connect.ts +12 -2
- package/src/client/state.ts +39 -1
- package/src/clients/config-export.ts +57 -9
- package/src/codex/auth-context.ts +51 -11
- package/src/codex/catalog/derive-entry.ts +5 -5
- package/src/codex/catalog/gather-capture.ts +25 -6
- package/src/codex/catalog/metadata.ts +3 -3
- package/src/codex/catalog/parsing.ts +39 -1
- package/src/codex/catalog/provider-models.ts +8 -6
- package/src/codex/catalog/retained-sync.ts +17 -7
- package/src/codex/catalog/sync.ts +2 -2
- package/src/codex/desktop-app-restart.ts +8 -0
- package/src/codex/desktop-switches.ts +9 -1
- package/src/codex/home.ts +43 -4
- package/src/codex/inject/config-toml.ts +137 -0
- package/src/codex/inject/plan.ts +23 -0
- package/src/codex/inject/remove.ts +21 -1
- package/src/codex/inject.ts +63 -8
- package/src/codex/journal.ts +36 -0
- package/src/codex/main-account-hard-lock.ts +14 -1
- package/src/codex/main-account-policy-wait.ts +109 -0
- package/src/codex/native-profile-startup.ts +24 -6
- package/src/codex/native-residue.ts +28 -10
- package/src/codex/quota-types.ts +8 -1
- package/src/codex/quota.ts +49 -7
- package/src/codex/runtime.ts +31 -10
- package/src/combos/failover.ts +7 -6
- package/src/combos/resolve.ts +91 -14
- package/src/combos/types.ts +18 -1
- package/src/config/load-degrade.ts +2 -0
- package/src/config/schema/config-schema.ts +21 -2
- package/src/config/schema/leaf-validators.ts +6 -3
- package/src/config/subagent-models.ts +3 -1
- package/src/generated/compatibility-version.json +275 -163
- package/src/images/artifacts.ts +14 -0
- package/src/integrations/owned-refresh.ts +1 -1
- package/src/integrations/ownership-policy.ts +32 -1
- package/src/integrations/state.ts +3 -1
- package/src/integrations/writer.ts +9 -0
- package/src/lib/config-ownership.ts +3 -0
- package/src/lib/errors.ts +87 -0
- package/src/lib/package-tree-integrity.ts +1 -1
- package/src/lib/request-execution-budget.ts +42 -14
- package/src/lib/request-resend-gate.ts +4 -3
- package/src/lib/retry-delay.ts +7 -1
- package/src/lib/tool-envelope-echo-filter.ts +236 -0
- package/src/lib/upstream-retry.ts +20 -12
- package/src/oauth/index.ts +1 -0
- package/src/oauth/kiro-credentials.ts +14 -1
- package/src/oauth/login-cli.ts +1 -0
- package/src/providers/derive.ts +4 -0
- package/src/providers/fast-opt-in.ts +31 -0
- package/src/providers/model-rename-fields.ts +2 -0
- package/src/providers/provider-id-rewrite.ts +2 -0
- package/src/providers/quota/vendor-probes-key.ts +8 -2
- package/src/providers/registry/entries-core.ts +45 -2
- package/src/providers/registry/entries-extended.ts +99 -12
- package/src/providers/registry/model-ids.ts +2 -0
- package/src/providers/registry/types.ts +7 -1
- package/src/providers/resolved-model-policy.ts +11 -4
- package/src/providers/service-tier.ts +10 -3
- package/src/responses/code-mode-helper-compat.ts +3 -0
- package/src/responses/parser.ts +45 -3
- package/src/router.ts +6 -0
- package/src/server/auth-cors.ts +8 -0
- package/src/server/chat-completions.ts +3 -1
- package/src/server/claude-messages.ts +69 -14
- package/src/server/grok-upstream-envelope-echo.ts +120 -0
- package/src/server/index/claude-intercept-lifecycle.ts +24 -0
- package/src/server/index/serve-options.ts +2 -2
- package/src/server/index.ts +7 -0
- package/src/server/management/agent-settings-routes.ts +204 -112
- package/src/server/management/claude-desktop-picker-routes.ts +128 -0
- package/src/server/management/combo-routes.ts +59 -2
- package/src/server/management/config-routes.ts +81 -39
- package/src/server/management/context.ts +3 -0
- package/src/server/management/native-integration-routes.ts +169 -93
- package/src/server/management/provider-routes.ts +46 -4
- package/src/server/management/route-registry.ts +10 -0
- package/src/server/management/routing-profile-routes.ts +9 -0
- package/src/server/management/sidebar-routes.ts +52 -0
- package/src/server/management-api.ts +2 -0
- package/src/server/proxy-liveness.ts +38 -3
- package/src/server/request-log-account-rotation.ts +53 -0
- package/src/server/request-log.ts +3 -16
- package/src/server/responses/adapter-dispatch.ts +17 -1
- package/src/server/responses/agent-task-recovery.ts +49 -26
- package/src/server/responses/codex-ws-exchange.ts +18 -7
- package/src/server/responses/codex-ws-wire.ts +27 -4
- package/src/server/responses/core-combo.ts +21 -3
- package/src/server/responses/core-normalize.ts +7 -0
- package/src/server/responses/core-opaque-recovery.ts +3 -2
- package/src/server/responses/encrypted-payload.ts +15 -14
- package/src/server/responses/fetch-helpers.ts +1 -1
- package/src/server/responses/input-admission.ts +10 -6
- package/src/server/responses/native-response-control.ts +2 -2
- package/src/server/responses/passthrough-delivery.ts +182 -12
- package/src/server/responses/passthrough-dispatch.ts +102 -44
- package/src/server/responses/policy-fallback.ts +3 -0
- package/src/server/responses/policy-refusal.ts +67 -0
- package/src/server/responses/request-send-budget.ts +4 -0
- package/src/server/responses/run-turn-execution.ts +42 -11
- package/src/server/responses/ws-upstream.ts +2 -1
- package/src/server/system-env-shell.ts +0 -1
- package/src/server/system-env.ts +25 -4
- package/src/service/cli.ts +34 -3
- package/src/tray/assets/opencodex-tray-offline-update.ico +0 -0
- package/src/tray/assets/opencodex-tray-online-update.ico +0 -0
- package/src/tray/assets/opencodex-tray-warning-update.ico +0 -0
- package/src/tray/windows-tray.ps1 +188 -7
- package/src/tray/windows.ts +23 -2
- package/src/types/config.ts +52 -12
- package/src/types/provider.ts +22 -5
- package/src/types/tools.ts +11 -5
- package/src/types.ts +1 -0
- package/src/update/async-check.ts +109 -0
- package/src/update/badge.ts +11 -17
- package/src/update/check-types.ts +9 -0
- package/src/update/desktop-badge.ts +102 -0
- package/src/update/index.ts +48 -8
- package/src/update/install-detection.d.mts +29 -2
- package/src/update/install-detection.mjs +213 -7
- package/src/update/job.ts +17 -12
- package/src/update/notify.ts +16 -11
- package/src/update/pnpm-owner-worker.ts +12 -0
- package/src/update/refresh-scheduler.ts +141 -0
- package/src/usage/cost.ts +31 -4
- package/src/usage/expected-prices.ts +57 -0
- package/assets/download-linux.svg +0 -10
- package/assets/download-macos.svg +0 -10
- package/assets/download-windows.svg +0 -10
- package/gui/dist/assets/App-D5eN1TID.js +0 -50
- package/gui/dist/assets/App-I5AnaSLh.css +0 -1
- package/gui/dist/assets/Tray-Bh_ErQDh.js +0 -1
- package/gui/dist/assets/usage-companion-chart-DBG_37kQ.js +0 -1
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Account-risk notice for Claude Desktop first-party mode.
|
|
3
|
+
*
|
|
4
|
+
* First-party mode sends the Claude subscription's Claude Code traffic through the local
|
|
5
|
+
* interception proxy. Every surface that offers, applies or reports first-party shows this text
|
|
6
|
+
* (CLI, management status, native toggle, dashboard, docs), so it has one owner and cannot drift.
|
|
7
|
+
*/
|
|
8
|
+
export const FIRST_PARTY_ACCOUNT_RISK = {
|
|
9
|
+
code: "first_party_account_suspension_risk",
|
|
10
|
+
message: "First-party mode sends Claude subscription traffic through a local interception proxy. "
|
|
11
|
+
+ "Anthropic may treat this as a violation of its terms and suspend the account. "
|
|
12
|
+
+ "Use it at your own risk; gateway mode is the default.",
|
|
13
|
+
} as const;
|
|
14
|
+
|
|
15
|
+
export type FirstPartyAccountRisk = { code: typeof FIRST_PARTY_ACCOUNT_RISK.code; message: string };
|
|
16
|
+
|
|
17
|
+
/** A fresh copy for JSON payloads, so no caller can mutate the shared constant. */
|
|
18
|
+
export function firstPartyAccountRisk(): FirstPartyAccountRisk {
|
|
19
|
+
return { code: FIRST_PARTY_ACCOUNT_RISK.code, message: FIRST_PARTY_ACCOUNT_RISK.message };
|
|
20
|
+
}
|
|
@@ -6,9 +6,12 @@
|
|
|
6
6
|
* subscription-preserving launch deliberately sets no token, so the CLI can never
|
|
7
7
|
* refresh its picker list itself — it reads whatever cache exists. We therefore
|
|
8
8
|
* pre-write the cache in the exact on-disk schema the CLI uses:
|
|
9
|
-
* { baseUrl, fetchedAt, models: [{ id, display_name? }] } (mode 0600)
|
|
10
|
-
* mirroring
|
|
9
|
+
* { baseUrl, fetchedAt, models: [{ id, display_name?, description? }] } (mode 0600)
|
|
10
|
+
* mirroring the picker rule that the id must contain `claude` or `anthropic`.
|
|
11
|
+
* Current aliases are `ocx-claude-*`, so an anchored `^(claude|anthropic)` filter
|
|
12
|
+
* would drop every newly minted routed model. The picker validates
|
|
11
13
|
* only `baseUrl === ANTHROPIC_BASE_URL`, so a foreign base URL is simply ignored.
|
|
14
|
+
* `description` replaces the picker's generic "From gateway" line (Claude Code >= 2.1.257).
|
|
12
15
|
*/
|
|
13
16
|
import { mkdirSync, writeFileSync } from "node:fs";
|
|
14
17
|
import { homedir } from "node:os";
|
|
@@ -19,6 +22,7 @@ import type { OcxConfig } from "../types";
|
|
|
19
22
|
export interface GatewayModelRow {
|
|
20
23
|
id: string;
|
|
21
24
|
display_name?: string;
|
|
25
|
+
description?: string;
|
|
22
26
|
}
|
|
23
27
|
|
|
24
28
|
export interface GatewayModelCacheRefreshOptions {
|
|
@@ -49,14 +53,18 @@ export function claudeConfigDir(): string {
|
|
|
49
53
|
export function writeGatewayModelCache(baseUrl: string, models: readonly GatewayModelRow[], configDir = claudeConfigDir()): string | null {
|
|
50
54
|
try {
|
|
51
55
|
// Mirror the CLI's usable-id filter so our file matches what it would cache.
|
|
52
|
-
const usable = models.filter(m =>
|
|
56
|
+
const usable = models.filter(m => /(claude|anthropic)/i.test(m.id));
|
|
53
57
|
const cacheDir = join(configDir, "cache");
|
|
54
58
|
mkdirSync(cacheDir, { recursive: true });
|
|
55
59
|
const path = join(cacheDir, "gateway-models.json");
|
|
56
60
|
const payload = {
|
|
57
61
|
baseUrl,
|
|
58
62
|
fetchedAt: Date.now(),
|
|
59
|
-
models: usable.map(m => (
|
|
63
|
+
models: usable.map(m => ({
|
|
64
|
+
id: m.id,
|
|
65
|
+
...(m.display_name === undefined ? {} : { display_name: m.display_name }),
|
|
66
|
+
...(m.description === undefined ? {} : { description: m.description }),
|
|
67
|
+
})),
|
|
60
68
|
};
|
|
61
69
|
writeFileSync(path, JSON.stringify(payload), { encoding: "utf8", mode: 0o600 });
|
|
62
70
|
return path;
|
|
@@ -110,6 +118,7 @@ export async function refreshGatewayModelCacheFromProxy(
|
|
|
110
118
|
.map(m => ({
|
|
111
119
|
id: m.id as string,
|
|
112
120
|
display_name: typeof m.display_name === "string" ? m.display_name : undefined,
|
|
121
|
+
description: typeof m.description === "string" ? m.description : undefined,
|
|
113
122
|
}));
|
|
114
123
|
return writeGatewayModelCache(baseUrl, models, options.configDir);
|
|
115
124
|
} catch {
|
package/src/claude/inbound.ts
CHANGED
|
@@ -442,13 +442,16 @@ function translateAnthropicRequest(
|
|
|
442
442
|
if (outputConfigFormat) body.text = { format: outputConfigFormat };
|
|
443
443
|
let cacheKeySource: ClaudeCacheKeySource = null;
|
|
444
444
|
if (isRec(raw.metadata) && typeof raw.metadata.user_id === "string") {
|
|
445
|
-
|
|
445
|
+
const userIdHash = createHash("sha256").update(raw.metadata.user_id).digest("hex");
|
|
446
|
+
// OpenAI and Azure reject `user` longer than 64 chars, and Claude Code's metadata.user_id
|
|
447
|
+
// is a JSON blob well past that; send its 64-char hash instead of the raw value.
|
|
448
|
+
body.user = raw.metadata.user_id.length <= 64 ? raw.metadata.user_id : userIdHash;
|
|
446
449
|
// OpenAI-side prompt caching is routed by prompt_cache_key (Codex clients send
|
|
447
450
|
// their session id; without it consecutive /v1/messages turns reported
|
|
448
451
|
// cached_tokens: 0 on the ChatGPT backend — devlog 090). Claude Code's
|
|
449
452
|
// metadata.user_id embeds the session uuid, so hashing it yields a stable
|
|
450
453
|
// per-session key with a bounded length/charset.
|
|
451
|
-
body.prompt_cache_key =
|
|
454
|
+
body.prompt_cache_key = userIdHash.slice(0, 32);
|
|
452
455
|
cacheKeySource = "metadata";
|
|
453
456
|
} else if (systemParts.length > 0) {
|
|
454
457
|
// Claude Desktop sends no metadata.user_id (H1, devlog 130): without any key the
|
|
@@ -6,7 +6,8 @@ import { BlockList, createServer, connect, isIP, type Server, type Socket } from
|
|
|
6
6
|
* Claude Code honours `HTTPS_PROXY` and opens `CONNECT <host>:443` for every upstream. This
|
|
7
7
|
* proxy splices tunnels for the intercepted hosts onto the local TLS listener (which holds a
|
|
8
8
|
* leaf certificate for them) and blindly relays every other tunnel to its real destination,
|
|
9
|
-
* so telemetry
|
|
9
|
+
* so telemetry and OAuth refresh stay native and opaque to opencodex. Picker mode may
|
|
10
|
+
* terminate only claude.ai tunnels, selected independently for each connection.
|
|
10
11
|
*
|
|
11
12
|
* Only CONNECT is served. Plain proxied HTTP requests are refused: Claude Code never sends
|
|
12
13
|
* them, and answering them would turn this socket into a generic forward proxy.
|
|
@@ -15,6 +16,7 @@ import { BlockList, createServer, connect, isIP, type Server, type Socket } from
|
|
|
15
16
|
export const CLAUDE_INTERCEPT_HOSTS = ["api.anthropic.com"] as const;
|
|
16
17
|
|
|
17
18
|
const MAX_HEAD_BYTES = 8 * 1024;
|
|
19
|
+
const MAX_PENDING_BYTES = 16 * 1024 * 1024;
|
|
18
20
|
const HEAD_TIMEOUT_MS = 10_000;
|
|
19
21
|
const UPSTREAM_CONNECT_TIMEOUT_MS = 15_000;
|
|
20
22
|
|
|
@@ -23,10 +25,43 @@ export interface ConnectProxyOptions {
|
|
|
23
25
|
interceptPort: number;
|
|
24
26
|
/** Hostnames (lowercase) whose 443 tunnels are spliced onto `interceptPort`. */
|
|
25
27
|
interceptHosts?: readonly string[];
|
|
28
|
+
/** Per-connection override, consulted before interceptHosts; null keeps the default. */
|
|
29
|
+
selectTunnel?: (host: string, port: number, request: ConnectRequestInfo) => TunnelDecision | null | Promise<TunnelDecision | null>;
|
|
26
30
|
/** Test seam: dial the real destination for a blind tunnel. */
|
|
27
31
|
dialUpstream?: (host: string, port: number) => Socket;
|
|
28
32
|
}
|
|
29
33
|
|
|
34
|
+
export type TunnelDecision = { kind: "intercept"; port: number } | { kind: "blind" };
|
|
35
|
+
|
|
36
|
+
/** What the CONNECT head says about its client, for tunnel choice only. Never logged. */
|
|
37
|
+
export interface ConnectRequestInfo {
|
|
38
|
+
/** The CONNECT request's User-Agent header, or null when it sent none. */
|
|
39
|
+
userAgent: string | null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Chromium (Claude Desktop's app) sends its browser User-Agent on CONNECT; the Claude Code
|
|
44
|
+
* processes Desktop spawns send none. The two trust different CAs, so the tunnel choice uses it.
|
|
45
|
+
*
|
|
46
|
+
* This is a routing hint, not a trust boundary: a local client can send any User-Agent. Neither
|
|
47
|
+
* answer grants anything a local process lacks already. A non-browser tunnel reaches the
|
|
48
|
+
* api.anthropic.com intercept, which the Claude Code proxy offers every local process; a browser
|
|
49
|
+
* tunnel reaches the claude.ai relay, which verifies upstream and injects no credential. A client
|
|
50
|
+
* that lies only breaks its own TLS, because each terminator presents a certificate only its
|
|
51
|
+
* intended client trusts.
|
|
52
|
+
*/
|
|
53
|
+
export function isBrowserConnect(request: ConnectRequestInfo): boolean {
|
|
54
|
+
return request.userAgent !== null && /^Mozilla\//.test(request.userAgent);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function connectRequestInfo(head: string): ConnectRequestInfo {
|
|
58
|
+
const match = /\r\nuser-agent:[ \t]*([^\r\n]*)/i.exec(head);
|
|
59
|
+
return { userAgent: match ? match[1]!.trim() : null };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
type ResolvedConnectProxyOptions = Required<Pick<ConnectProxyOptions, "interceptPort" | "interceptHosts" | "dialUpstream">>
|
|
63
|
+
& Pick<ConnectProxyOptions, "selectTunnel">;
|
|
64
|
+
|
|
30
65
|
export interface ConnectProxyHandle {
|
|
31
66
|
port: number;
|
|
32
67
|
close(): Promise<void>;
|
|
@@ -90,7 +125,7 @@ function splice(client: Socket, upstream: Socket, pending: Uint8Array): void {
|
|
|
90
125
|
upstream.pipe(client);
|
|
91
126
|
}
|
|
92
127
|
|
|
93
|
-
function handleConnection(socket: Socket, options:
|
|
128
|
+
function handleConnection(socket: Socket, options: ResolvedConnectProxyOptions): void {
|
|
94
129
|
let head: Buffer = Buffer.alloc(0);
|
|
95
130
|
socket.on("error", () => socket.destroy());
|
|
96
131
|
socket.setTimeout(HEAD_TIMEOUT_MS, () => respond(socket, 408, "Request Timeout"));
|
|
@@ -98,7 +133,8 @@ function handleConnection(socket: Socket, options: Required<Pick<ConnectProxyOpt
|
|
|
98
133
|
const onData = (chunk: Buffer) => {
|
|
99
134
|
head = head.length === 0 ? chunk : Buffer.concat([head, chunk]);
|
|
100
135
|
const end = head.indexOf("\r\n\r\n");
|
|
101
|
-
|
|
136
|
+
// The cap holds however the head arrives: across reads or in one oversized read.
|
|
137
|
+
if (end === -1 || end + 4 > MAX_HEAD_BYTES) {
|
|
102
138
|
if (head.length > MAX_HEAD_BYTES) {
|
|
103
139
|
socket.off("data", onData);
|
|
104
140
|
respond(socket, 431, "Request Header Fields Too Large");
|
|
@@ -109,7 +145,7 @@ function handleConnection(socket: Socket, options: Required<Pick<ConnectProxyOpt
|
|
|
109
145
|
socket.pause();
|
|
110
146
|
const target = parseConnectRequestLine(head.subarray(0, end).toString("latin1"));
|
|
111
147
|
// Bytes after the head belong to the tunnel (a client may pipeline its TLS ClientHello).
|
|
112
|
-
|
|
148
|
+
let pending = head.subarray(end + 4);
|
|
113
149
|
if (!target) {
|
|
114
150
|
respond(socket, 405, "Method Not Allowed");
|
|
115
151
|
return;
|
|
@@ -118,31 +154,71 @@ function handleConnection(socket: Socket, options: Required<Pick<ConnectProxyOpt
|
|
|
118
154
|
respond(socket, 403, "Forbidden");
|
|
119
155
|
return;
|
|
120
156
|
}
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
157
|
+
const dialFor = (selected: TunnelDecision | null): void => {
|
|
158
|
+
if (socket.destroyed) return;
|
|
159
|
+
const choice = selected ?? (target.port === 443 && options.interceptHosts.includes(target.host)
|
|
160
|
+
? { kind: "intercept" as const, port: options.interceptPort }
|
|
161
|
+
: { kind: "blind" as const });
|
|
162
|
+
const upstream = choice.kind === "intercept"
|
|
163
|
+
? connect({ host: "127.0.0.1", port: choice.port })
|
|
164
|
+
: options.dialUpstream(target.host, target.port);
|
|
165
|
+
let established = false;
|
|
166
|
+
const connectTimer = setTimeout(() => {
|
|
167
|
+
if (!established) {
|
|
168
|
+
upstream.destroy();
|
|
169
|
+
respond(socket, 504, "Gateway Timeout");
|
|
170
|
+
}
|
|
171
|
+
}, UPSTREAM_CONNECT_TIMEOUT_MS);
|
|
172
|
+
upstream.once("error", () => {
|
|
173
|
+
clearTimeout(connectTimer);
|
|
174
|
+
if (!established) respond(socket, 502, "Bad Gateway");
|
|
175
|
+
});
|
|
176
|
+
upstream.once("connect", () => {
|
|
177
|
+
established = true;
|
|
178
|
+
clearTimeout(connectTimer);
|
|
179
|
+
if (socket.destroyed) {
|
|
180
|
+
upstream.destroy();
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
socket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
|
|
184
|
+
splice(socket, upstream, pending);
|
|
185
|
+
socket.resume();
|
|
186
|
+
});
|
|
187
|
+
};
|
|
188
|
+
if (!options.selectTunnel) {
|
|
189
|
+
dialFor(null);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
let decision: ReturnType<NonNullable<ConnectProxyOptions["selectTunnel"]>>;
|
|
193
|
+
try {
|
|
194
|
+
decision = options.selectTunnel(target.host, target.port, connectRequestInfo(head.subarray(0, end).toString("latin1")));
|
|
195
|
+
} catch {
|
|
196
|
+
dialFor({ kind: "blind" });
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
if (!decision || typeof (decision as Promise<TunnelDecision | null>).then !== "function") {
|
|
200
|
+
dialFor(decision as TunnelDecision | null);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
// A paused socket does not notice a peer FIN until its readable side is drained.
|
|
204
|
+
// Hold later tunnel bytes here so a departing client cannot trigger a stale dial.
|
|
205
|
+
const onPendingReadable = () => {
|
|
206
|
+
let chunk: Buffer | null;
|
|
207
|
+
while ((chunk = socket.read() as Buffer | null) !== null) {
|
|
208
|
+
if (pending.length + chunk.length > MAX_PENDING_BYTES) {
|
|
209
|
+
socket.destroy();
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
pending = Buffer.concat([pending, chunk]);
|
|
142
213
|
}
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
214
|
+
};
|
|
215
|
+
const onPendingEnd = () => socket.destroy();
|
|
216
|
+
socket.on("readable", onPendingReadable);
|
|
217
|
+
socket.once("end", onPendingEnd);
|
|
218
|
+
void Promise.resolve(decision).catch(() => ({ kind: "blind" as const })).then(choice => {
|
|
219
|
+
socket.off("readable", onPendingReadable);
|
|
220
|
+
socket.off("end", onPendingEnd);
|
|
221
|
+
dialFor(choice);
|
|
146
222
|
});
|
|
147
223
|
};
|
|
148
224
|
socket.on("data", onData);
|
|
@@ -150,9 +226,10 @@ function handleConnection(socket: Socket, options: Required<Pick<ConnectProxyOpt
|
|
|
150
226
|
|
|
151
227
|
/** Bind the CONNECT proxy on 127.0.0.1. Rejects when the port is unavailable. */
|
|
152
228
|
export function startConnectProxy(port: number, options: ConnectProxyOptions): Promise<ConnectProxyHandle> {
|
|
153
|
-
const resolved = {
|
|
229
|
+
const resolved: ResolvedConnectProxyOptions = {
|
|
154
230
|
interceptPort: options.interceptPort,
|
|
155
231
|
interceptHosts: options.interceptHosts ?? CLAUDE_INTERCEPT_HOSTS,
|
|
232
|
+
selectTunnel: options.selectTunnel,
|
|
156
233
|
dialUpstream: options.dialUpstream ?? ((host: string, targetPort: number) => connect({ host, port: targetPort })),
|
|
157
234
|
};
|
|
158
235
|
return new Promise((resolve, reject) => {
|
|
@@ -91,6 +91,7 @@ const OID = {
|
|
|
91
91
|
organization: "2.5.4.10",
|
|
92
92
|
ecdsaWithSha256: "1.2.840.10045.4.3.2",
|
|
93
93
|
basicConstraints: "2.5.29.19",
|
|
94
|
+
nameConstraints: "2.5.29.30",
|
|
94
95
|
keyUsage: "2.5.29.15",
|
|
95
96
|
subjectAltName: "2.5.29.17",
|
|
96
97
|
extendedKeyUsage: "2.5.29.37",
|
|
@@ -112,6 +113,23 @@ function extension(oid: string, critical: boolean, value: Uint8Array): Uint8Arra
|
|
|
112
113
|
: sequence(objectIdentifier(oid), octetString(value));
|
|
113
114
|
}
|
|
114
115
|
|
|
116
|
+
/** iPAddress bases (address + mask, all zero) that cover every IPv4 and every IPv6 address. */
|
|
117
|
+
export const ALL_IP_ADDRESS_BASES: readonly Uint8Array[] = [new Uint8Array(8), new Uint8Array(32)];
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* RFC 5280 NameConstraints. permittedSubtrees holds one dNSName base per name. A DNS-only permitted
|
|
121
|
+
* list leaves other name forms unconstrained, so excludedSubtrees names every IPv4 and IPv6
|
|
122
|
+
* address unless the caller opts out.
|
|
123
|
+
*/
|
|
124
|
+
function nameConstraints(permitted: readonly string[], excludeAllIpAddresses: boolean): Uint8Array {
|
|
125
|
+
const subtrees = permitted.map(name => sequence(contextTag(2, new TextEncoder().encode(name), false)));
|
|
126
|
+
const excluded = ALL_IP_ADDRESS_BASES.map(base => sequence(contextTag(7, base, false)));
|
|
127
|
+
return sequence(
|
|
128
|
+
contextTag(0, concat(...subtrees)),
|
|
129
|
+
...(excludeAllIpAddresses ? [contextTag(1, concat(...excluded))] : []),
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
115
133
|
function subjectPublicKeyInfo(key: KeyObject): Uint8Array {
|
|
116
134
|
return new Uint8Array(key.export({ type: "spki", format: "der" }));
|
|
117
135
|
}
|
|
@@ -173,9 +191,16 @@ export interface LocalInterceptCa extends PemKeyPair {
|
|
|
173
191
|
privateKey: KeyObject;
|
|
174
192
|
}
|
|
175
193
|
|
|
176
|
-
export
|
|
194
|
+
export interface AuthorityOptions {
|
|
195
|
+
commonName: string;
|
|
196
|
+
permittedDnsNames?: readonly string[];
|
|
197
|
+
/** With permittedDnsNames: also exclude every IP address (default true). */
|
|
198
|
+
excludeAllIpAddresses?: boolean;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function createCertificateAuthority(options: AuthorityOptions): LocalInterceptCa {
|
|
177
202
|
const { publicKey, privateKey } = generateKeyPairSync("ec", { namedCurve: "prime256v1" });
|
|
178
|
-
const name = distinguishedName(
|
|
203
|
+
const name = distinguishedName(options.commonName);
|
|
179
204
|
const der = issueCertificate({
|
|
180
205
|
subject: name,
|
|
181
206
|
issuer: name,
|
|
@@ -187,6 +212,9 @@ export function createLocalInterceptCa(): LocalInterceptCa {
|
|
|
187
212
|
// keyCertSign | cRLSign
|
|
188
213
|
extension(OID.keyUsage, true, bitString(Uint8Array.of(0x06), 1)),
|
|
189
214
|
extension(OID.subjectKeyIdentifier, false, octetString(keyIdentifier(publicKey))),
|
|
215
|
+
...(options.permittedDnsNames?.length
|
|
216
|
+
? [extension(OID.nameConstraints, true, nameConstraints(options.permittedDnsNames, options.excludeAllIpAddresses !== false))]
|
|
217
|
+
: []),
|
|
190
218
|
],
|
|
191
219
|
});
|
|
192
220
|
return {
|
|
@@ -197,13 +225,27 @@ export function createLocalInterceptCa(): LocalInterceptCa {
|
|
|
197
225
|
};
|
|
198
226
|
}
|
|
199
227
|
|
|
200
|
-
|
|
201
|
-
|
|
228
|
+
export function createLocalInterceptCa(): LocalInterceptCa {
|
|
229
|
+
return createCertificateAuthority({ commonName: CLAUDE_INTERCEPT_CA_COMMON_NAME });
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** IPv4 literal to its four octets, or null. Only the leaf SAN encoder needs it. */
|
|
233
|
+
function ipv4Octets(host: string): Uint8Array | null {
|
|
234
|
+
const parts = host.split(".");
|
|
235
|
+
if (parts.length !== 4 || !parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)) return null;
|
|
236
|
+
return Uint8Array.from(parts.map(Number));
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Issue a serverAuth leaf for `hosts` (first entry becomes the CN). Names become SAN dNSNames and
|
|
241
|
+
* IPv4 literals become iPAddress entries.
|
|
242
|
+
*/
|
|
243
|
+
export function issueServerLeaf(ca: LocalInterceptCa, issuerCommonName: string, hosts: readonly string[]): PemKeyPair {
|
|
202
244
|
if (hosts.length === 0) throw new Error("intercept leaf requires at least one host");
|
|
203
245
|
const { publicKey, privateKey } = generateKeyPairSync("ec", { namedCurve: "prime256v1" });
|
|
204
246
|
const der = issueCertificate({
|
|
205
247
|
subject: distinguishedName(hosts[0]!),
|
|
206
|
-
issuer: distinguishedName(
|
|
248
|
+
issuer: distinguishedName(issuerCommonName),
|
|
207
249
|
subjectKey: publicKey,
|
|
208
250
|
signingKey: ca.privateKey,
|
|
209
251
|
validityDays: LEAF_VALIDITY_DAYS,
|
|
@@ -213,7 +255,10 @@ export function issueLocalInterceptLeaf(ca: LocalInterceptCa, hosts: readonly st
|
|
|
213
255
|
extension(OID.keyUsage, true, bitString(Uint8Array.of(0x80), 7)),
|
|
214
256
|
extension(OID.extendedKeyUsage, false, sequence(objectIdentifier(OID.serverAuth))),
|
|
215
257
|
extension(OID.subjectAltName, false, sequence(
|
|
216
|
-
...hosts.map(host =>
|
|
258
|
+
...hosts.map(host => {
|
|
259
|
+
const octets = ipv4Octets(host);
|
|
260
|
+
return octets ? contextTag(7, octets, false) : contextTag(2, new TextEncoder().encode(host), false);
|
|
261
|
+
}),
|
|
217
262
|
)),
|
|
218
263
|
extension(OID.authorityKeyIdentifier, false, sequence(contextTag(0, keyIdentifier(ca.publicKey), false))),
|
|
219
264
|
],
|
|
@@ -224,6 +269,10 @@ export function issueLocalInterceptLeaf(ca: LocalInterceptCa, hosts: readonly st
|
|
|
224
269
|
};
|
|
225
270
|
}
|
|
226
271
|
|
|
272
|
+
export function issueLocalInterceptLeaf(ca: LocalInterceptCa, hosts: readonly string[]): PemKeyPair {
|
|
273
|
+
return issueServerLeaf(ca, CLAUDE_INTERCEPT_CA_COMMON_NAME, hosts);
|
|
274
|
+
}
|
|
275
|
+
|
|
227
276
|
// ── Persistence ─────────────────────────────────────────────────────────────────
|
|
228
277
|
|
|
229
278
|
export const CLAUDE_INTERCEPT_STATE_DIR = "claude-intercept";
|
|
@@ -246,7 +295,7 @@ function writeFileAtomic(path: string, contents: string, mode: number): void {
|
|
|
246
295
|
renameSync(tmp, path);
|
|
247
296
|
}
|
|
248
297
|
|
|
249
|
-
function loadPersistedCa(dir: string): LocalInterceptCa | null {
|
|
298
|
+
function loadPersistedCa(dir: string, accept?: (cert: X509Certificate) => boolean): LocalInterceptCa | null {
|
|
250
299
|
const certPath = join(dir, CLAUDE_INTERCEPT_CA_CERT_FILE);
|
|
251
300
|
const keyPath = join(dir, CA_KEY_FILE);
|
|
252
301
|
if (!existsSync(certPath) || !existsSync(keyPath)) return null;
|
|
@@ -256,32 +305,41 @@ function loadPersistedCa(dir: string): LocalInterceptCa | null {
|
|
|
256
305
|
const privateKey = createPrivateKey(keyPem);
|
|
257
306
|
const publicKey = createPublicKey(keyPem);
|
|
258
307
|
const certificate = new X509Certificate(certPem);
|
|
259
|
-
if (!certificate.ca || !certificate.checkPrivateKey(privateKey) || !certificate.verify(publicKey)
|
|
308
|
+
if (!certificate.ca || !certificate.checkPrivateKey(privateKey) || !certificate.verify(publicKey)
|
|
309
|
+
|| (accept && !accept(certificate))) return null;
|
|
260
310
|
return { certPem, keyPem, publicKey, privateKey };
|
|
261
311
|
} catch { // no-excuse-ok: catch -- an unreadable or corrupt authority is regenerated below.
|
|
262
312
|
return null;
|
|
263
313
|
}
|
|
264
314
|
}
|
|
265
315
|
|
|
266
|
-
/**
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
316
|
+
/** Persist an authority under its own lease, replacing unreadable or rejected pairs. */
|
|
317
|
+
export function ensurePersistedAuthority(
|
|
318
|
+
dir: string,
|
|
319
|
+
options: AuthorityOptions,
|
|
320
|
+
lockName = "ca-publication.sqlite",
|
|
321
|
+
accept?: (cert: X509Certificate) => boolean,
|
|
322
|
+
): LocalInterceptCa {
|
|
273
323
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
274
324
|
// A separate SQLite namespace binds exclusion to the explicit CA directory.
|
|
275
325
|
// The OS releases it on crash; a contending caller fails before touching either
|
|
276
326
|
// PEM. Readers also take the lease so they cannot observe half a publication.
|
|
277
327
|
return withClientLifecycleSync(() => {
|
|
278
|
-
const existing = loadPersistedCa(dir);
|
|
328
|
+
const existing = loadPersistedCa(dir, accept);
|
|
279
329
|
if (existing) return existing;
|
|
280
|
-
const ca =
|
|
330
|
+
const ca = createCertificateAuthority(options);
|
|
281
331
|
writeFileAtomic(join(dir, CA_KEY_FILE), ca.keyPem, 0o600);
|
|
282
332
|
writeFileAtomic(join(dir, CLAUDE_INTERCEPT_CA_CERT_FILE), ca.certPem, 0o644);
|
|
283
333
|
return ca;
|
|
284
|
-
}, { lockPath: join(dir,
|
|
334
|
+
}, { lockPath: join(dir, lockName) });
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Preserve the original intercept CA path, name, permissions and extension set. */
|
|
338
|
+
export function ensureLocalInterceptCa(configDir: string): LocalInterceptCa {
|
|
339
|
+
return ensurePersistedAuthority(
|
|
340
|
+
claudeInterceptStateDir(configDir),
|
|
341
|
+
{ commonName: CLAUDE_INTERCEPT_CA_COMMON_NAME },
|
|
342
|
+
);
|
|
285
343
|
}
|
|
286
344
|
|
|
287
345
|
/** Startup may race a settings apply publishing the same CA. Retry only lease
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import type { OcxClaudeCodeConfig } from "../../types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* First-party model bindings for Claude Code traffic that reaches opencodex through the local
|
|
5
|
+
* intercept pair (Claude Desktop's Code tab and the standalone `claude` CLI in first-party mode).
|
|
6
|
+
*
|
|
7
|
+
* In first-party mode Claude Desktop's Code tab picker is owned by claude.ai: its rows come from
|
|
8
|
+
* the account's model selector config and no local setting can add one. What opencodex does see
|
|
9
|
+
* is the picker's Anthropic model id on every Messages request. A binding maps such an id
|
|
10
|
+
* (`claude-sonnet-4-6`) to an opencodex route (`xai/grok-4.7`), so picking that row in Desktop is
|
|
11
|
+
* served by the bound model. The picker keeps Anthropic's label; the binding only changes which
|
|
12
|
+
* model answers.
|
|
13
|
+
*
|
|
14
|
+
* Bindings live in `claudeCode.intercept.modelMap` and apply ONLY to requests that arrived on the
|
|
15
|
+
* `claude-intercept` ingress. They are overlaid on the global `claudeCode.modelMap` for that one
|
|
16
|
+
* request (binding wins per key), so every existing resolution rule still applies: alias first,
|
|
17
|
+
* Desktop 3P alias, exact key, date-suffix-stripped key, `[1m]` strip and `--fast` decode. The
|
|
18
|
+
* overlay is a request-scoped view of `claudeCode`; the live config object is never copied or
|
|
19
|
+
* persisted with the merged map.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** Anthropic picker id shape. Desktop only ever sends `claude-*` ids from its picker. */
|
|
23
|
+
const BINDING_ID_PATTERN = /^claude-[a-z0-9][a-z0-9.\-]*$/i;
|
|
24
|
+
const MAX_BINDING_ID_LENGTH = 128;
|
|
25
|
+
const MAX_BINDING_ROUTE_LENGTH = 256;
|
|
26
|
+
const NATIVE_ROUTE_PREFIX = "native/";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Picker ids Claude Desktop's Code tab offered on 2026-09-23 (the claude.ai model selector config
|
|
30
|
+
* for a Max account). claude.ai owns this list; it is a suggestion for the dashboard, never a
|
|
31
|
+
* restriction, so any `claude-` id is accepted.
|
|
32
|
+
*/
|
|
33
|
+
export const DESKTOP_PICKER_ID_SUGGESTIONS: readonly string[] = [
|
|
34
|
+
"claude-opus-5-5",
|
|
35
|
+
"claude-sonnet-5",
|
|
36
|
+
"claude-fable-5-1",
|
|
37
|
+
"claude-haiku-4-5",
|
|
38
|
+
"claude-opus-5",
|
|
39
|
+
"claude-fable-5",
|
|
40
|
+
"claude-opus-4-8",
|
|
41
|
+
"claude-opus-4-7",
|
|
42
|
+
"claude-opus-4-6",
|
|
43
|
+
"claude-sonnet-4-6",
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
export function isInterceptBindingId(value: unknown): value is string {
|
|
47
|
+
return typeof value === "string" && value.length <= MAX_BINDING_ID_LENGTH && BINDING_ID_PATTERN.test(value);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function isInterceptBindingRoute(value: unknown): value is string {
|
|
51
|
+
return typeof value === "string" && value.length > 0 && value.length <= MAX_BINDING_ROUTE_LENGTH && !/\s/.test(value);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Router model id for a binding target written in the Desktop route vocabulary. `native/<slug>`
|
|
56
|
+
* is the native OpenAI pool's pseudo-provider and resolves to the bare slug, exactly like a
|
|
57
|
+
* Desktop 3P alias does (src/claude/inbound-model-options.ts); every other route is used as is.
|
|
58
|
+
*/
|
|
59
|
+
export function normalizeBindingTarget(route: string): string {
|
|
60
|
+
return route.startsWith(NATIVE_ROUTE_PREFIX) && route.length > NATIVE_ROUTE_PREFIX.length
|
|
61
|
+
? route.slice(NATIVE_ROUTE_PREFIX.length)
|
|
62
|
+
: route;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Valid bindings from a config value; malformed entries are ignored rather than routed. */
|
|
66
|
+
export function readInterceptBindings(cc: OcxClaudeCodeConfig | undefined): Record<string, string> {
|
|
67
|
+
const raw = cc?.intercept?.modelMap;
|
|
68
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
|
|
69
|
+
const out: Record<string, string> = {};
|
|
70
|
+
for (const [id, route] of Object.entries(raw)) {
|
|
71
|
+
if (isInterceptBindingId(id) && isInterceptBindingRoute(route)) out[id] = route;
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The `claudeCode` view a Messages or count_tokens handler resolves models against. Requests on
|
|
78
|
+
* any other ingress, or with no bindings, get the live object back unchanged. Only binding
|
|
79
|
+
* targets are normalized; global `modelMap` values keep their verbatim semantics.
|
|
80
|
+
*/
|
|
81
|
+
export function claudeCodeForIngress(
|
|
82
|
+
cc: OcxClaudeCodeConfig | undefined,
|
|
83
|
+
claudeIntercept: boolean,
|
|
84
|
+
): OcxClaudeCodeConfig | undefined {
|
|
85
|
+
if (!claudeIntercept) return cc;
|
|
86
|
+
const bindings = readInterceptBindings(cc);
|
|
87
|
+
const ids = Object.keys(bindings);
|
|
88
|
+
if (ids.length === 0) return cc;
|
|
89
|
+
const overlay: Record<string, string> = { ...(cc?.modelMap ?? {}) };
|
|
90
|
+
for (const id of ids) overlay[id] = normalizeBindingTarget(bindings[id]!);
|
|
91
|
+
return { ...(cc ?? {}), modelMap: overlay };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface InterceptBindingPatch {
|
|
95
|
+
set?: Record<string, string>;
|
|
96
|
+
remove?: string[];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export type InterceptBindingPatchResult =
|
|
100
|
+
| { ok: true; bindings: Record<string, string>; changed: boolean }
|
|
101
|
+
| { ok: false; error: string };
|
|
102
|
+
|
|
103
|
+
/** Parse an untrusted PUT body into a patch. */
|
|
104
|
+
export function parseInterceptBindingPatch(body: unknown): InterceptBindingPatch | { error: string } {
|
|
105
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) return { error: "body must be an object" };
|
|
106
|
+
const { set, remove, ...rest } = body as Record<string, unknown>;
|
|
107
|
+
const unknown = Object.keys(rest);
|
|
108
|
+
if (unknown.length > 0) return { error: `unknown field: ${unknown[0]}` };
|
|
109
|
+
if (set === undefined && remove === undefined) return { error: "set or remove is required" };
|
|
110
|
+
const patch: InterceptBindingPatch = {};
|
|
111
|
+
if (set !== undefined) {
|
|
112
|
+
if (!set || typeof set !== "object" || Array.isArray(set)) return { error: "set must be an object of picker id to route" };
|
|
113
|
+
const entries: Record<string, string> = {};
|
|
114
|
+
for (const [id, route] of Object.entries(set as Record<string, unknown>)) {
|
|
115
|
+
if (!isInterceptBindingId(id)) return { error: `invalid picker id: ${id} (expected a claude- model id)` };
|
|
116
|
+
if (!isInterceptBindingRoute(route)) return { error: `invalid route for ${id}` };
|
|
117
|
+
entries[id] = route;
|
|
118
|
+
}
|
|
119
|
+
patch.set = entries;
|
|
120
|
+
}
|
|
121
|
+
if (remove !== undefined) {
|
|
122
|
+
if (!Array.isArray(remove) || remove.some(id => typeof id !== "string")) return { error: "remove must be an array of picker ids" };
|
|
123
|
+
patch.remove = remove as string[];
|
|
124
|
+
}
|
|
125
|
+
return patch;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Apply a patch to the current bindings. Routes must be in `availableRoutes` (the Desktop route
|
|
130
|
+
* vocabulary, native routes included); removing an unbound id is a no-op.
|
|
131
|
+
*/
|
|
132
|
+
export function applyInterceptBindingPatch(
|
|
133
|
+
current: Record<string, string>,
|
|
134
|
+
patch: InterceptBindingPatch,
|
|
135
|
+
availableRoutes: ReadonlySet<string>,
|
|
136
|
+
): InterceptBindingPatchResult {
|
|
137
|
+
const next: Record<string, string> = { ...current };
|
|
138
|
+
for (const [id, route] of Object.entries(patch.set ?? {})) {
|
|
139
|
+
if (!availableRoutes.has(route)) return { ok: false, error: `route is not available: ${route}` };
|
|
140
|
+
next[id] = route;
|
|
141
|
+
}
|
|
142
|
+
for (const id of patch.remove ?? []) delete next[id];
|
|
143
|
+
const changed = JSON.stringify(Object.entries(next).sort()) !== JSON.stringify(Object.entries(current).sort());
|
|
144
|
+
return { ok: true, bindings: next, changed };
|
|
145
|
+
}
|