@bitkyc08/opencodex 2.64.0 → 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-SggB6t3z.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-CpuDF3ci.js +0 -50
- package/gui/dist/assets/App-I5AnaSLh.css +0 -1
- package/gui/dist/assets/Tray-B4uEIa1O.js +0 -1
- package/gui/dist/assets/usage-companion-chart-DLbKOJml.js +0 -1
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Desktop picker mode: narrowly rewrite the Code bootstrap catalog.
|
|
3
|
+
* A failed or inapplicable transform leaves the upstream bytes untouched.
|
|
4
|
+
*/
|
|
5
|
+
import { brotliDecompressSync, gunzipSync, inflateSync } from "node:zlib";
|
|
6
|
+
|
|
7
|
+
/** One opencodex model offered in Desktop's Code-tab picker. */
|
|
8
|
+
export interface PickerModelEntry {
|
|
9
|
+
id: string;
|
|
10
|
+
name: string;
|
|
11
|
+
contextWindow?: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const BOOTSTRAP_MAX_ENCODED_BYTES = 4 * 1024 * 1024;
|
|
15
|
+
export const BOOTSTRAP_MAX_DECODED_BYTES = 16 * 1024 * 1024;
|
|
16
|
+
const BOOTSTRAP_PATH = /^\/(?:edge-api|api)\/bootstrap(?:\/[A-Za-z0-9-]+\/app_start)?\/?$/;
|
|
17
|
+
const REWRITE_REMOVED_HEADERS = new Set([
|
|
18
|
+
"content-encoding", "content-length", "etag", "digest", "content-md5", "transfer-encoding",
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
export function isPickerBootstrapRequest(method: string, pathname: string): boolean {
|
|
22
|
+
return method === "GET" && BOOTSTRAP_PATH.test(pathname);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function narrowBootstrapAcceptEncoding(): string {
|
|
26
|
+
return "gzip, deflate, br";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function record(value: unknown): Record<string, unknown> | null {
|
|
30
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
31
|
+
? value as Record<string, unknown> : null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Why a bootstrap was left unchanged, for the metadata-only picker log. Never carries values. */
|
|
35
|
+
export type PickerInjectionOutcome =
|
|
36
|
+
| { kind: "rewritten"; added: number }
|
|
37
|
+
| { kind: "unchanged"; reason: string };
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Surfaces whose picker the local Desktop Code tab can show. Desktop reads "ccd" and falls back to
|
|
41
|
+
* "code" only when "ccd" carries no catalog; "ccr" (remote sessions) is left alone because a
|
|
42
|
+
* remote session never reaches this machine's proxy, so an opencodex route could not run there.
|
|
43
|
+
*/
|
|
44
|
+
export const PICKER_SURFACE_IDS = ["ccd", "code"] as const;
|
|
45
|
+
/** Surface names the log may print; anything else from the body is counted, never echoed. */
|
|
46
|
+
const KNOWN_SURFACE_IDS = new Set(["ccd", "code", "cc", "ccr", "cowork", "chat", "design"]);
|
|
47
|
+
|
|
48
|
+
function injectIntoSurface(surface: Record<string, unknown>, models: readonly PickerModelEntry[]): number | string {
|
|
49
|
+
if (!Array.isArray(surface.models)) return "no_models";
|
|
50
|
+
const entries = surface.models as unknown[];
|
|
51
|
+
const template = entries.map(record).find(entry =>
|
|
52
|
+
typeof entry?.id === "string" && entry.id.startsWith("claude-")
|
|
53
|
+
&& !entry.disabled && !entry.disabled_reason && entry.section !== "deprecated");
|
|
54
|
+
if (!template) return `no_template(models=${entries.length})`;
|
|
55
|
+
const existing = new Set(entries.map(record).map(entry => entry?.id));
|
|
56
|
+
let added = 0;
|
|
57
|
+
for (const model of models) {
|
|
58
|
+
if (existing.has(model.id)) continue;
|
|
59
|
+
const copy = structuredClone(template);
|
|
60
|
+
copy.id = model.id;
|
|
61
|
+
copy.name = model.name;
|
|
62
|
+
copy.section = "main";
|
|
63
|
+
if (model.contextWindow === undefined) delete copy.context_window;
|
|
64
|
+
else copy.context_window = model.contextWindow;
|
|
65
|
+
for (const key of Object.keys(copy)) {
|
|
66
|
+
if (["disabled", "disabled_reason", "badge", "tooltip", "description", "fast_mode"].includes(key)
|
|
67
|
+
|| /version/i.test(key)) delete copy[key];
|
|
68
|
+
}
|
|
69
|
+
entries.push(copy);
|
|
70
|
+
existing.add(model.id);
|
|
71
|
+
added++;
|
|
72
|
+
}
|
|
73
|
+
return added;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function injectPickerModels(
|
|
77
|
+
bootstrap: unknown,
|
|
78
|
+
models: readonly PickerModelEntry[],
|
|
79
|
+
explain?: (outcome: PickerInjectionOutcome) => void,
|
|
80
|
+
): number {
|
|
81
|
+
const unchanged = (reason: string): number => { explain?.({ kind: "unchanged", reason }); return 0; };
|
|
82
|
+
const surfaces = record(bootstrap)?.model_selector_config;
|
|
83
|
+
if (!Array.isArray(surfaces)) return unchanged("no_model_selector_config");
|
|
84
|
+
const rows = surfaces.map(record);
|
|
85
|
+
const targets = rows.filter((entry): entry is Record<string, unknown> =>
|
|
86
|
+
entry !== null && (PICKER_SURFACE_IDS as readonly unknown[]).includes(entry.id));
|
|
87
|
+
if (targets.length === 0) {
|
|
88
|
+
// Only known surface names reach the log; any other body-derived value is counted.
|
|
89
|
+
const known = rows.flatMap(entry => typeof entry?.id === "string" && KNOWN_SURFACE_IDS.has(entry.id) ? [entry.id] : []);
|
|
90
|
+
const other = rows.length - known.length;
|
|
91
|
+
return unchanged(`no_code_surface(${[...known, ...(other > 0 ? [`other:${other}`] : [])].join(",")})`);
|
|
92
|
+
}
|
|
93
|
+
let added = 0;
|
|
94
|
+
const skipped: string[] = [];
|
|
95
|
+
for (const surface of targets) {
|
|
96
|
+
const result = injectIntoSurface(surface, models);
|
|
97
|
+
if (typeof result === "number") added += result;
|
|
98
|
+
else skipped.push(`${String(surface.id)}:${result}`);
|
|
99
|
+
}
|
|
100
|
+
if (added === 0) return unchanged(skipped.length > 0 ? skipped.join(";") : models.length === 0 ? "no_routes" : "all_present");
|
|
101
|
+
explain?.({ kind: "rewritten", added });
|
|
102
|
+
return added;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function rewriteBootstrapBody(
|
|
106
|
+
encoded: Buffer,
|
|
107
|
+
contentEncoding: string | undefined,
|
|
108
|
+
models: readonly PickerModelEntry[],
|
|
109
|
+
explain?: (outcome: PickerInjectionOutcome) => void,
|
|
110
|
+
): Buffer | null {
|
|
111
|
+
const unchanged = (reason: string): null => { explain?.({ kind: "unchanged", reason }); return null; };
|
|
112
|
+
if (encoded.length > BOOTSTRAP_MAX_ENCODED_BYTES) return unchanged("encoded_cap");
|
|
113
|
+
const encoding = contentEncoding?.trim().toLowerCase() || "identity";
|
|
114
|
+
let decoded: Buffer;
|
|
115
|
+
try {
|
|
116
|
+
switch (encoding) {
|
|
117
|
+
case "identity": decoded = encoded; break;
|
|
118
|
+
case "gzip":
|
|
119
|
+
case "x-gzip": decoded = gunzipSync(encoded, { maxOutputLength: BOOTSTRAP_MAX_DECODED_BYTES }); break;
|
|
120
|
+
case "deflate": decoded = inflateSync(encoded, { maxOutputLength: BOOTSTRAP_MAX_DECODED_BYTES }); break;
|
|
121
|
+
case "br": decoded = brotliDecompressSync(encoded, { maxOutputLength: BOOTSTRAP_MAX_DECODED_BYTES }); break;
|
|
122
|
+
default: return unchanged("unsupported_encoding");
|
|
123
|
+
}
|
|
124
|
+
if (decoded.length > BOOTSTRAP_MAX_DECODED_BYTES) return unchanged("decoded_cap");
|
|
125
|
+
const parsed: unknown = JSON.parse(decoded.toString("utf8"));
|
|
126
|
+
if (injectPickerModels(parsed, models, explain) === 0) return null;
|
|
127
|
+
return Buffer.from(JSON.stringify(parsed), "utf8");
|
|
128
|
+
} catch {
|
|
129
|
+
return unchanged("decode_or_parse_failed");
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Rewritten payloads are identity encoded, so stale validators and sizes must go. */
|
|
134
|
+
export function rewrittenHeaders(raw: readonly string[], bodyLength: number): string[] {
|
|
135
|
+
const result: string[] = [];
|
|
136
|
+
for (let i = 0; i + 1 < raw.length; i += 2) {
|
|
137
|
+
if (!REWRITE_REMOVED_HEADERS.has(raw[i]!.toLowerCase())) result.push(raw[i]!, raw[i + 1]!);
|
|
138
|
+
}
|
|
139
|
+
result.push("Content-Length", String(bodyLength));
|
|
140
|
+
return result;
|
|
141
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { createHash, X509Certificate } from "node:crypto";
|
|
2
|
+
import { chmodSync, mkdirSync, renameSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import {
|
|
5
|
+
ALL_IP_ADDRESS_BASES,
|
|
6
|
+
ensurePersistedAuthority,
|
|
7
|
+
issueServerLeaf,
|
|
8
|
+
type LocalInterceptCa,
|
|
9
|
+
type PemKeyPair,
|
|
10
|
+
} from "./local-ca";
|
|
11
|
+
|
|
12
|
+
/** Separate root for Desktop traffic: its critical DNS constraint is checked on every reload. */
|
|
13
|
+
export const PICKER_HOST = "claude.ai";
|
|
14
|
+
export const PICKER_CA_COMMON_NAME = "opencodex Claude Desktop Picker CA";
|
|
15
|
+
export const PICKER_STATE_DIR = "claude-picker";
|
|
16
|
+
|
|
17
|
+
export interface PickerCa extends LocalInterceptCa { fingerprint: string }
|
|
18
|
+
|
|
19
|
+
export function pickerStateDir(configDir: string): string { return join(configDir, PICKER_STATE_DIR); }
|
|
20
|
+
export function pickerCaCertPath(configDir: string): string { return join(pickerStateDir(configDir), "ca.pem"); }
|
|
21
|
+
export function pickerLeafCertPath(configDir: string): string { return join(pickerStateDir(configDir), "leaf.pem"); }
|
|
22
|
+
|
|
23
|
+
export function pickerCaFingerprints(certPem: string): { sha1: string; sha256: string } {
|
|
24
|
+
const der = new X509Certificate(certPem).raw;
|
|
25
|
+
return {
|
|
26
|
+
sha1: createHash("sha1").update(der).digest("hex").toUpperCase(),
|
|
27
|
+
sha256: createHash("sha256").update(der).digest("hex").toUpperCase(),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface DerItem { tag: number; body: Buffer; next: number }
|
|
32
|
+
|
|
33
|
+
function readDer(bytes: Buffer, at: number): DerItem | null {
|
|
34
|
+
if (at + 2 > bytes.length) return null;
|
|
35
|
+
const tag = bytes[at]!;
|
|
36
|
+
let length = bytes[at + 1]!;
|
|
37
|
+
let cursor = at + 2;
|
|
38
|
+
if (length & 0x80) {
|
|
39
|
+
const width = length & 0x7f;
|
|
40
|
+
if (width === 0 || width > 4 || cursor + width > bytes.length) return null;
|
|
41
|
+
length = 0;
|
|
42
|
+
for (let i = 0; i < width; i++) length = length * 256 + bytes[cursor++]!;
|
|
43
|
+
}
|
|
44
|
+
if (cursor + length > bytes.length) return null;
|
|
45
|
+
return { tag, body: bytes.subarray(cursor, cursor + length), next: cursor + length };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function children(bytes: Buffer): DerItem[] | null {
|
|
49
|
+
const out: DerItem[] = [];
|
|
50
|
+
for (let cursor = 0; cursor < bytes.length;) {
|
|
51
|
+
const item = readDer(bytes, cursor);
|
|
52
|
+
if (!item) return null;
|
|
53
|
+
out.push(item);
|
|
54
|
+
cursor = item.next;
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function constrainedToPickerHost(value: Buffer): boolean {
|
|
60
|
+
const root = readDer(value, 0);
|
|
61
|
+
if (!root || root.tag !== 0x30 || root.next !== value.length) return false;
|
|
62
|
+
const fields = children(root.body);
|
|
63
|
+
// permittedSubtrees [0] with exactly claude.ai, excludedSubtrees [1] with every IPv4 and IPv6 address.
|
|
64
|
+
if (!fields || fields.length !== 2 || fields[0]!.tag !== 0xa0 || fields[1]!.tag !== 0xa1) return false;
|
|
65
|
+
const excluded = children(fields[1]!.body);
|
|
66
|
+
if (!excluded || excluded.length !== ALL_IP_ADDRESS_BASES.length) return false;
|
|
67
|
+
const excludesAllIps = excluded.every((subtree, index) => {
|
|
68
|
+
if (subtree.tag !== 0x30) return false;
|
|
69
|
+
const base = children(subtree.body);
|
|
70
|
+
return !!base && base.length === 1 && base[0]!.tag === 0x87
|
|
71
|
+
&& base[0]!.body.equals(Buffer.from(ALL_IP_ADDRESS_BASES[index]!));
|
|
72
|
+
});
|
|
73
|
+
if (!excludesAllIps) return false;
|
|
74
|
+
const subtrees = children(fields[0]!.body);
|
|
75
|
+
if (!subtrees || subtrees.length !== 1 || subtrees[0]!.tag !== 0x30) return false;
|
|
76
|
+
const subtree = children(subtrees[0]!.body);
|
|
77
|
+
return !!subtree && subtree.length === 1 && subtree[0]!.tag === 0x82
|
|
78
|
+
&& subtree[0]!.body.equals(Buffer.from(PICKER_HOST, "ascii"));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Inspect the extension itself; X509Certificate does not expose nameConstraints. */
|
|
82
|
+
function acceptsPickerAuthority(cert: X509Certificate): boolean {
|
|
83
|
+
if (!cert.subject.split("\n").includes(`CN=${PICKER_CA_COMMON_NAME}`)) return false;
|
|
84
|
+
const root = readDer(cert.raw, 0);
|
|
85
|
+
if (!root || root.tag !== 0x30 || root.next !== cert.raw.length) return false;
|
|
86
|
+
const certificate = children(root.body);
|
|
87
|
+
if (!certificate || certificate[0]?.tag !== 0x30) return false;
|
|
88
|
+
const tbs = children(certificate[0].body);
|
|
89
|
+
const extensionField = tbs?.find(item => item.tag === 0xa3);
|
|
90
|
+
const wrapped = extensionField && readDer(extensionField.body, 0);
|
|
91
|
+
if (!wrapped || wrapped.tag !== 0x30 || wrapped.next !== extensionField!.body.length) return false;
|
|
92
|
+
const extensions = children(wrapped.body);
|
|
93
|
+
if (!extensions) return false;
|
|
94
|
+
const matches = extensions.filter(item => {
|
|
95
|
+
if (item.tag !== 0x30) return false;
|
|
96
|
+
const fields = children(item.body);
|
|
97
|
+
return fields?.[0]?.tag === 0x06 && fields[0].body.equals(Buffer.from([0x55, 0x1d, 0x1e]));
|
|
98
|
+
});
|
|
99
|
+
if (matches.length !== 1) return false;
|
|
100
|
+
const fields = children(matches[0]!.body);
|
|
101
|
+
return !!fields && fields.length === 3
|
|
102
|
+
&& fields[1]!.tag === 0x01 && fields[1]!.body.equals(Buffer.from([0xff]))
|
|
103
|
+
&& fields[2]!.tag === 0x04 && constrainedToPickerHost(fields[2]!.body);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function ensurePickerCa(configDir: string): PickerCa {
|
|
107
|
+
const ca = ensurePersistedAuthority(
|
|
108
|
+
pickerStateDir(configDir),
|
|
109
|
+
{ commonName: PICKER_CA_COMMON_NAME, permittedDnsNames: [PICKER_HOST] },
|
|
110
|
+
"ca-publication.sqlite",
|
|
111
|
+
acceptsPickerAuthority,
|
|
112
|
+
);
|
|
113
|
+
return { ...ca, fingerprint: pickerCaFingerprints(ca.certPem).sha256 };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Persist only the public leaf, so trust inspection verifies this exact local issuer. */
|
|
117
|
+
export function issuePickerLeaf(ca: PickerCa, configDir: string): PemKeyPair {
|
|
118
|
+
const leaf = issueServerLeaf(ca, PICKER_CA_COMMON_NAME, [PICKER_HOST]);
|
|
119
|
+
const path = pickerLeafCertPath(configDir);
|
|
120
|
+
mkdirSync(pickerStateDir(configDir), { recursive: true, mode: 0o700 });
|
|
121
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
122
|
+
writeFileSync(tmp, leaf.certPem, { mode: 0o644 });
|
|
123
|
+
try { chmodSync(tmp, 0o644); } catch { /* best-effort on platforms without POSIX modes */ }
|
|
124
|
+
renameSync(tmp, path);
|
|
125
|
+
return leaf;
|
|
126
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP/1.1 TLS terminator for claude.ai. Only the bounded bootstrap response is
|
|
3
|
+
* held; all other HTTP bodies and upgraded sockets relay as streams.
|
|
4
|
+
*/
|
|
5
|
+
import { createServer, request as httpsRequest } from "node:https";
|
|
6
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
7
|
+
import { connect as tlsConnect } from "node:tls";
|
|
8
|
+
import type { Duplex } from "node:stream";
|
|
9
|
+
import type { PemKeyPair } from "./local-ca";
|
|
10
|
+
import {
|
|
11
|
+
BOOTSTRAP_MAX_ENCODED_BYTES, isPickerBootstrapRequest, narrowBootstrapAcceptEncoding,
|
|
12
|
+
rewriteBootstrapBody, rewrittenHeaders,
|
|
13
|
+
} from "./picker-bootstrap";
|
|
14
|
+
import type { PickerModelEntry } from "./picker-bootstrap";
|
|
15
|
+
|
|
16
|
+
export interface PickerListenerOptions {
|
|
17
|
+
leaf: PemKeyPair;
|
|
18
|
+
models: () => readonly PickerModelEntry[];
|
|
19
|
+
upstream?: { host: string; port: number; servername: string; ca?: string };
|
|
20
|
+
/** Test seam: encoded bootstrap cap; production uses BOOTSTRAP_MAX_ENCODED_BYTES. */
|
|
21
|
+
maxEncodedBytes?: number;
|
|
22
|
+
log?: (line: string) => void;
|
|
23
|
+
}
|
|
24
|
+
export interface PickerListenerHandle { port: number; close(): Promise<void> }
|
|
25
|
+
|
|
26
|
+
const HOP_HEADERS = new Set([
|
|
27
|
+
"connection", "keep-alive", "proxy-connection", "proxy-authenticate", "proxy-authorization",
|
|
28
|
+
"te", "trailer", "transfer-encoding", "upgrade",
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
function filteredHeaders(raw: readonly string[], omit: ReadonlySet<string> = new Set()): string[] {
|
|
32
|
+
const named = new Set<string>();
|
|
33
|
+
for (let i = 0; i + 1 < raw.length; i += 2) {
|
|
34
|
+
if (raw[i]!.toLowerCase() === "connection") {
|
|
35
|
+
for (const name of raw[i + 1]!.split(",")) named.add(name.trim().toLowerCase());
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const result: string[] = [];
|
|
39
|
+
for (let i = 0; i + 1 < raw.length; i += 2) {
|
|
40
|
+
const name = raw[i]!.toLowerCase();
|
|
41
|
+
if (!HOP_HEADERS.has(name) && !named.has(name) && !omit.has(name)) {
|
|
42
|
+
result.push(raw[i]!, raw[i + 1]!);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return result;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function hasJsonContentType(raw: readonly string[]): boolean {
|
|
49
|
+
for (let i = 0; i + 1 < raw.length; i += 2) {
|
|
50
|
+
if (raw[i]!.toLowerCase() === "content-type") {
|
|
51
|
+
return /^(?:application\/json|[^;\s]+\+json)(?:\s*;|\s*$)/i.test(raw[i + 1]!);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function contentEncoding(raw: readonly string[]): string | undefined {
|
|
58
|
+
for (let i = 0; i + 1 < raw.length; i += 2) {
|
|
59
|
+
if (raw[i]!.toLowerCase() === "content-encoding") return raw[i + 1]!;
|
|
60
|
+
}
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function startPickerListener(options: PickerListenerOptions): Promise<PickerListenerHandle> {
|
|
65
|
+
const upstream = options.upstream ?? { host: "claude.ai", port: 443, servername: "claude.ai" };
|
|
66
|
+
const cap = options.maxEncodedBytes ?? BOOTSTRAP_MAX_ENCODED_BYTES;
|
|
67
|
+
const upgrades = new Set<Duplex>();
|
|
68
|
+
const server = createServer({ cert: options.leaf.certPem, key: options.leaf.keyPem, ALPNProtocols: ["http/1.1"] });
|
|
69
|
+
|
|
70
|
+
server.on("request", (req: IncomingMessage, res: ServerResponse) => {
|
|
71
|
+
const method = req.method ?? "GET";
|
|
72
|
+
const pathname = new URL(req.url ?? "/", "https://claude.ai").pathname;
|
|
73
|
+
const bootstrap = isPickerBootstrapRequest(method, pathname);
|
|
74
|
+
const category = bootstrap ? "bootstrap" : "other";
|
|
75
|
+
let logged = false;
|
|
76
|
+
const log = (status: number) => {
|
|
77
|
+
if (!logged) options.log?.(`picker ${method} ${category} ${status}`);
|
|
78
|
+
logged = true;
|
|
79
|
+
};
|
|
80
|
+
const fail = () => {
|
|
81
|
+
if (res.headersSent) res.destroy();
|
|
82
|
+
else { res.writeHead(502, { "Content-Length": "0" }); res.end(); log(502); }
|
|
83
|
+
};
|
|
84
|
+
const omit = bootstrap ? new Set(["accept-encoding"]) : new Set<string>();
|
|
85
|
+
const headers = filteredHeaders(req.rawHeaders, omit);
|
|
86
|
+
if (bootstrap) headers.push("Accept-Encoding", narrowBootstrapAcceptEncoding());
|
|
87
|
+
const upReq = httpsRequest({
|
|
88
|
+
host: upstream.host, port: upstream.port, servername: upstream.servername,
|
|
89
|
+
ca: upstream.ca, rejectUnauthorized: true, agent: false,
|
|
90
|
+
method, path: req.url, headers,
|
|
91
|
+
}, upRes => {
|
|
92
|
+
const status = upRes.statusCode ?? 502;
|
|
93
|
+
const originalHeaders = filteredHeaders(upRes.rawHeaders);
|
|
94
|
+
const sendHead = (raw: string[]) => {
|
|
95
|
+
if (res.headersSent) return;
|
|
96
|
+
res.writeHead(status, upRes.statusMessage, raw);
|
|
97
|
+
log(status);
|
|
98
|
+
};
|
|
99
|
+
upRes.on("error", fail);
|
|
100
|
+
if (!bootstrap || status !== 200 || !hasJsonContentType(upRes.rawHeaders)) {
|
|
101
|
+
sendHead(originalHeaders);
|
|
102
|
+
upRes.pipe(res);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const held: Buffer[] = [];
|
|
106
|
+
let size = 0;
|
|
107
|
+
let handedOff = false;
|
|
108
|
+
const onData = (chunk: Buffer) => {
|
|
109
|
+
if (size + chunk.length > cap) {
|
|
110
|
+
upRes.pause();
|
|
111
|
+
upRes.off("data", onData);
|
|
112
|
+
handedOff = true;
|
|
113
|
+
sendHead(originalHeaders);
|
|
114
|
+
for (const part of held) res.write(part);
|
|
115
|
+
res.write(chunk);
|
|
116
|
+
upRes.pipe(res);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
held.push(chunk);
|
|
120
|
+
size += chunk.length;
|
|
121
|
+
};
|
|
122
|
+
upRes.on("data", onData);
|
|
123
|
+
upRes.once("end", () => {
|
|
124
|
+
if (handedOff) return;
|
|
125
|
+
const original = Buffer.concat(held, size);
|
|
126
|
+
let outcome = "unchanged";
|
|
127
|
+
const rewritten = rewriteBootstrapBody(original, contentEncoding(upRes.rawHeaders), options.models(), result => {
|
|
128
|
+
outcome = result.kind === "rewritten" ? `rewritten(+${result.added})` : `unchanged:${result.reason}`;
|
|
129
|
+
});
|
|
130
|
+
sendHead(rewritten === null ? originalHeaders : rewrittenHeaders(originalHeaders, rewritten.length));
|
|
131
|
+
options.log?.(`picker ${method} bootstrap ${outcome}`);
|
|
132
|
+
res.end(rewritten ?? original);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
upReq.on("error", fail);
|
|
136
|
+
req.on("error", () => upReq.destroy());
|
|
137
|
+
res.on("close", () => { if (!res.writableEnded) upReq.destroy(); });
|
|
138
|
+
req.pipe(upReq);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
server.on("upgrade", (req, client, head) => {
|
|
142
|
+
const method = req.method ?? "GET";
|
|
143
|
+
const target = tlsConnect({
|
|
144
|
+
host: upstream.host, port: upstream.port, servername: upstream.servername,
|
|
145
|
+
ca: upstream.ca, rejectUnauthorized: true, ALPNProtocols: ["http/1.1"],
|
|
146
|
+
});
|
|
147
|
+
upgrades.add(client);
|
|
148
|
+
upgrades.add(target);
|
|
149
|
+
let established = false;
|
|
150
|
+
let upgradeLogged = false;
|
|
151
|
+
let responseStart = "";
|
|
152
|
+
const logUpgrade = (status: number) => {
|
|
153
|
+
if (upgradeLogged) return;
|
|
154
|
+
upgradeLogged = true;
|
|
155
|
+
options.log?.(`picker ${method} other ${status}`);
|
|
156
|
+
};
|
|
157
|
+
const onResponseData = (chunk: Buffer) => {
|
|
158
|
+
responseStart += chunk.toString("latin1");
|
|
159
|
+
const end = responseStart.indexOf("\r\n");
|
|
160
|
+
if (end >= 0) {
|
|
161
|
+
target.off("data", onResponseData);
|
|
162
|
+
const status = /^HTTP\/1\.[01] (\d{3})(?: |$)/.exec(responseStart.slice(0, end));
|
|
163
|
+
logUpgrade(status ? Number(status[1]) : 502);
|
|
164
|
+
} else if (responseStart.length > 128) {
|
|
165
|
+
target.off("data", onResponseData);
|
|
166
|
+
logUpgrade(502);
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
const fail = () => {
|
|
170
|
+
if (!established && !client.destroyed) client.end("HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
|
|
171
|
+
else client.destroy();
|
|
172
|
+
target.destroy();
|
|
173
|
+
logUpgrade(502);
|
|
174
|
+
};
|
|
175
|
+
target.on("data", onResponseData);
|
|
176
|
+
target.once("secureConnect", () => {
|
|
177
|
+
established = true;
|
|
178
|
+
target.write(`${method} ${req.url ?? "/"} HTTP/1.1\r\n`);
|
|
179
|
+
for (let i = 0; i + 1 < req.rawHeaders.length; i += 2) {
|
|
180
|
+
if (/^proxy-(?:authorization|connection)$/i.test(req.rawHeaders[i]!)) continue;
|
|
181
|
+
target.write(`${req.rawHeaders[i]}: ${req.rawHeaders[i + 1]}\r\n`);
|
|
182
|
+
}
|
|
183
|
+
target.write("\r\n");
|
|
184
|
+
if (head.length) target.write(head);
|
|
185
|
+
client.pipe(target).pipe(client);
|
|
186
|
+
});
|
|
187
|
+
target.once("error", fail);
|
|
188
|
+
client.once("error", () => target.destroy());
|
|
189
|
+
target.once("close", () => { upgrades.delete(target); if (!upgradeLogged) logUpgrade(502); client.destroy(); });
|
|
190
|
+
client.once("close", () => { upgrades.delete(client); target.destroy(); });
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
try {
|
|
194
|
+
await new Promise<void>((resolve, reject) => {
|
|
195
|
+
server.once("error", reject);
|
|
196
|
+
server.listen(0, "127.0.0.1", () => { server.off("error", reject); resolve(); });
|
|
197
|
+
});
|
|
198
|
+
} catch (error) {
|
|
199
|
+
server.close();
|
|
200
|
+
throw error;
|
|
201
|
+
}
|
|
202
|
+
const address = server.address();
|
|
203
|
+
if (!address || typeof address === "string") throw new Error("picker listener has no port");
|
|
204
|
+
return {
|
|
205
|
+
port: address.port,
|
|
206
|
+
async close() {
|
|
207
|
+
for (const socket of upgrades) socket.destroy();
|
|
208
|
+
const closed = new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve()));
|
|
209
|
+
server.closeAllConnections();
|
|
210
|
+
await closed;
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Desktop picker candidates use the gateway's profile order and labels, while
|
|
3
|
+
* publishing aliases that the first-party Messages ingress can resolve.
|
|
4
|
+
*/
|
|
5
|
+
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { dirname } from "node:path";
|
|
7
|
+
import { nativeOpenAiContextWindow, type NativeContextLimitsInput } from "../../codex/catalog";
|
|
8
|
+
import type { OcxClaudeDesktopProfile } from "../../types";
|
|
9
|
+
import { aliasForRoute, claudeCodeNativeAlias } from "../alias";
|
|
10
|
+
import { displayModelId, type Desktop3pRoutedModel } from "../desktop-3p";
|
|
11
|
+
import { reconcileDesktopProfile, renderDesktopProfile, type DesktopProfileModel } from "../desktop-profile";
|
|
12
|
+
import type { PickerModelEntry } from "./picker-bootstrap";
|
|
13
|
+
|
|
14
|
+
export interface PickerRouteInput {
|
|
15
|
+
nativeSlugs: string[];
|
|
16
|
+
routedModels: Desktop3pRoutedModel[];
|
|
17
|
+
profile?: OcxClaudeDesktopProfile;
|
|
18
|
+
nativeContextCap?: NativeContextLimitsInput;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function buildPickerModels(input: PickerRouteInput): PickerModelEntry[] {
|
|
22
|
+
const candidates: DesktopProfileModel[] = [
|
|
23
|
+
...input.nativeSlugs.map(id => {
|
|
24
|
+
const contextWindow = nativeOpenAiContextWindow(id, input.nativeContextCap);
|
|
25
|
+
return {
|
|
26
|
+
route: `native/${id}`,
|
|
27
|
+
label: `${displayModelId(id)} (native)`,
|
|
28
|
+
...(contextWindow === undefined ? {} : { contextWindow }),
|
|
29
|
+
};
|
|
30
|
+
}),
|
|
31
|
+
...input.routedModels.map(({ provider, id, contextWindow }) => ({
|
|
32
|
+
route: `${provider}/${id}`,
|
|
33
|
+
label: `${displayModelId(id)} (${provider})`,
|
|
34
|
+
...(contextWindow === undefined ? {} : { contextWindow }),
|
|
35
|
+
})),
|
|
36
|
+
];
|
|
37
|
+
const rendered = input.profile
|
|
38
|
+
? renderDesktopProfile(reconcileDesktopProfile(input.profile, candidates), candidates)
|
|
39
|
+
: candidates;
|
|
40
|
+
const out: PickerModelEntry[] = [];
|
|
41
|
+
const seen = new Set<string>();
|
|
42
|
+
for (const model of rendered) {
|
|
43
|
+
const slash = model.route.indexOf("/");
|
|
44
|
+
const provider = model.route.slice(0, slash);
|
|
45
|
+
const id = model.route.slice(slash + 1);
|
|
46
|
+
if (provider === "anthropic" && id.startsWith("claude-")) continue;
|
|
47
|
+
const alias = provider === "native" ? claudeCodeNativeAlias(id) : aliasForRoute(provider, id);
|
|
48
|
+
if (!alias || seen.has(alias)) continue;
|
|
49
|
+
seen.add(alias);
|
|
50
|
+
out.push({ id: alias, name: model.label,
|
|
51
|
+
...(model.contextWindow === undefined ? {} : { contextWindow: model.contextWindow }) });
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface PickerModelSnapshot {
|
|
57
|
+
current(): { models: PickerModelEntry[]; builtAt: number } | null;
|
|
58
|
+
refresh(): Promise<void>;
|
|
59
|
+
refreshIfStale(maxAgeMs: number): void;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function parseSnapshot(value: unknown): { models: PickerModelEntry[]; builtAt: number } | null {
|
|
63
|
+
if (!value || typeof value !== "object") return null;
|
|
64
|
+
const candidate = value as { models?: unknown; builtAt?: unknown };
|
|
65
|
+
if (typeof candidate.builtAt !== "number" || !Number.isFinite(candidate.builtAt) || !Array.isArray(candidate.models)) return null;
|
|
66
|
+
if (!candidate.models.every(model => model && typeof model === "object"
|
|
67
|
+
&& typeof model.id === "string" && typeof model.name === "string"
|
|
68
|
+
&& (model.contextWindow === undefined || typeof model.contextWindow === "number"))) return null;
|
|
69
|
+
return { models: candidate.models as PickerModelEntry[], builtAt: candidate.builtAt };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function createPickerModelSnapshot(load: () => Promise<PickerRouteInput>, persistPath?: string): PickerModelSnapshot {
|
|
73
|
+
let snapshot: { models: PickerModelEntry[]; builtAt: number } | null = null;
|
|
74
|
+
if (persistPath) {
|
|
75
|
+
try { snapshot = parseSnapshot(JSON.parse(readFileSync(persistPath, "utf8")) as unknown); } catch { /* No usable prior snapshot. */ }
|
|
76
|
+
}
|
|
77
|
+
let pending: Promise<void> | null = null;
|
|
78
|
+
const refresh = (): Promise<void> => {
|
|
79
|
+
if (pending) return pending;
|
|
80
|
+
pending = (async () => {
|
|
81
|
+
try {
|
|
82
|
+
const models = buildPickerModels(await load());
|
|
83
|
+
const next = { models, builtAt: Date.now() };
|
|
84
|
+
if (persistPath) {
|
|
85
|
+
mkdirSync(dirname(persistPath), { recursive: true, mode: 0o700 });
|
|
86
|
+
writeFileSync(persistPath, JSON.stringify(next), { encoding: "utf8", mode: 0o600 });
|
|
87
|
+
chmodSync(persistPath, 0o600);
|
|
88
|
+
}
|
|
89
|
+
snapshot = next;
|
|
90
|
+
} catch { /* Keep the last good snapshot when discovery or persistence fails. */ }
|
|
91
|
+
})().finally(() => { pending = null; });
|
|
92
|
+
return pending;
|
|
93
|
+
};
|
|
94
|
+
return {
|
|
95
|
+
current: () => snapshot,
|
|
96
|
+
refresh,
|
|
97
|
+
refreshIfStale(maxAgeMs) {
|
|
98
|
+
if (!pending && (!snapshot || Date.now() - snapshot.builtAt >= maxAgeMs)) void refresh();
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|