@bitkyc08/opencodex 2.77.0 → 2.78.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/bin/ocx.mjs +11 -10
- package/gui/dist/assets/App-BtfG5id-.js +54 -0
- package/gui/dist/assets/App-lCIslZ1L.css +1 -0
- package/gui/dist/assets/{Tray-gloIYNaW.js → Tray-DiJpSub0.js} +1 -1
- package/gui/dist/assets/index-Btc3Ogw4.js +94 -0
- package/gui/dist/assets/index-DmQB8D_R.css +1 -0
- package/gui/dist/assets/quota-summary-B_a96nXj.js +1 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/anthropic/beta-allowlist.ts +19 -2
- package/src/adapters/anthropic/client-identity.ts +13 -4
- package/src/adapters/anthropic/native-client-preamble.ts +31 -0
- package/src/adapters/anthropic/passthrough.ts +103 -25
- package/src/adapters/devin/cloud-direct/chat.ts +7 -2
- package/src/adapters/devin/trajectory.ts +49 -0
- package/src/adapters/devin.ts +13 -0
- package/src/adapters/ollama-native.ts +35 -19
- package/src/claude/intercept/client-hello.ts +103 -0
- package/src/claude/intercept/model-bindings.ts +3 -2
- package/src/claude/intercept/picker-listener.ts +279 -66
- package/src/cli/access-audio-input.ts +84 -0
- package/src/cli/access-audio-live.ts +99 -0
- package/src/cli/access-audio.ts +123 -0
- package/src/cli/access-data-client.ts +127 -0
- package/src/cli/access-data-plane.ts +104 -0
- package/src/cli/access-data-response.ts +76 -0
- package/src/cli/access.ts +65 -2
- package/src/cli/account-api.ts +30 -6
- package/src/cli/account-auth.ts +111 -42
- package/src/cli/account-key-quota.ts +118 -0
- package/src/cli/account-main.ts +11 -4
- package/src/cli/account-next-actions.ts +63 -0
- package/src/cli/account-policy-dto.ts +68 -0
- package/src/cli/account-policy.ts +167 -0
- package/src/cli/account-target.ts +8 -0
- package/src/cli/account.ts +34 -8
- package/src/cli/agent-runtime-settings.ts +130 -0
- package/src/cli/agent-settings.ts +101 -0
- package/src/cli/agent.ts +19 -2
- package/src/cli/alias.ts +7 -2
- package/src/cli/aside-profiles.ts +2 -2
- package/src/cli/capabilities-access-remote.ts +147 -0
- package/src/cli/capabilities-accounts.ts +195 -0
- package/src/cli/capabilities-agents-routing.ts +414 -0
- package/src/cli/capabilities-base.ts +783 -0
- package/src/cli/capabilities-command.ts +1 -0
- package/src/cli/capabilities-integrations.ts +264 -0
- package/src/cli/capabilities-lab.ts +214 -0
- package/src/cli/capabilities-observe-system.ts +662 -0
- package/src/cli/capabilities-provider-models.ts +302 -0
- package/src/cli/capabilities.ts +30 -1199
- package/src/cli/capability-types.ts +54 -0
- package/src/cli/catalog-command-result.ts +57 -0
- package/src/cli/chatgpt-command.ts +25 -2
- package/src/cli/claude-agent-startup-sync.ts +17 -0
- package/src/cli/claude-desktop-profile.ts +100 -0
- package/src/cli/claude-desktop.ts +39 -6
- package/src/cli/codex-shim-autorestore.ts +2 -1
- package/src/cli/codex-shim-readiness.ts +23 -3
- package/src/cli/combo-input.ts +88 -0
- package/src/cli/combo-result.ts +52 -0
- package/src/cli/combo-stats.ts +123 -0
- package/src/cli/combo.ts +37 -14
- package/src/cli/companion-timeline.ts +142 -0
- package/src/cli/companion-usage.ts +146 -0
- package/src/cli/companion.ts +12 -3
- package/src/cli/config-command.ts +24 -8
- package/src/cli/dispatch.ts +52 -79
- package/src/cli/doctor.ts +22 -4
- package/src/cli/help-catalog.ts +31 -14
- package/src/cli/help-recovery.ts +2 -2
- package/src/cli/help.ts +25 -11
- package/src/cli/index.ts +38 -27
- package/src/cli/injection-follow.ts +35 -0
- package/src/cli/inspect.ts +5 -0
- package/src/cli/integration-aside-sync.ts +69 -0
- package/src/cli/integration-cursor.ts +106 -0
- package/src/cli/integration-input.ts +60 -0
- package/src/cli/integration-journal.ts +49 -0
- package/src/cli/integration-plan-dto.ts +156 -0
- package/src/cli/integration-preview.ts +215 -0
- package/src/cli/integrations.ts +24 -18
- package/src/cli/json-input.ts +134 -0
- package/src/cli/link.ts +32 -4
- package/src/cli/local-sync-result.ts +41 -0
- package/src/cli/log-follow.ts +84 -0
- package/src/cli/log-view-filter.ts +146 -0
- package/src/cli/logout-command.ts +44 -0
- package/src/cli/model-picker-ordering.ts +137 -0
- package/src/cli/models-custom-input.ts +107 -0
- package/src/cli/models-custom-runtime.ts +112 -0
- package/src/cli/models-order.ts +194 -0
- package/src/cli/models-runtime-subcommands.ts +2 -0
- package/src/cli/models-runtime.ts +7 -0
- package/src/cli/models.ts +66 -81
- package/src/cli/observe-stream.ts +142 -0
- package/src/cli/observe.ts +75 -34
- package/src/cli/provider-batch.ts +77 -0
- package/src/cli/provider-lifecycle-runtime.ts +170 -0
- package/src/cli/provider-result.ts +124 -0
- package/src/cli/provider-runtime.ts +15 -12
- package/src/cli/provider-settings.ts +143 -0
- package/src/cli/provider.ts +112 -34
- package/src/cli/registry.ts +62 -20
- package/src/cli/remote-workspace-hub.ts +83 -0
- package/src/cli/remote-workspace.ts +6 -1
- package/src/cli/restart-failure.ts +27 -0
- package/src/cli/root.ts +12 -0
- package/src/cli/route-policy-write.ts +137 -0
- package/src/cli/route-policy.ts +8 -0
- package/src/cli/runtime-api.ts +56 -79
- package/src/cli/secret-args.ts +149 -0
- package/src/cli/settings-result.ts +30 -0
- package/src/cli/storage.ts +77 -7
- package/src/cli/system-command.ts +60 -2
- package/src/cli/system-restart-client.ts +20 -3
- package/src/cli/system-settings-parity.ts +83 -0
- package/src/cli/update-restart-candidate.ts +21 -0
- package/src/cli/update-restart-child.ts +81 -0
- package/src/cli/update-restart-home.ts +38 -0
- package/src/cli/update-restart-transport.ts +193 -0
- package/src/cli/update-restart.ts +260 -0
- package/src/cli/usage-model-search.ts +58 -0
- package/src/cli/usage-report.ts +13 -5
- package/src/cli/v2-input.ts +66 -0
- package/src/cli/v2-local-output.ts +37 -0
- package/src/cli/v2-runtime.ts +90 -0
- package/src/cli/v2.ts +102 -144
- package/src/clients/config-export/dsh.ts +21 -0
- package/src/clients/config-export.ts +7 -1
- package/src/codex/catalog/build-entries.ts +1 -1
- package/src/codex/catalog/bundled.ts +9 -2
- package/src/codex/catalog/heal-observation.ts +66 -0
- package/src/codex/catalog/publication-observer.ts +19 -0
- package/src/codex/catalog/remote.ts +13 -15
- package/src/codex/catalog/restore.ts +19 -6
- package/src/codex/catalog/retained-sync.ts +58 -10
- package/src/codex/catalog/routed-removal.ts +116 -0
- package/src/codex/catalog/write-audit-contract.ts +21 -0
- package/src/codex/catalog/write-audit.ts +171 -0
- package/src/codex/catalog-self-heal.ts +284 -0
- package/src/codex/catalog-write-serialization.ts +71 -7
- package/src/codex/codex-home-owner.ts +124 -0
- package/src/codex/convergence.ts +55 -15
- package/src/codex/features.ts +8 -1
- package/src/codex/inject/restore.ts +46 -4
- package/src/codex/inject.ts +11 -1
- package/src/codex/internal/catalog-writer.ts +120 -13
- package/src/codex/journal.ts +49 -13
- package/src/codex/management-convergence.ts +3 -0
- package/src/codex/quota-types.ts +3 -0
- package/src/codex/quota.ts +13 -3
- package/src/codex/refresh.ts +20 -9
- package/src/codex/runtime.ts +7 -1
- package/src/codex/shim-diagnostics.ts +80 -0
- package/src/codex/shim-fingerprint.ts +7 -2
- package/src/codex/shim-migration.ts +153 -0
- package/src/codex/shim-overlay.ts +509 -0
- package/src/codex/shim-path-resolution.ts +14 -2
- package/src/codex/shim-probe.ts +4 -3
- package/src/codex/shim-state-file.ts +93 -7
- package/src/codex/shim.ts +15 -68
- package/src/codex/sync.ts +24 -4
- package/src/combos/failover.ts +19 -0
- package/src/combos/index.ts +1 -0
- package/src/combos/resolve.ts +12 -0
- package/src/config/atomic-write.ts +13 -8
- package/src/config/diagnostics.ts +19 -1
- package/src/config/load-degrade.ts +3 -0
- package/src/config/schema/chatgpt-desktop.ts +22 -0
- package/src/config/schema/config-schema.ts +32 -14
- package/src/config/schema/leaf-validators.ts +3 -2
- package/src/generated/compatibility-version.json +445 -129
- package/src/integrations/merge.ts +13 -5
- package/src/integrations/mutation-plan.ts +46 -3
- package/src/integrations/omp-yaml-source.ts +181 -1
- package/src/integrations/ownership-policy.ts +66 -0
- package/src/integrations/registry.ts +50 -1
- package/src/integrations/state.ts +46 -21
- package/src/integrations/target.ts +25 -2
- package/src/integrations/writer.ts +89 -31
- package/src/lib/package-tree-integrity.ts +42 -1
- package/src/lib/proxy-env.ts +3 -0
- package/src/lib/test-home-guard.ts +2 -1
- package/src/oauth/anthropic-account-refusal.ts +19 -8
- package/src/oauth/anthropic-routing.ts +17 -12
- package/src/oauth/anthropic.ts +27 -2
- package/src/oauth/index.ts +6 -8
- package/src/oauth/pool-settings-capability.ts +9 -2
- package/src/protocols/plan-snapshot.ts +1 -1
- package/src/protocols/settings.ts +28 -4
- package/src/server/caller-session-identity.ts +32 -0
- package/src/server/claude-messages.ts +11 -4
- package/src/server/index/serve-options.ts +12 -7
- package/src/server/index.ts +1 -1
- package/src/server/management/agent-settings-routes.ts +6 -5
- package/src/server/management/context.ts +8 -1
- package/src/server/management/cursor-integration-routes.ts +3 -3
- package/src/server/management/integration-routes.ts +5 -5
- package/src/server/management/model-routes.ts +3 -3
- package/src/server/management/native-integration-routes.ts +2 -2
- package/src/server/management/oauth-account-routes.ts +96 -30
- package/src/server/management/route-registry.ts +25 -24
- package/src/server/messages-native-eligibility.ts +3 -6
- package/src/server/messages-native-oauth.ts +82 -35
- package/src/server/messages-native.ts +78 -16
- package/src/server/responses/adapter-dispatch.ts +53 -28
- package/src/server/responses/core-auth.ts +10 -4
- package/src/server/responses/core-combo.ts +46 -6
- package/src/service/cli.ts +6 -1
- package/src/service/ownership-mutation-lease.d.mts +26 -0
- package/src/service/ownership-mutation-lease.mjs +76 -1
- package/src/types/config.ts +2 -0
- package/src/types/provider.ts +10 -10
- package/src/update/index.ts +2 -1
- package/src/update/update-failure-guidance.d.mts +6 -0
- package/src/update/update-failure-guidance.mjs +30 -5
- package/gui/dist/assets/App-COdEeqSj.css +0 -1
- package/gui/dist/assets/App-DEpCJEP_.js +0 -54
- package/gui/dist/assets/index-Cd7ittMv.css +0 -1
- package/gui/dist/assets/index-DwJKFJm5.js +0 -94
- package/gui/dist/assets/quota-summary-DGcEJU3h.js +0 -1
|
@@ -71,7 +71,7 @@ interface PendingToolCall {
|
|
|
71
71
|
namespace?: string;
|
|
72
72
|
wireName: string;
|
|
73
73
|
order: number;
|
|
74
|
-
|
|
74
|
+
results: Array<OcxMessage & { role: "toolResult" }>;
|
|
75
75
|
}
|
|
76
76
|
|
|
77
77
|
interface PendingToolBatch {
|
|
@@ -307,7 +307,7 @@ function assistantTextThinkingAndCalls(message: OcxAssistantMessage): {
|
|
|
307
307
|
* Build native replay messages without mutating the parsed history. Keep tool results
|
|
308
308
|
* beside their originating batch, deferring intervening conversation until settlement.
|
|
309
309
|
* Reserve replayed call IDs for response translation and mark missing results explicitly.
|
|
310
|
-
* @throws When call IDs are invalid or results are orphaned
|
|
310
|
+
* @throws When call IDs are invalid or results are orphaned or mismatched.
|
|
311
311
|
*/
|
|
312
312
|
function buildNativeMessages(
|
|
313
313
|
parsed: OcxParsedRequest,
|
|
@@ -324,6 +324,7 @@ function buildNativeMessages(
|
|
|
324
324
|
// owned by this adapter/request lifecycle rather than process-global state.
|
|
325
325
|
reservedToolCallIds.clear();
|
|
326
326
|
let pending: PendingToolBatch | undefined;
|
|
327
|
+
const issuedCalls = new Map<string, PendingToolCall>();
|
|
327
328
|
// Codex records mid-turn injections (a PostToolUse hook verdict, a context notice) between an
|
|
328
329
|
// assistant tool call and that call's own tool result. Native Ollama needs the call and its
|
|
329
330
|
// results adjacent, so those conversational messages wait here instead of closing the batch
|
|
@@ -341,10 +342,9 @@ function buildNativeMessages(
|
|
|
341
342
|
const flushPending = (): void => {
|
|
342
343
|
if (!pending) return;
|
|
343
344
|
for (const call of pending.calls) {
|
|
344
|
-
if (
|
|
345
|
-
// No result
|
|
346
|
-
//
|
|
347
|
-
// the conversation replayable.
|
|
345
|
+
if (call.results.length === 0) {
|
|
346
|
+
// No result was available when this batch settled. A later notification is carried
|
|
347
|
+
// separately, not used to invent an outcome at this earlier conversation boundary.
|
|
348
348
|
messages.push({
|
|
349
349
|
role: "tool",
|
|
350
350
|
tool_call_id: call.id,
|
|
@@ -356,13 +356,18 @@ function buildNativeMessages(
|
|
|
356
356
|
});
|
|
357
357
|
continue;
|
|
358
358
|
}
|
|
359
|
-
|
|
359
|
+
// A code-mode cell can yield, then notify repeatedly. Join once at settlement rather
|
|
360
|
+
// than repeatedly copying an ever-growing result, and never mutate the source history.
|
|
361
|
+
const fragments = call.results.map(result => ({
|
|
362
|
+
...contentToNative(result.content, "tool result"), isError: result.isError,
|
|
363
|
+
}));
|
|
364
|
+
const images = fragments.flatMap(fragment => fragment.images ?? []);
|
|
360
365
|
messages.push({
|
|
361
366
|
role: "tool",
|
|
362
367
|
tool_call_id: call.id,
|
|
363
368
|
tool_name: call.wireName,
|
|
364
|
-
content:
|
|
365
|
-
...(
|
|
369
|
+
content: fragments.map(fragment => `${fragment.isError ? "ERROR: " : ""}${fragment.content}`).join("\n"),
|
|
370
|
+
...(images.length ? { images } : {}),
|
|
366
371
|
});
|
|
367
372
|
}
|
|
368
373
|
pending = undefined;
|
|
@@ -371,21 +376,30 @@ function buildNativeMessages(
|
|
|
371
376
|
|
|
372
377
|
for (const message of parsed.context.messages) {
|
|
373
378
|
if (message.role === "toolResult") {
|
|
374
|
-
|
|
375
|
-
throw new Error(`ollama-native orphan tool result ${message.toolCallId || "<missing-id>"}`);
|
|
376
|
-
}
|
|
377
|
-
const call = pending.byId.get(message.toolCallId);
|
|
379
|
+
const call = issuedCalls.get(message.toolCallId);
|
|
378
380
|
if (!call) {
|
|
379
|
-
throw new Error(
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
throw new Error(`ollama-native duplicate tool result for ${message.toolCallId}`);
|
|
381
|
+
throw new Error(pending
|
|
382
|
+
? `ollama-native tool result ${message.toolCallId || "<missing-id>"} has no originating call`
|
|
383
|
+
: `ollama-native orphan tool result ${message.toolCallId || "<missing-id>"}`);
|
|
383
384
|
}
|
|
384
385
|
if (call.name !== message.toolName || call.namespace !== message.toolNamespace) {
|
|
385
386
|
throw new Error(`ollama-native tool result ${message.toolCallId} names the wrong originating tool`);
|
|
386
387
|
}
|
|
387
|
-
call.
|
|
388
|
-
|
|
388
|
+
if (pending?.byId.has(call.id)) {
|
|
389
|
+
if (call.results.length === 0) pending.unresolvedCount--;
|
|
390
|
+
call.results.push(message);
|
|
391
|
+
} else {
|
|
392
|
+
// Reopening a settled batch would split a later call/result pair. Keep known late
|
|
393
|
+
// output explicitly attributed as conversation text instead of dropping it or
|
|
394
|
+
// fabricating another executable call. Unknown/mismatched identities still fail above.
|
|
395
|
+
const translated = contentToNative(message.content, "late tool result");
|
|
396
|
+
const late: OllamaNativeMessage = { role: "user",
|
|
397
|
+
content: `[ocx] additional output for previously issued tool "${call.wireName}" (${call.id}):\n${message.isError ? "ERROR: " : ""}${translated.content}`,
|
|
398
|
+
...(translated.images ? { images: translated.images } : {}),
|
|
399
|
+
};
|
|
400
|
+
if (pending) deferred.push(late);
|
|
401
|
+
else messages.push(late);
|
|
402
|
+
}
|
|
389
403
|
continue;
|
|
390
404
|
}
|
|
391
405
|
|
|
@@ -450,8 +464,10 @@ function buildNativeMessages(
|
|
|
450
464
|
namespace: call.namespace,
|
|
451
465
|
wireName,
|
|
452
466
|
order: index,
|
|
467
|
+
results: [],
|
|
453
468
|
};
|
|
454
469
|
wireCalls.push(pendingCall);
|
|
470
|
+
issuedCalls.set(call.id, pendingCall);
|
|
455
471
|
return {
|
|
456
472
|
type: "function" as const,
|
|
457
473
|
id: call.id,
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads the ALPN offer from a TLS ClientHello without terminating TLS.
|
|
3
|
+
*
|
|
4
|
+
* The picker listener uses it to send a client that offers `h2` to its HTTP/2 server and every
|
|
5
|
+
* other client to its HTTP/1.1 server. The ClientHello is reassembled from as many plaintext
|
|
6
|
+
* handshake records as it spans (RFC 8446 5.1 allows any split), within fixed byte and record
|
|
7
|
+
* limits. This is a routing hint, not validation: parsing stops at the ALPN decision, and the TLS
|
|
8
|
+
* server that receives the replayed bytes validates the handshake. Anything the parser cannot read
|
|
9
|
+
* up to that decision returns false, which keeps that client on HTTP/1.1, the behaviour before
|
|
10
|
+
* HTTP/2 existed here.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Wire bytes the front reads before deciding; a real ClientHello is far smaller. */
|
|
14
|
+
export const CLIENT_HELLO_MAX_BYTES = 64 * 1024;
|
|
15
|
+
/** Handshake records one ClientHello may span. */
|
|
16
|
+
export const CLIENT_HELLO_MAX_RECORDS = 16;
|
|
17
|
+
|
|
18
|
+
const RECORD_HEADER_BYTES = 5;
|
|
19
|
+
const RECORD_HANDSHAKE = 0x16;
|
|
20
|
+
const MAX_RECORD_PAYLOAD = 16_384;
|
|
21
|
+
const HANDSHAKE_CLIENT_HELLO = 0x01;
|
|
22
|
+
const EXTENSION_ALPN = 0x0010;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Whether the ClientHello at the start of `data` offers `h2` in ALPN. Returns null while more
|
|
26
|
+
* bytes are needed to decide, and false for anything else (not TLS, malformed, over a limit).
|
|
27
|
+
*/
|
|
28
|
+
export function clientHelloOffersH2(data: Uint8Array): boolean | null {
|
|
29
|
+
const handshake: Uint8Array[] = [];
|
|
30
|
+
let handshakeBytes = 0;
|
|
31
|
+
let at = 0;
|
|
32
|
+
for (let records = 0; ; records++) {
|
|
33
|
+
if (handshakeBytes >= 4) {
|
|
34
|
+
const first = concat(handshake, 4);
|
|
35
|
+
if (first[0] !== HANDSHAKE_CLIENT_HELLO) return false;
|
|
36
|
+
const needed = 4 + ((first[1]! << 16) | (first[2]! << 8) | first[3]!);
|
|
37
|
+
if (needed > CLIENT_HELLO_MAX_BYTES) return false;
|
|
38
|
+
if (handshakeBytes >= needed) return helloOffersH2(concat(handshake, needed).subarray(4));
|
|
39
|
+
}
|
|
40
|
+
if (records >= CLIENT_HELLO_MAX_RECORDS || at >= CLIENT_HELLO_MAX_BYTES) return false;
|
|
41
|
+
if (data.length < at + RECORD_HEADER_BYTES) return null;
|
|
42
|
+
if (data[at] !== RECORD_HANDSHAKE || data[at + 1] !== 0x03) return false;
|
|
43
|
+
const length = (data[at + 3]! << 8) | data[at + 4]!;
|
|
44
|
+
if (length === 0 || length > MAX_RECORD_PAYLOAD) return false;
|
|
45
|
+
const end = at + RECORD_HEADER_BYTES + length;
|
|
46
|
+
if (end > CLIENT_HELLO_MAX_BYTES) return false;
|
|
47
|
+
if (data.length < end) return null;
|
|
48
|
+
handshake.push(data.subarray(at + RECORD_HEADER_BYTES, end));
|
|
49
|
+
handshakeBytes += length;
|
|
50
|
+
at = end;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function concat(parts: readonly Uint8Array[], bytes: number): Uint8Array {
|
|
55
|
+
const out = new Uint8Array(bytes);
|
|
56
|
+
let offset = 0;
|
|
57
|
+
for (const part of parts) {
|
|
58
|
+
if (offset >= bytes) break;
|
|
59
|
+
const take = part.subarray(0, bytes - offset);
|
|
60
|
+
out.set(take, offset);
|
|
61
|
+
offset += take.length;
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function helloOffersH2(hello: Uint8Array): boolean {
|
|
67
|
+
let at = 2 + 32; // legacy_version, random
|
|
68
|
+
const skip = (lengthBytes: 1 | 2): boolean => {
|
|
69
|
+
if (at + lengthBytes > hello.length) return false;
|
|
70
|
+
const length = lengthBytes === 1 ? hello[at]! : (hello[at]! << 8) | hello[at + 1]!;
|
|
71
|
+
at += lengthBytes + length;
|
|
72
|
+
return at <= hello.length;
|
|
73
|
+
};
|
|
74
|
+
if (!skip(1) || !skip(2) || !skip(1)) return false; // session id, cipher suites, compression
|
|
75
|
+
if (at + 2 > hello.length) return false; // no extensions block: no ALPN
|
|
76
|
+
const extensionsEnd = at + 2 + ((hello[at]! << 8) | hello[at + 1]!);
|
|
77
|
+
if (extensionsEnd > hello.length) return false;
|
|
78
|
+
at += 2;
|
|
79
|
+
// Unknown and GREASE extensions are skipped by their declared length, in any order.
|
|
80
|
+
while (at + 4 <= extensionsEnd) {
|
|
81
|
+
const type = (hello[at]! << 8) | hello[at + 1]!;
|
|
82
|
+
const length = (hello[at + 2]! << 8) | hello[at + 3]!;
|
|
83
|
+
const body = at + 4;
|
|
84
|
+
if (body + length > extensionsEnd) return false;
|
|
85
|
+
if (type === EXTENSION_ALPN) return alpnListOffersH2(hello.subarray(body, body + length));
|
|
86
|
+
at = body + length;
|
|
87
|
+
}
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function alpnListOffersH2(extension: Uint8Array): boolean {
|
|
92
|
+
if (extension.length < 2) return false;
|
|
93
|
+
const end = 2 + ((extension[0]! << 8) | extension[1]!);
|
|
94
|
+
if (end > extension.length) return false;
|
|
95
|
+
for (let at = 2; at < end;) {
|
|
96
|
+
const length = extension[at]!;
|
|
97
|
+
const name = at + 1;
|
|
98
|
+
if (length === 0 || name + length > end) return false;
|
|
99
|
+
if (length === 2 && extension[name] === 0x68 && extension[name + 1] === 0x32) return true; // "h2"
|
|
100
|
+
at = name + length;
|
|
101
|
+
}
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
@@ -113,7 +113,7 @@ export function parseInterceptBindingPatch(body: unknown): InterceptBindingPatch
|
|
|
113
113
|
if (!set || typeof set !== "object" || Array.isArray(set)) return { error: "set must be an object of picker id to route" };
|
|
114
114
|
const entries: Record<string, string> = {};
|
|
115
115
|
for (const [id, route] of Object.entries(set as Record<string, unknown>)) {
|
|
116
|
-
if (!isInterceptBindingId(id)) return { error:
|
|
116
|
+
if (!isInterceptBindingId(id)) return { error: "invalid picker id (expected a claude- model id)" };
|
|
117
117
|
if (!isInterceptBindingRoute(route)) return { error: `invalid route for ${id}` };
|
|
118
118
|
entries[id] = route;
|
|
119
119
|
}
|
|
@@ -137,7 +137,8 @@ export function applyInterceptBindingPatch(
|
|
|
137
137
|
): InterceptBindingPatchResult {
|
|
138
138
|
const next: Record<string, string> = { ...current };
|
|
139
139
|
for (const [id, route] of Object.entries(patch.set ?? {})) {
|
|
140
|
-
|
|
140
|
+
// A rejected route is not echoed: whatever sits in that position may be a misplaced credential.
|
|
141
|
+
if (!availableRoutes.has(route)) return { ok: false, error: "route is not available (see ocx claude desktop show)" };
|
|
141
142
|
next[id] = route;
|
|
142
143
|
}
|
|
143
144
|
for (const id of patch.remove ?? []) delete next[id];
|
|
@@ -1,11 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* TLS terminator for claude.ai.
|
|
3
|
+
*
|
|
4
|
+
* The listener port is a plain TCP front that reads each connection's TLS ClientHello and splices
|
|
5
|
+
* it, unterminated, to one of two loopback servers. A client offering `h2` (Chromium's ordinary
|
|
6
|
+
* requests) reaches an HTTP/2 server, so Desktop multiplexes every claude.ai request over one
|
|
7
|
+
* connection as it does against Anthropic's own edge. When this listener spoke only HTTP/1.1,
|
|
8
|
+
* Desktop's long-lived SSE subscriptions took all six of Chromium's per-origin connections and every
|
|
9
|
+
* later claude.ai request queued in the client until it timed out (#6511). Everything else (no ALPN,
|
|
10
|
+
* HTTP/1.1 only, WebSocket connections, a ClientHello the front cannot read) reaches the native
|
|
11
|
+
* HTTP/1.1 server, unchanged. Upstream is one HTTP/1.1 request per client request on both paths.
|
|
12
|
+
* Only the bounded bootstrap response is held; other bodies and upgraded sockets relay as streams.
|
|
4
13
|
*/
|
|
14
|
+
import { createSecureServer } from "node:http2";
|
|
15
|
+
import type { Http2ServerRequest, Http2ServerResponse, ServerHttp2Session } from "node:http2";
|
|
5
16
|
import { createServer, request as httpsRequest } from "node:https";
|
|
6
17
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
18
|
+
import { createServer as createTcpServer, connect as tcpConnect, type Server as TcpServer, type Socket } from "node:net";
|
|
7
19
|
import { connect as tlsConnect } from "node:tls";
|
|
8
20
|
import type { Duplex } from "node:stream";
|
|
21
|
+
import { clientHelloOffersH2 } from "./client-hello";
|
|
9
22
|
import type { PemKeyPair } from "./local-ca";
|
|
10
23
|
import {
|
|
11
24
|
BOOTSTRAP_MAX_ENCODED_BYTES, isPickerBootstrapRequest, narrowBootstrapAcceptEncoding,
|
|
@@ -19,15 +32,45 @@ export interface PickerListenerOptions {
|
|
|
19
32
|
upstream?: { host: string; port: number; servername: string; ca?: string };
|
|
20
33
|
/** Test seam: encoded bootstrap cap; production uses BOOTSTRAP_MAX_ENCODED_BYTES. */
|
|
21
34
|
maxEncodedBytes?: number;
|
|
35
|
+
/** Test seam: concurrent upstream requests; production uses PICKER_MAX_ACTIVE_UPSTREAMS. */
|
|
36
|
+
maxActiveUpstreams?: number;
|
|
22
37
|
log?: (line: string) => void;
|
|
23
38
|
}
|
|
24
39
|
export interface PickerListenerHandle { port: number; close(): Promise<void> }
|
|
25
40
|
|
|
41
|
+
// Browser session cookies can exceed the HTTP compatibility layer's 16 KiB default.
|
|
42
|
+
// Keep both sides bounded, while allowing ordinary desktop session headers through.
|
|
43
|
+
export const PICKER_MAX_HEADER_BYTES = 64 * 1024;
|
|
44
|
+
/**
|
|
45
|
+
* Upstream requests in flight across the listener. HTTP/2 lets one connection open many streams,
|
|
46
|
+
* and each relays as its own upstream connection, so the budget caps that fan-out well above what
|
|
47
|
+
* Desktop uses (a handful of SSE subscriptions plus bursts of ordinary calls).
|
|
48
|
+
*/
|
|
49
|
+
export const PICKER_MAX_ACTIVE_UPSTREAMS = 256;
|
|
50
|
+
/** Advertised per HTTP/2 connection; Chromium queues further requests rather than failing them. */
|
|
51
|
+
export const PICKER_MAX_CONCURRENT_STREAMS = 100;
|
|
52
|
+
/** A connection that has not sent a complete ClientHello by then is dropped. */
|
|
53
|
+
const CLIENT_HELLO_TIMEOUT_MS = 10_000;
|
|
54
|
+
|
|
26
55
|
const HOP_HEADERS = new Set([
|
|
27
56
|
"connection", "keep-alive", "proxy-connection", "proxy-authenticate", "proxy-authorization",
|
|
28
57
|
"te", "trailer", "transfer-encoding", "upgrade",
|
|
29
58
|
]);
|
|
30
59
|
|
|
60
|
+
type RelayRequest = IncomingMessage | Http2ServerRequest;
|
|
61
|
+
type RelayResponse = ServerResponse | Http2ServerResponse;
|
|
62
|
+
|
|
63
|
+
const METHOD_TOKEN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
|
|
64
|
+
const ORIGIN_FORM_TARGET = /^\/[\x21-\x7e]*$/;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* HTTP/2 carries method and path as header values, which Bun accepts more loosely than an
|
|
68
|
+
* HTTP/1.1 request line can express. Only a method token and an origin-form target relay.
|
|
69
|
+
*/
|
|
70
|
+
export function isRelayableH2Target(method: string | undefined, path: string | undefined): boolean {
|
|
71
|
+
return method !== undefined && path !== undefined && METHOD_TOKEN.test(method) && ORIGIN_FORM_TARGET.test(path);
|
|
72
|
+
}
|
|
73
|
+
|
|
31
74
|
function filteredHeaders(raw: readonly string[], omit: ReadonlySet<string> = new Set()): string[] {
|
|
32
75
|
const named = new Set<string>();
|
|
33
76
|
for (let i = 0; i + 1 < raw.length; i += 2) {
|
|
@@ -61,15 +104,98 @@ function contentEncoding(raw: readonly string[]): string | undefined {
|
|
|
61
104
|
return undefined;
|
|
62
105
|
}
|
|
63
106
|
|
|
107
|
+
/**
|
|
108
|
+
* Request headers for the HTTP/1.1 upstream. An HTTP/2 request has pseudo-headers instead of a
|
|
109
|
+
* request line, names its origin in `:authority` (which replaces any Host, RFC 9113 8.3.1) and
|
|
110
|
+
* may split Cookie into crumbs (8.2.3); HTTP/1.1 needs one Host and one Cookie header.
|
|
111
|
+
*/
|
|
112
|
+
function upstreamRequestHeaders(req: RelayRequest, omit: ReadonlySet<string>): string[] {
|
|
113
|
+
if (req.httpVersionMajor !== 2) return filteredHeaders(req.rawHeaders, omit);
|
|
114
|
+
const rest: string[] = [];
|
|
115
|
+
const cookies: string[] = [];
|
|
116
|
+
let authority: string | undefined;
|
|
117
|
+
let host: string | undefined;
|
|
118
|
+
for (let i = 0; i + 1 < req.rawHeaders.length; i += 2) {
|
|
119
|
+
const name = req.rawHeaders[i]!.toLowerCase();
|
|
120
|
+
const value = req.rawHeaders[i + 1]!;
|
|
121
|
+
if (name === ":authority") authority = value;
|
|
122
|
+
else if (name === "host") host = value;
|
|
123
|
+
else if (name === "cookie") cookies.push(value);
|
|
124
|
+
else if (!name.startsWith(":")) rest.push(req.rawHeaders[i]!, value);
|
|
125
|
+
}
|
|
126
|
+
const headers = filteredHeaders(rest, omit);
|
|
127
|
+
headers.unshift("Host", authority ?? host ?? "claude.ai");
|
|
128
|
+
if (cookies.length > 0) headers.push("Cookie", cookies.join("; "));
|
|
129
|
+
return headers;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function listenLoopback(server: TcpServer): Promise<number> {
|
|
133
|
+
return new Promise((resolve, reject) => {
|
|
134
|
+
server.once("error", reject);
|
|
135
|
+
server.listen(0, "127.0.0.1", () => {
|
|
136
|
+
server.off("error", reject);
|
|
137
|
+
const address = server.address();
|
|
138
|
+
if (!address || typeof address === "string") reject(new Error("picker listener has no port"));
|
|
139
|
+
else resolve(address.port);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function closeServer(server: TcpServer): Promise<void> {
|
|
145
|
+
return new Promise(resolve => {
|
|
146
|
+
if (!server.listening) { resolve(); return; }
|
|
147
|
+
server.close(() => resolve());
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
64
151
|
export async function startPickerListener(options: PickerListenerOptions): Promise<PickerListenerHandle> {
|
|
65
152
|
const upstream = options.upstream ?? { host: "claude.ai", port: 443, servername: "claude.ai" };
|
|
66
153
|
const cap = options.maxEncodedBytes ?? BOOTSTRAP_MAX_ENCODED_BYTES;
|
|
154
|
+
const maxActiveUpstreams = options.maxActiveUpstreams ?? PICKER_MAX_ACTIVE_UPSTREAMS;
|
|
155
|
+
let activeUpstreams = 0;
|
|
67
156
|
const upgrades = new Set<Duplex>();
|
|
68
|
-
const
|
|
157
|
+
const spliced = new Set<Socket>();
|
|
158
|
+
const h2Sockets = new Set<Duplex>();
|
|
159
|
+
const sessions = new Set<ServerHttp2Session>();
|
|
160
|
+
const server = createServer({ cert: options.leaf.certPem, key: options.leaf.keyPem, ALPNProtocols: ["http/1.1"], maxHeaderSize: PICKER_MAX_HEADER_BYTES });
|
|
161
|
+
// ALPN h2 only; no enableConnectProtocol, so Chromium opens WebSockets as HTTP/1.1 connections,
|
|
162
|
+
// which the front sends to `server`. An over-budget header block is refused natively per stream
|
|
163
|
+
// (Bun counts name + value + 32 bytes per field) before the request handler runs.
|
|
164
|
+
const h2Server = createSecureServer({
|
|
165
|
+
cert: options.leaf.certPem, key: options.leaf.keyPem,
|
|
166
|
+
settings: { maxHeaderListSize: PICKER_MAX_HEADER_BYTES, maxConcurrentStreams: PICKER_MAX_CONCURRENT_STREAMS },
|
|
167
|
+
});
|
|
168
|
+
// Track sockets from accept (a handshake that never completes too) and after TLS, so a forced
|
|
169
|
+
// shutdown reaches every one of them.
|
|
170
|
+
for (const event of ["connection", "secureConnection"] as const) {
|
|
171
|
+
h2Server.on(event, (socket: Duplex) => {
|
|
172
|
+
h2Sockets.add(socket);
|
|
173
|
+
socket.once("close", () => h2Sockets.delete(socket));
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
h2Server.on("session", (session: ServerHttp2Session) => {
|
|
177
|
+
sessions.add(session);
|
|
178
|
+
session.once("close", () => sessions.delete(session));
|
|
179
|
+
options.log?.("picker session h2");
|
|
180
|
+
});
|
|
69
181
|
|
|
70
|
-
|
|
182
|
+
const relay = (req: RelayRequest, relayRes: RelayResponse) => {
|
|
183
|
+
// The HTTP/2 compat response has every ServerResponse member used here; only writeHead's
|
|
184
|
+
// reason phrase differs, and sendHead branches on it.
|
|
185
|
+
const res = relayRes as ServerResponse;
|
|
186
|
+
const h2 = req.httpVersionMajor === 2;
|
|
187
|
+
// Refusals answer with an empty response and a fixed log line that carries no request data.
|
|
188
|
+
const refuse = (status: 400 | 503) => {
|
|
189
|
+
res.writeHead(status, { "Content-Length": "0" });
|
|
190
|
+
res.end();
|
|
191
|
+
options.log?.(`picker request refused ${status}`);
|
|
192
|
+
};
|
|
193
|
+
if (h2 && !isRelayableH2Target(req.method, req.url)) { refuse(400); return; }
|
|
194
|
+
if (activeUpstreams >= maxActiveUpstreams) { refuse(503); return; }
|
|
71
195
|
const method = req.method ?? "GET";
|
|
72
|
-
|
|
196
|
+
let pathname: string;
|
|
197
|
+
// A target like "//[" reads as an authority and throws; refuse it like any unrelayable target.
|
|
198
|
+
try { pathname = new URL(req.url ?? "/", "https://claude.ai").pathname; } catch { refuse(400); return; }
|
|
73
199
|
const bootstrap = isPickerBootstrapRequest(method, pathname);
|
|
74
200
|
const category = bootstrap ? "bootstrap" : "other";
|
|
75
201
|
let logged = false;
|
|
@@ -77,66 +203,100 @@ export async function startPickerListener(options: PickerListenerOptions): Promi
|
|
|
77
203
|
if (!logged) options.log?.(`picker ${method} ${category} ${status}`);
|
|
78
204
|
logged = true;
|
|
79
205
|
};
|
|
206
|
+
// Set once the client side closes before the response finished: the upstream error that
|
|
207
|
+
// tearing it down raises is then expected, not a relay failure to answer or log.
|
|
208
|
+
let clientGone = false;
|
|
80
209
|
const fail = () => {
|
|
210
|
+
if (clientGone) return;
|
|
81
211
|
if (res.headersSent) res.destroy();
|
|
82
212
|
else { res.writeHead(502, { "Content-Length": "0" }); res.end(); log(502); }
|
|
83
213
|
};
|
|
84
214
|
const omit = bootstrap ? new Set(["accept-encoding"]) : new Set<string>();
|
|
85
|
-
const headers =
|
|
215
|
+
const headers = upstreamRequestHeaders(req, omit);
|
|
86
216
|
if (bootstrap) headers.push("Accept-Encoding", narrowBootstrapAcceptEncoding());
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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;
|
|
217
|
+
let upReq: ReturnType<typeof httpsRequest>;
|
|
218
|
+
try {
|
|
219
|
+
// The HTTP/1.1 client validates the method, path and header values synchronously.
|
|
220
|
+
upReq = httpsRequest({
|
|
221
|
+
host: upstream.host, port: upstream.port, servername: upstream.servername,
|
|
222
|
+
ca: upstream.ca, rejectUnauthorized: true, agent: false,
|
|
223
|
+
method, path: req.url, headers, maxHeaderSize: PICKER_MAX_HEADER_BYTES,
|
|
224
|
+
}, upRes => {
|
|
225
|
+
const status = upRes.statusCode ?? 502;
|
|
226
|
+
const originalHeaders = filteredHeaders(upRes.rawHeaders);
|
|
227
|
+
const sendHead = (raw: string[]) => {
|
|
228
|
+
if (res.headersSent) return;
|
|
229
|
+
// HTTP/2 has no reason phrase; its compat writeHead takes the same flat raw header array.
|
|
230
|
+
if (h2) (relayRes as Http2ServerResponse).writeHead(status, raw as unknown as Record<string, string>);
|
|
231
|
+
else res.writeHead(status, upRes.statusMessage, raw);
|
|
232
|
+
log(status);
|
|
233
|
+
};
|
|
234
|
+
upRes.on("error", fail);
|
|
235
|
+
if (!bootstrap || status !== 200 || !hasJsonContentType(upRes.rawHeaders)) {
|
|
113
236
|
sendHead(originalHeaders);
|
|
114
|
-
for (const part of held) res.write(part);
|
|
115
|
-
res.write(chunk);
|
|
116
237
|
upRes.pipe(res);
|
|
117
238
|
return;
|
|
118
239
|
}
|
|
119
|
-
held
|
|
120
|
-
size
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
240
|
+
const held: Buffer[] = [];
|
|
241
|
+
let size = 0;
|
|
242
|
+
let handedOff = false;
|
|
243
|
+
const onData = (chunk: Buffer) => {
|
|
244
|
+
if (size + chunk.length > cap) {
|
|
245
|
+
upRes.pause();
|
|
246
|
+
upRes.off("data", onData);
|
|
247
|
+
handedOff = true;
|
|
248
|
+
sendHead(originalHeaders);
|
|
249
|
+
for (const part of held) res.write(part);
|
|
250
|
+
res.write(chunk);
|
|
251
|
+
upRes.pipe(res);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
held.push(chunk);
|
|
255
|
+
size += chunk.length;
|
|
256
|
+
};
|
|
257
|
+
upRes.on("data", onData);
|
|
258
|
+
upRes.once("end", () => {
|
|
259
|
+
if (handedOff) return;
|
|
260
|
+
const original = Buffer.concat(held, size);
|
|
261
|
+
let outcome = "unchanged";
|
|
262
|
+
const rewritten = rewriteBootstrapBody(original, contentEncoding(upRes.rawHeaders), options.models(), result => {
|
|
263
|
+
outcome = result.kind === "rewritten" ? `rewritten(+${result.added})` : `unchanged:${result.reason}`;
|
|
264
|
+
});
|
|
265
|
+
sendHead(rewritten === null ? originalHeaders : rewrittenHeaders(originalHeaders, rewritten.length));
|
|
266
|
+
options.log?.(`picker ${method} bootstrap ${outcome}`);
|
|
267
|
+
res.end(rewritten ?? original);
|
|
129
268
|
});
|
|
130
|
-
sendHead(rewritten === null ? originalHeaders : rewrittenHeaders(originalHeaders, rewritten.length));
|
|
131
|
-
options.log?.(`picker ${method} bootstrap ${outcome}`);
|
|
132
|
-
res.end(rewritten ?? original);
|
|
133
269
|
});
|
|
270
|
+
} catch {
|
|
271
|
+
refuse(400);
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
// One budget slot per upstream request, released once by whichever end finishes first.
|
|
275
|
+
activeUpstreams++;
|
|
276
|
+
let released = false;
|
|
277
|
+
const release = () => { if (!released) { released = true; activeUpstreams--; } };
|
|
278
|
+
upReq.once("close", release);
|
|
279
|
+
res.once("close", release);
|
|
280
|
+
if (h2) (req as Http2ServerRequest).stream.once("close", release);
|
|
281
|
+
upReq.on("error", error => {
|
|
282
|
+
if ((error as NodeJS.ErrnoException).code === "HPE_HEADER_OVERFLOW") {
|
|
283
|
+
options.log?.(`picker ${method} ${category} upstream:headers-too-large`);
|
|
284
|
+
}
|
|
285
|
+
fail();
|
|
134
286
|
});
|
|
135
|
-
upReq.on("error", fail);
|
|
136
287
|
req.on("error", () => upReq.destroy());
|
|
137
|
-
|
|
288
|
+
const onClientClose = () => {
|
|
289
|
+
if (!res.writableEnded) clientGone = true;
|
|
290
|
+
upReq.destroy();
|
|
291
|
+
};
|
|
292
|
+
res.on("close", () => { if (!res.writableEnded) onClientClose(); });
|
|
293
|
+
// Bun's compat response skips its close event for a HEAD reset before end(); the stream's own
|
|
294
|
+
// close always fires. Destroying an upstream request that already completed is a no-op.
|
|
295
|
+
if (h2) (req as Http2ServerRequest).stream.once("close", onClientClose);
|
|
138
296
|
req.pipe(upReq);
|
|
139
|
-
}
|
|
297
|
+
};
|
|
298
|
+
server.on("request", relay);
|
|
299
|
+
h2Server.on("request", relay);
|
|
140
300
|
|
|
141
301
|
server.on("upgrade", (req, client, head) => {
|
|
142
302
|
const method = req.method ?? "GET";
|
|
@@ -190,24 +350,77 @@ export async function startPickerListener(options: PickerListenerOptions): Promi
|
|
|
190
350
|
client.once("close", () => { upgrades.delete(client); target.destroy(); });
|
|
191
351
|
});
|
|
192
352
|
|
|
353
|
+
let http1Port = 0;
|
|
354
|
+
let h2Port = 0;
|
|
355
|
+
// The front never terminates TLS: it reads the ClientHello, then splices the untouched bytes
|
|
356
|
+
// to the chosen server through a loopback socket, so both directions keep stream backpressure.
|
|
357
|
+
// Ownership: `spliced` holds every front and bridge socket from accept until close; close()
|
|
358
|
+
// destroys them, the h2 sessions and their TLS sockets, and the HTTP/1.1 server's connections.
|
|
359
|
+
let closing = false;
|
|
360
|
+
const front = createTcpServer(client => {
|
|
361
|
+
if (closing) { client.destroy(); return; }
|
|
362
|
+
spliced.add(client);
|
|
363
|
+
client.once("close", () => spliced.delete(client));
|
|
364
|
+
client.on("error", () => client.destroy());
|
|
365
|
+
// An absolute deadline: trickled bytes cannot hold an unfinished ClientHello open.
|
|
366
|
+
const deadline = setTimeout(() => client.destroy(), CLIENT_HELLO_TIMEOUT_MS);
|
|
367
|
+
client.once("close", () => clearTimeout(deadline));
|
|
368
|
+
let head: Buffer = Buffer.alloc(0);
|
|
369
|
+
const onData = (chunk: Buffer) => {
|
|
370
|
+
head = head.length === 0 ? chunk : Buffer.concat([head, chunk]);
|
|
371
|
+
const offersH2 = clientHelloOffersH2(head);
|
|
372
|
+
if (offersH2 === null) return;
|
|
373
|
+
client.off("data", onData);
|
|
374
|
+
// Paused, a FIN or later bytes wait in the readable buffer and follow `head` once piped.
|
|
375
|
+
client.pause();
|
|
376
|
+
clearTimeout(deadline);
|
|
377
|
+
const inner = tcpConnect({ host: "127.0.0.1", port: offersH2 ? h2Port : http1Port });
|
|
378
|
+
spliced.add(inner);
|
|
379
|
+
const teardown = () => { client.destroy(); inner.destroy(); };
|
|
380
|
+
inner.on("error", teardown);
|
|
381
|
+
client.on("error", teardown);
|
|
382
|
+
// pipe() forwards each FIN after the queued bytes; a side that closes without having ended
|
|
383
|
+
// its peer (reset, dial failure) tears the peer down instead of leaving it open.
|
|
384
|
+
inner.once("close", () => { spliced.delete(inner); if (!client.writableEnded) client.destroy(); });
|
|
385
|
+
client.once("close", () => { if (!inner.writableEnded) inner.destroy(); });
|
|
386
|
+
inner.once("connect", () => {
|
|
387
|
+
inner.setNoDelay(true);
|
|
388
|
+
client.setNoDelay(true);
|
|
389
|
+
// Every byte read during the peek (ClientHello and anything coalesced after it), once.
|
|
390
|
+
inner.write(head);
|
|
391
|
+
client.pipe(inner);
|
|
392
|
+
inner.pipe(client);
|
|
393
|
+
client.resume();
|
|
394
|
+
});
|
|
395
|
+
};
|
|
396
|
+
client.on("data", onData);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
const servers: TcpServer[] = [server, h2Server, front];
|
|
400
|
+
// One forced shutdown for close() and for a failed start, shared by concurrent callers: destroy
|
|
401
|
+
// every tracked socket and session (a graceful HTTP/2 close would wait on open SSE streams
|
|
402
|
+
// forever), force the HTTP/1.1 server's connections closed while its native handle still exists
|
|
403
|
+
// (Bun's close() drops it), then stop accepting and wait for the servers to report closed.
|
|
404
|
+
let stopped: Promise<void> | null = null;
|
|
405
|
+
const shutdown = () => {
|
|
406
|
+
stopped ??= (async () => {
|
|
407
|
+
closing = true;
|
|
408
|
+
for (const socket of upgrades) socket.destroy();
|
|
409
|
+
for (const socket of spliced) socket.destroy();
|
|
410
|
+
for (const session of sessions) session.destroy();
|
|
411
|
+
for (const socket of h2Sockets) socket.destroy();
|
|
412
|
+
server.closeAllConnections();
|
|
413
|
+
await Promise.all(servers.map(closeServer));
|
|
414
|
+
})();
|
|
415
|
+
return stopped;
|
|
416
|
+
};
|
|
193
417
|
try {
|
|
194
|
-
await
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
}
|
|
418
|
+
http1Port = await listenLoopback(server);
|
|
419
|
+
h2Port = await listenLoopback(h2Server);
|
|
420
|
+
const port = await listenLoopback(front);
|
|
421
|
+
return { port, close: shutdown };
|
|
198
422
|
} catch (error) {
|
|
199
|
-
|
|
423
|
+
await shutdown();
|
|
200
424
|
throw error;
|
|
201
425
|
}
|
|
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
426
|
}
|