@sellable/install 0.1.803 → 0.1.804
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/container/Dockerfile +4 -4
- package/container/README.md +1 -1
- package/lib/sellable-agent/fly-admin-image/Dockerfile +3 -1
- package/lib/sellable-agent/fly-admin-image/admin-runtime.mjs +19 -3
- package/lib/sellable-agent/fly-customer-image/Dockerfile +2 -2
- package/lib/sellable-agent/fly-customer-image/claude-code-runs-api.d.mts +23 -3
- package/lib/sellable-agent/fly-customer-image/claude-code-runs-api.mjs +197 -52
- package/lib/sellable-agent/fly-customer-image/customer-runtime.mjs +48 -22
- package/lib/sellable-agent/fly-customer-image/endpoint-model-selection.d.mts +1 -0
- package/lib/sellable-agent/fly-customer-image/endpoint-model-selection.mjs +21 -4
- package/lib/sellable-agent/fly-customer-image/hermes-runs-client.mjs +61 -5
- package/lib/sellable-agent/fly-customer-image/openai-runs-client.d.mts +3 -1
- package/lib/sellable-agent/fly-customer-image/openai-runs-client.mjs +4 -1
- package/lib/sellable-agent/fly-customer-image/public-endpoint-guard.d.mts +28 -0
- package/lib/sellable-agent/fly-customer-image/public-endpoint-guard.mjs +209 -0
- package/lib/sellable-agent/fly-customer-image/runs-api-connections.d.mts +1 -1
- package/lib/sellable-agent/fly-customer-image/runs-api-connections.mjs +97 -24
- package/lib/sellable-agent/fly-customer-image/runs-api-health-check.mjs +11 -42
- package/lib/sellable-agent/fly-customer-image/workspace-connection-client.d.mts +22 -0
- package/lib/sellable-agent/fly-customer-image/workspace-connection-client.mjs +98 -0
- package/lib/sellable-agent/fly-runtime-identity.mjs +8 -0
- package/lib/sellable-agent/host-bootstrap.mjs +1 -1
- package/lib/sellable-agent/host-worker.mjs +1 -1
- package/lib/sellable-agent/profile-materializer.mjs +1 -1
- package/lib/sellable-agent/provisioning-adapter.mjs +1 -1
- package/package.json +1 -1
|
@@ -131,7 +131,7 @@ const RUNTIME_GENERATION = Number(
|
|
|
131
131
|
const BOOT_SESSION_ID = randomUUID();
|
|
132
132
|
const HERMES_VERSION = process.env.SELLABLE_AGENT_HERMES_VERSION ?? "0.21.5";
|
|
133
133
|
const INSTALLER_PACKAGE =
|
|
134
|
-
process.env.SELLABLE_AGENT_INSTALLER_PACKAGE ?? "@sellable/install@0.1.
|
|
134
|
+
process.env.SELLABLE_AGENT_INSTALLER_PACKAGE ?? "@sellable/install@0.1.804";
|
|
135
135
|
const MCP_PACKAGE =
|
|
136
136
|
process.env.SELLABLE_AGENT_MCP_PACKAGE ?? "@sellable/mcp@0.1.957";
|
|
137
137
|
export const ENDPOINT_MAX_CONCURRENT_RUNS = 10;
|
|
@@ -1979,15 +1979,44 @@ export function createCustomerEndpointRelay(options = {}) {
|
|
|
1979
1979
|
const chained = chain.length > 1;
|
|
1980
1980
|
// A one-model run is a chain of one; no selection is the Agent default.
|
|
1981
1981
|
const steps = chain.length ? chain : [null];
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1982
|
+
// No selection is the Agent default, built here as before (the opener
|
|
1983
|
+
// stages use it). A chain opens each step inside the run loop instead.
|
|
1984
|
+
const runs = chain.length
|
|
1985
|
+
? null
|
|
1986
|
+
: await createRunsClient({ apiKeyFile, claim, activeRuntime });
|
|
1987
|
+
// A workspace connection step's key, released to this attempt only. Its
|
|
1988
|
+
// literal values are scrubbed from this attempt's error code: the
|
|
1989
|
+
// diagnostic patterns miss a hex key and an srt_ grant.
|
|
1990
|
+
const released = [];
|
|
1991
|
+
const fetchConnectionKey = async (connectionId, chainStep) => {
|
|
1992
|
+
const value = await getControlClient().fetchEndpointConnectionKey({
|
|
1993
|
+
...identity,
|
|
1994
|
+
schemaVersion: "sellable-agent-endpoint-connection-key/v1",
|
|
1995
|
+
connectionId,
|
|
1996
|
+
chainStep,
|
|
1997
|
+
});
|
|
1998
|
+
for (const secret of [value?.apiKey, value?.tools?.authorization])
|
|
1999
|
+
if (typeof secret === "string" && secret) released.push(secret);
|
|
2000
|
+
return value;
|
|
2001
|
+
};
|
|
2002
|
+
// A step whose client cannot be opened sent nothing for this step; it
|
|
2003
|
+
// counts as a clean failure (STEP_UNAVAILABLE).
|
|
2004
|
+
const openStep = async (index) => {
|
|
2005
|
+
try {
|
|
2006
|
+
return await createRunsClient({
|
|
2007
|
+
apiKeyFile,
|
|
2008
|
+
claim: {
|
|
2009
|
+
...claim,
|
|
2010
|
+
run: { ...claim.run, modelSelection: chain[index] },
|
|
2011
|
+
},
|
|
2012
|
+
activeRuntime,
|
|
2013
|
+
chainStep: index,
|
|
2014
|
+
fetchConnectionKey,
|
|
2015
|
+
});
|
|
2016
|
+
} catch {
|
|
2017
|
+
return null;
|
|
2018
|
+
}
|
|
2019
|
+
};
|
|
1991
2020
|
const controller = new AbortController();
|
|
1992
2021
|
const abortFromOuter = () => controller.abort(outerSignal.reason);
|
|
1993
2022
|
outerSignal.addEventListener("abort", abortFromOuter, { once: true });
|
|
@@ -2460,7 +2489,7 @@ export function createCustomerEndpointRelay(options = {}) {
|
|
|
2460
2489
|
(descriptorBinding.expected.allowedToolNames ?? []).length === 0;
|
|
2461
2490
|
const history = [];
|
|
2462
2491
|
let stepIndex = 0;
|
|
2463
|
-
let stepRuns = runs;
|
|
2492
|
+
let stepRuns = chain.length ? await openStep(0) : runs;
|
|
2464
2493
|
for (;;) {
|
|
2465
2494
|
const outcome = await runNativeStep(stepIndex, stepRuns);
|
|
2466
2495
|
if (outcome.done) return outcome.result;
|
|
@@ -2492,16 +2521,7 @@ export function createCustomerEndpointRelay(options = {}) {
|
|
|
2492
2521
|
);
|
|
2493
2522
|
history.push(outcome.failure);
|
|
2494
2523
|
stepIndex = decision.stepIndex;
|
|
2495
|
-
|
|
2496
|
-
stepRuns = createRunsClient({
|
|
2497
|
-
apiKeyFile,
|
|
2498
|
-
claim: stepClaim(stepIndex),
|
|
2499
|
-
activeRuntime,
|
|
2500
|
-
});
|
|
2501
|
-
} catch {
|
|
2502
|
-
// Nothing was sent for this step; it counts as a clean failure.
|
|
2503
|
-
stepRuns = null;
|
|
2504
|
-
}
|
|
2524
|
+
stepRuns = await openStep(stepIndex);
|
|
2505
2525
|
}
|
|
2506
2526
|
} catch (error) {
|
|
2507
2527
|
if (heartbeatFailure) throw heartbeatFailure;
|
|
@@ -2554,10 +2574,16 @@ export function createCustomerEndpointRelay(options = {}) {
|
|
|
2554
2574
|
ok: false,
|
|
2555
2575
|
code:
|
|
2556
2576
|
error instanceof Error
|
|
2557
|
-
? redactRuntimeDiagnostic(
|
|
2577
|
+
? redactRuntimeDiagnostic(
|
|
2578
|
+
released.reduce(
|
|
2579
|
+
(text, secret) => text.replaceAll(secret, "[redacted]"),
|
|
2580
|
+
error.message
|
|
2581
|
+
)
|
|
2582
|
+
)
|
|
2558
2583
|
: "customer_endpoint_relay_failed",
|
|
2559
2584
|
};
|
|
2560
2585
|
} finally {
|
|
2586
|
+
released.length = 0;
|
|
2561
2587
|
clearTimeout(deadlineTimer);
|
|
2562
2588
|
outerSignal.removeEventListener("abort", abortFromOuter);
|
|
2563
2589
|
heartbeatStopped = true;
|
|
@@ -20,6 +20,7 @@ export type EndpointModelStep = Readonly<{
|
|
|
20
20
|
model: string;
|
|
21
21
|
reasoningEffort: (typeof ENDPOINT_MODEL_REASONING_EFFORTS)[number];
|
|
22
22
|
runsApiBaseUrl?: string;
|
|
23
|
+
modelConnectionId?: string;
|
|
23
24
|
}>;
|
|
24
25
|
export type EndpointModelSelection = Readonly<
|
|
25
26
|
EndpointModelStep & {
|
|
@@ -50,17 +50,24 @@ export function normalizeRunsApiBaseUrl(value) {
|
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
// One model: provider, model, effort and, for a custom Runs API, its URL.
|
|
53
|
+
// A custom step may also name the workspace's own model connection
|
|
54
|
+
// (modelConnectionId), which is always https. Skew: an older runtime refuses
|
|
55
|
+
// that unknown key as "invalid", so Verify & save refuses a connection step
|
|
56
|
+
// on a machine that cannot fetch its key per attempt.
|
|
53
57
|
function compileEndpointModelStep(value, extraKeys = []) {
|
|
54
58
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
55
59
|
const custom = Object.hasOwn(value, "runsApiBaseUrl");
|
|
60
|
+
const connection = Object.hasOwn(value, "modelConnectionId");
|
|
56
61
|
if (
|
|
57
62
|
Object.keys(value)
|
|
58
63
|
.filter((key) => !extraKeys.includes(key))
|
|
59
64
|
.sort()
|
|
60
65
|
.join(",") !==
|
|
61
|
-
(
|
|
62
|
-
? "model,provider,reasoningEffort,runsApiBaseUrl"
|
|
63
|
-
:
|
|
66
|
+
(connection
|
|
67
|
+
? "model,modelConnectionId,provider,reasoningEffort,runsApiBaseUrl"
|
|
68
|
+
: custom
|
|
69
|
+
? "model,provider,reasoningEffort,runsApiBaseUrl"
|
|
70
|
+
: "model,provider,reasoningEffort")
|
|
64
71
|
)
|
|
65
72
|
return null;
|
|
66
73
|
const { provider, model, reasoningEffort } = value;
|
|
@@ -86,11 +93,20 @@ function compileEndpointModelStep(value, extraKeys = []) {
|
|
|
86
93
|
!/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,199}$/.test(model))
|
|
87
94
|
)
|
|
88
95
|
return null;
|
|
96
|
+
const { modelConnectionId } = value;
|
|
97
|
+
if (
|
|
98
|
+
connection &&
|
|
99
|
+
(typeof modelConnectionId !== "string" ||
|
|
100
|
+
!/^[A-Za-z0-9_-]{1,128}$/.test(modelConnectionId) ||
|
|
101
|
+
!runsApiBaseUrl.startsWith("https://"))
|
|
102
|
+
)
|
|
103
|
+
return null;
|
|
89
104
|
return Object.freeze({
|
|
90
105
|
provider,
|
|
91
106
|
model,
|
|
92
107
|
reasoningEffort,
|
|
93
108
|
...(custom ? { runsApiBaseUrl } : {}),
|
|
109
|
+
...(connection ? { modelConnectionId } : {}),
|
|
94
110
|
});
|
|
95
111
|
}
|
|
96
112
|
|
|
@@ -98,7 +114,8 @@ const sameStep = (left, right) =>
|
|
|
98
114
|
left.provider === right.provider &&
|
|
99
115
|
left.model === right.model &&
|
|
100
116
|
left.reasoningEffort === right.reasoningEffort &&
|
|
101
|
-
left.runsApiBaseUrl === right.runsApiBaseUrl
|
|
117
|
+
left.runsApiBaseUrl === right.runsApiBaseUrl &&
|
|
118
|
+
left.modelConnectionId === right.modelConnectionId;
|
|
102
119
|
|
|
103
120
|
/**
|
|
104
121
|
* The top-level fields are the endpoint's first model. An optional ordered
|
|
@@ -244,6 +244,36 @@ export function validateOpenerOutputContract(value) {
|
|
|
244
244
|
return JSON.parse(stableJson(value));
|
|
245
245
|
}
|
|
246
246
|
|
|
247
|
+
// One remote MCP server for the run, in the OpenAI Responses tool shape.
|
|
248
|
+
function compileRemoteTools(value) {
|
|
249
|
+
const serverUrl = normalizeRunsApiBaseUrl(value?.serverUrl);
|
|
250
|
+
const names = value?.allowedTools;
|
|
251
|
+
if (
|
|
252
|
+
!exactObject(value) ||
|
|
253
|
+
Object.keys(value).sort().join(",") !==
|
|
254
|
+
"allowedTools,authorization,serverUrl" ||
|
|
255
|
+
!serverUrl?.startsWith("https://") ||
|
|
256
|
+
typeof value.authorization !== "string" ||
|
|
257
|
+
!/^srt_[A-Za-z0-9-]{1,128}_[A-Za-z0-9_-]{43}$/.test(value.authorization) ||
|
|
258
|
+
!Array.isArray(names) ||
|
|
259
|
+
names.length < 1 ||
|
|
260
|
+
names.length > 32 ||
|
|
261
|
+
new Set(names).size !== names.length ||
|
|
262
|
+
names.some(
|
|
263
|
+
(name) => typeof name !== "string" || !/^[a-z][a-z0-9_]{0,63}$/.test(name)
|
|
264
|
+
)
|
|
265
|
+
)
|
|
266
|
+
throw new Error("hermes_runs_submit_rejected");
|
|
267
|
+
return Object.freeze({
|
|
268
|
+
type: "mcp",
|
|
269
|
+
server_label: "sellable",
|
|
270
|
+
server_url: serverUrl,
|
|
271
|
+
authorization: value.authorization,
|
|
272
|
+
allowed_tools: [...names],
|
|
273
|
+
require_approval: "never",
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
247
277
|
function compileCreateBody(input, openerStage = false, verification = false) {
|
|
248
278
|
if (!exactObject(input)) throw new Error("hermes_runs_submit_rejected");
|
|
249
279
|
const allowed = [
|
|
@@ -253,6 +283,7 @@ function compileCreateBody(input, openerStage = false, verification = false) {
|
|
|
253
283
|
"correlation",
|
|
254
284
|
"outputContract",
|
|
255
285
|
...(openerStage && !verification ? [] : ["modelSelection"]),
|
|
286
|
+
...(openerStage ? [] : ["remoteTools"]),
|
|
256
287
|
];
|
|
257
288
|
if (Object.keys(input).some((key) => !allowed.includes(key))) {
|
|
258
289
|
throw new Error("hermes_runs_submit_rejected");
|
|
@@ -288,6 +319,10 @@ function compileCreateBody(input, openerStage = false, verification = false) {
|
|
|
288
319
|
input.outputContract === undefined
|
|
289
320
|
? null
|
|
290
321
|
: validateOpenerOutputContract(input.outputContract);
|
|
322
|
+
const remoteTools =
|
|
323
|
+
input.remoteTools === undefined
|
|
324
|
+
? null
|
|
325
|
+
: compileRemoteTools(input.remoteTools);
|
|
291
326
|
const modelOptions = {
|
|
292
327
|
...(modelSelection === null
|
|
293
328
|
? {}
|
|
@@ -309,6 +344,7 @@ function compileCreateBody(input, openerStage = false, verification = false) {
|
|
|
309
344
|
...(Object.keys(modelOptions).length === 0
|
|
310
345
|
? {}
|
|
311
346
|
: { model_options: modelOptions }),
|
|
347
|
+
...(remoteTools === null ? {} : { tools: [remoteTools] }),
|
|
312
348
|
});
|
|
313
349
|
}
|
|
314
350
|
|
|
@@ -585,6 +621,7 @@ function submitNotAdmitted(response, value) {
|
|
|
585
621
|
/** @param {any} options */
|
|
586
622
|
export function createHermesRunsClient(options = {}) {
|
|
587
623
|
const {
|
|
624
|
+
apiKey: inMemoryKey,
|
|
588
625
|
apiKeyFile,
|
|
589
626
|
baseUrl = LOOPBACK_ORIGIN,
|
|
590
627
|
scopeHeaders = {},
|
|
@@ -596,9 +633,22 @@ export function createHermesRunsClient(options = {}) {
|
|
|
596
633
|
const origin = normalizeRunsApiBaseUrl(baseUrl);
|
|
597
634
|
if (
|
|
598
635
|
!origin ||
|
|
636
|
+
// Exactly one key source. An in-memory key (a workspace model connection)
|
|
637
|
+
// follows that key's own rule, never readApiKey's key-file rule.
|
|
638
|
+
(inMemoryKey === undefined) === (apiKeyFile === undefined) ||
|
|
639
|
+
(inMemoryKey !== undefined &&
|
|
640
|
+
(typeof inMemoryKey !== "string" ||
|
|
641
|
+
inMemoryKey.length < 16 ||
|
|
642
|
+
inMemoryKey.length > 1024 ||
|
|
643
|
+
/[\s\x00-\x1f\x7f]/.test(inMemoryKey))) ||
|
|
599
644
|
!exactObject(scopeHeaders) ||
|
|
600
|
-
Object.keys(scopeHeaders).some(
|
|
601
|
-
|
|
645
|
+
Object.keys(scopeHeaders).some(
|
|
646
|
+
(key) => !["x-sellable-workspace-id", "x-sellable-agent-id"].includes(key)
|
|
647
|
+
) ||
|
|
648
|
+
Object.values(scopeHeaders).some(
|
|
649
|
+
(value) =>
|
|
650
|
+
typeof value !== "string" || !/^[A-Za-z0-9_-]{1,128}$/.test(value)
|
|
651
|
+
) ||
|
|
602
652
|
typeof fetchImpl !== "function" ||
|
|
603
653
|
!Number.isSafeInteger(timeoutMs) ||
|
|
604
654
|
timeoutMs < 100 ||
|
|
@@ -606,7 +656,7 @@ export function createHermesRunsClient(options = {}) {
|
|
|
606
656
|
) {
|
|
607
657
|
throw new Error("hermes_runs_client_rejected");
|
|
608
658
|
}
|
|
609
|
-
const apiKey = readApiKey(apiKeyFile);
|
|
659
|
+
const apiKey = inMemoryKey ?? readApiKey(apiKeyFile);
|
|
610
660
|
const request = async (path, init = {}) => {
|
|
611
661
|
if (!path.startsWith("/v1/runs")) {
|
|
612
662
|
throw new Error("hermes_runs_path_rejected");
|
|
@@ -624,7 +674,12 @@ export function createHermesRunsClient(options = {}) {
|
|
|
624
674
|
signal: init.signal ?? AbortSignal.timeout(timeoutMs),
|
|
625
675
|
});
|
|
626
676
|
};
|
|
627
|
-
const submit = async (
|
|
677
|
+
const submit = async (
|
|
678
|
+
compiled,
|
|
679
|
+
{ signal } = {},
|
|
680
|
+
openerStage = false,
|
|
681
|
+
verification = false
|
|
682
|
+
) => {
|
|
628
683
|
const body = compileCreateBody(compiled, openerStage, verification);
|
|
629
684
|
let response;
|
|
630
685
|
try {
|
|
@@ -668,7 +723,8 @@ export function createHermesRunsClient(options = {}) {
|
|
|
668
723
|
fixtureIdentity: HERMES_RUNS_FIXTURE_IDENTITY,
|
|
669
724
|
submit: (compiled, options) => submit(compiled, options),
|
|
670
725
|
// Fixed operator verification uses the existing tool-free session policy.
|
|
671
|
-
submitVerification: (compiled, options) =>
|
|
726
|
+
submitVerification: (compiled, options) =>
|
|
727
|
+
submit(compiled, options, true, true),
|
|
672
728
|
// Only this reserved session prefix receives the pinned tool-free bridge policy.
|
|
673
729
|
submitOpenerStage: (compiled, options) => submit(compiled, options, true),
|
|
674
730
|
async status(runId, { signal } = {}) {
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { RunsApiConnection } from "./runs-api-connections.mjs";
|
|
2
2
|
export function createOpenAiRunsClient(options: {
|
|
3
|
-
connection: RunsApiConnection;
|
|
3
|
+
connection: Omit<RunsApiConnection, "apiKeyFile"> & { apiKeyFile?: string };
|
|
4
|
+
/** Used instead of `connection.apiKeyFile` when present. */
|
|
5
|
+
apiKey?: string;
|
|
4
6
|
responseSchema?: unknown;
|
|
5
7
|
modelSelection?: { provider: string; model: string; reasoningEffort: string };
|
|
6
8
|
fetchImpl?: typeof fetch;
|
|
@@ -30,6 +30,7 @@ export function createOpenAiRunsClient({
|
|
|
30
30
|
connection,
|
|
31
31
|
responseSchema,
|
|
32
32
|
modelSelection,
|
|
33
|
+
apiKey,
|
|
33
34
|
fetchImpl = fetch,
|
|
34
35
|
}) {
|
|
35
36
|
const runs = new Map();
|
|
@@ -265,7 +266,9 @@ export function createOpenAiRunsClient({
|
|
|
265
266
|
};
|
|
266
267
|
return createHermesRunsClient({
|
|
267
268
|
baseUrl: connection.baseUrl,
|
|
268
|
-
|
|
269
|
+
...(apiKey === undefined
|
|
270
|
+
? { apiKeyFile: connection.apiKeyFile }
|
|
271
|
+
: { apiKey }),
|
|
269
272
|
fetchImpl: bridge,
|
|
270
273
|
});
|
|
271
274
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export type PublicEndpointGuardCode =
|
|
2
|
+
| "URL_REJECTED"
|
|
3
|
+
| "IP_LITERAL"
|
|
4
|
+
| "BLOCKED_NAME"
|
|
5
|
+
| "NON_PUBLIC_ADDRESS"
|
|
6
|
+
| "REDIRECT_REFUSED"
|
|
7
|
+
| "RESPONSE_TOO_LARGE"
|
|
8
|
+
| "DEADLINE_EXCEEDED";
|
|
9
|
+
export type PublicEndpointGuardError = Error & {
|
|
10
|
+
code: PublicEndpointGuardCode;
|
|
11
|
+
};
|
|
12
|
+
export type PublicAddress = { address: string; family: number };
|
|
13
|
+
export type PublicTarget = {
|
|
14
|
+
url: URL;
|
|
15
|
+
host: string;
|
|
16
|
+
addresses: PublicAddress[];
|
|
17
|
+
};
|
|
18
|
+
export function isPublicAddress(ip: string): boolean;
|
|
19
|
+
export function resolvePublicTarget(
|
|
20
|
+
rawUrl: string,
|
|
21
|
+
options?: { lookup?: (host: string) => Promise<readonly PublicAddress[]> }
|
|
22
|
+
): Promise<PublicTarget>;
|
|
23
|
+
export function createPinnedFetch(options?: {
|
|
24
|
+
maxResponseBytes?: number;
|
|
25
|
+
deadlineMs?: number;
|
|
26
|
+
resolve?: (rawUrl: string) => Promise<PublicTarget>;
|
|
27
|
+
request?: typeof import("node:https").request;
|
|
28
|
+
}): typeof fetch;
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// SSRF control for user-supplied endpoint URLs, shared by the Next server and
|
|
2
|
+
// the agent machine. Node built-ins only.
|
|
3
|
+
import { promises as dns } from "node:dns";
|
|
4
|
+
import https from "node:https";
|
|
5
|
+
import net from "node:net";
|
|
6
|
+
import { Readable } from "node:stream";
|
|
7
|
+
|
|
8
|
+
// IANA special-purpose registries (2025-10-09) plus metadata and CGNAT. There
|
|
9
|
+
// is deliberately no ::ffff:0:0/96 row: a BlockList checks IPv4 addresses
|
|
10
|
+
// against v4-mapped IPv6 rules, so that row would block every IPv4 answer.
|
|
11
|
+
// v4-mapped answers are unwrapped and checked against the IPv4 rows instead.
|
|
12
|
+
const V4 = [
|
|
13
|
+
["0.0.0.0", 8],
|
|
14
|
+
["10.0.0.0", 8],
|
|
15
|
+
["100.64.0.0", 10],
|
|
16
|
+
["127.0.0.0", 8],
|
|
17
|
+
["169.254.0.0", 16],
|
|
18
|
+
["172.16.0.0", 12],
|
|
19
|
+
["192.0.0.0", 24],
|
|
20
|
+
["192.0.2.0", 24],
|
|
21
|
+
["192.88.99.0", 24],
|
|
22
|
+
["192.168.0.0", 16],
|
|
23
|
+
["198.18.0.0", 15],
|
|
24
|
+
["198.51.100.0", 24],
|
|
25
|
+
["203.0.113.0", 24],
|
|
26
|
+
["224.0.0.0", 4],
|
|
27
|
+
["240.0.0.0", 4],
|
|
28
|
+
];
|
|
29
|
+
const V6 = [
|
|
30
|
+
["::", 96],
|
|
31
|
+
["64:ff9b::", 96],
|
|
32
|
+
["64:ff9b:1::", 48],
|
|
33
|
+
["100::", 64],
|
|
34
|
+
["2001::", 23],
|
|
35
|
+
["2001:db8::", 32],
|
|
36
|
+
["2002::", 16],
|
|
37
|
+
["3fff::", 20],
|
|
38
|
+
["5f00::", 16],
|
|
39
|
+
["fc00::", 7],
|
|
40
|
+
["fe80::", 10],
|
|
41
|
+
["fec0::", 10],
|
|
42
|
+
["ff00::", 8],
|
|
43
|
+
];
|
|
44
|
+
const blocked = new net.BlockList();
|
|
45
|
+
for (const [address, prefix] of V4) blocked.addSubnet(address, prefix, "ipv4");
|
|
46
|
+
for (const [address, prefix] of V6) blocked.addSubnet(address, prefix, "ipv6");
|
|
47
|
+
|
|
48
|
+
// The IPv4 address an IPv4-mapped answer carries (::ffff:a.b.c.d in any
|
|
49
|
+
// spelling), so its check never rests on BlockList's v4-in-v6 matching.
|
|
50
|
+
function mappedIPv4(ip) {
|
|
51
|
+
let hostname;
|
|
52
|
+
try {
|
|
53
|
+
hostname = new URL(`http://[${ip}]`).hostname;
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
const mapped = /^\[::ffff:([\da-f]{1,4}):([\da-f]{1,4})\]$/.exec(hostname);
|
|
58
|
+
if (!mapped) return null;
|
|
59
|
+
const [high, low] = [parseInt(mapped[1], 16), parseInt(mapped[2], 16)];
|
|
60
|
+
return [high >> 8, high & 255, low >> 8, low & 255].join(".");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function isPublicAddress(ip) {
|
|
64
|
+
const family = net.isIP(ip);
|
|
65
|
+
if (family === 0) return false;
|
|
66
|
+
const v4 = family === 4 ? ip : mappedIPv4(ip);
|
|
67
|
+
return v4 ? !blocked.check(v4, "ipv4") : !blocked.check(ip, "ipv6");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Errors carry only the fixed code: never the URL, its credentials or headers.
|
|
71
|
+
const refuse = (code) => Object.assign(new Error(code), { code });
|
|
72
|
+
|
|
73
|
+
export async function resolvePublicTarget(
|
|
74
|
+
rawUrl,
|
|
75
|
+
{ lookup = (host) => dns.lookup(host, { all: true, verbatim: true }) } = {}
|
|
76
|
+
) {
|
|
77
|
+
let url;
|
|
78
|
+
try {
|
|
79
|
+
url = new URL(rawUrl);
|
|
80
|
+
} catch {
|
|
81
|
+
throw refuse("URL_REJECTED");
|
|
82
|
+
}
|
|
83
|
+
// url.port is "" for the default (443) port, however it was written.
|
|
84
|
+
if (
|
|
85
|
+
url.protocol !== "https:" ||
|
|
86
|
+
url.port ||
|
|
87
|
+
url.username ||
|
|
88
|
+
url.password ||
|
|
89
|
+
url.search ||
|
|
90
|
+
url.hash
|
|
91
|
+
) {
|
|
92
|
+
throw refuse("URL_REJECTED");
|
|
93
|
+
}
|
|
94
|
+
const host = url.hostname.replace(/\.$/, "").toLowerCase();
|
|
95
|
+
if (net.isIP(host.replace(/^\[|\]$/g, ""))) throw refuse("IP_LITERAL");
|
|
96
|
+
if (/(^|\.)(localhost|local|internal|flycast|home\.arpa)$/.test(host)) {
|
|
97
|
+
throw refuse("BLOCKED_NAME");
|
|
98
|
+
}
|
|
99
|
+
const answers = await lookup(host);
|
|
100
|
+
if (!answers.length || answers.some((a) => !isPublicAddress(a.address))) {
|
|
101
|
+
throw refuse("NON_PUBLIC_ADDRESS");
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
url,
|
|
105
|
+
host,
|
|
106
|
+
addresses: answers.map(({ address, family }) => ({ address, family })),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// A fetch that resolves and checks the target on every request, dials only
|
|
111
|
+
// the vetted addresses (TLS SNI and certificate checks stay on the hostname)
|
|
112
|
+
// and refuses every redirect. `request` is a test seam only.
|
|
113
|
+
export function createPinnedFetch({
|
|
114
|
+
maxResponseBytes = 8 << 20,
|
|
115
|
+
deadlineMs = 120_000,
|
|
116
|
+
resolve = resolvePublicTarget,
|
|
117
|
+
request = https.request,
|
|
118
|
+
} = {}) {
|
|
119
|
+
return async (input, init = {}) => {
|
|
120
|
+
// deadlineMs bounds DNS, connect and response headers only. Once headers
|
|
121
|
+
// arrive, only init.signal and the byte cap bound the body, so a long SSE
|
|
122
|
+
// stream or provider call is never cut here. A plain setTimeout (not
|
|
123
|
+
// AbortSignal.timeout) so it can be cleared.
|
|
124
|
+
const deadline = new AbortController();
|
|
125
|
+
const expired = new Promise((_, reject) => {
|
|
126
|
+
deadline.signal.addEventListener("abort", () =>
|
|
127
|
+
reject(deadline.signal.reason)
|
|
128
|
+
);
|
|
129
|
+
});
|
|
130
|
+
const timer = setTimeout(
|
|
131
|
+
() => deadline.abort(refuse("DEADLINE_EXCEEDED")),
|
|
132
|
+
deadlineMs
|
|
133
|
+
);
|
|
134
|
+
try {
|
|
135
|
+
const target = await Promise.race([resolve(String(input)), expired]);
|
|
136
|
+
// Both lookup shapes (Pitfall 2): net asks for { all: true } under
|
|
137
|
+
// autoSelectFamily; refuse any hostname other than the vetted one.
|
|
138
|
+
const lookup = (hostname, options, callback) => {
|
|
139
|
+
if (hostname !== target.host) return callback(refuse("ENOTFOUND"));
|
|
140
|
+
if (options?.all) return callback(null, target.addresses);
|
|
141
|
+
const [first] = target.addresses;
|
|
142
|
+
callback(null, first.address, first.family);
|
|
143
|
+
};
|
|
144
|
+
const method = (init.method ?? "GET").toUpperCase();
|
|
145
|
+
const headers =
|
|
146
|
+
init.headers instanceof Headers
|
|
147
|
+
? Object.fromEntries(init.headers)
|
|
148
|
+
: init.headers;
|
|
149
|
+
const dial = new Promise((resolveResponse, reject) => {
|
|
150
|
+
const req = request(
|
|
151
|
+
{
|
|
152
|
+
hostname: target.host,
|
|
153
|
+
servername: target.host,
|
|
154
|
+
port: target.url.port || 443,
|
|
155
|
+
path: target.url.pathname + target.url.search,
|
|
156
|
+
method,
|
|
157
|
+
headers,
|
|
158
|
+
lookup,
|
|
159
|
+
agent: false,
|
|
160
|
+
signal: init.signal
|
|
161
|
+
? AbortSignal.any([init.signal, deadline.signal])
|
|
162
|
+
: deadline.signal,
|
|
163
|
+
},
|
|
164
|
+
(res) => {
|
|
165
|
+
if (res.statusCode >= 300 && res.statusCode < 400) {
|
|
166
|
+
res.destroy();
|
|
167
|
+
return reject(refuse("REDIRECT_REFUSED"));
|
|
168
|
+
}
|
|
169
|
+
let seen = 0;
|
|
170
|
+
res.on("data", (chunk) => {
|
|
171
|
+
if ((seen += chunk.length) > maxResponseBytes) {
|
|
172
|
+
res.destroy(refuse("RESPONSE_TOO_LARGE"));
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
// An untrusted server can send what Response refuses (status 999):
|
|
176
|
+
// reject, never throw out of this callback (that kills the process).
|
|
177
|
+
try {
|
|
178
|
+
const responseHeaders = new Headers();
|
|
179
|
+
for (const [name, value] of Object.entries(res.headers)) {
|
|
180
|
+
for (const item of [].concat(value)) {
|
|
181
|
+
responseHeaders.append(name, item);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
const nullBody =
|
|
185
|
+
[204, 205].includes(res.statusCode) || method === "HEAD";
|
|
186
|
+
resolveResponse(
|
|
187
|
+
new Response(nullBody ? null : Readable.toWeb(res), {
|
|
188
|
+
status: res.statusCode,
|
|
189
|
+
statusText: res.statusMessage,
|
|
190
|
+
headers: responseHeaders,
|
|
191
|
+
})
|
|
192
|
+
);
|
|
193
|
+
} catch (error) {
|
|
194
|
+
res.destroy();
|
|
195
|
+
reject(error);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
);
|
|
199
|
+
req.on("error", reject);
|
|
200
|
+
if (init.body != null) req.write(init.body);
|
|
201
|
+
req.end();
|
|
202
|
+
});
|
|
203
|
+
return await Promise.race([dial, expired]);
|
|
204
|
+
} finally {
|
|
205
|
+
// Settles at response headers (or on any error): the deadline ends here.
|
|
206
|
+
clearTimeout(timer);
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
}
|