@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.
Files changed (26) hide show
  1. package/container/Dockerfile +4 -4
  2. package/container/README.md +1 -1
  3. package/lib/sellable-agent/fly-admin-image/Dockerfile +3 -1
  4. package/lib/sellable-agent/fly-admin-image/admin-runtime.mjs +19 -3
  5. package/lib/sellable-agent/fly-customer-image/Dockerfile +2 -2
  6. package/lib/sellable-agent/fly-customer-image/claude-code-runs-api.d.mts +23 -3
  7. package/lib/sellable-agent/fly-customer-image/claude-code-runs-api.mjs +197 -52
  8. package/lib/sellable-agent/fly-customer-image/customer-runtime.mjs +48 -22
  9. package/lib/sellable-agent/fly-customer-image/endpoint-model-selection.d.mts +1 -0
  10. package/lib/sellable-agent/fly-customer-image/endpoint-model-selection.mjs +21 -4
  11. package/lib/sellable-agent/fly-customer-image/hermes-runs-client.mjs +61 -5
  12. package/lib/sellable-agent/fly-customer-image/openai-runs-client.d.mts +3 -1
  13. package/lib/sellable-agent/fly-customer-image/openai-runs-client.mjs +4 -1
  14. package/lib/sellable-agent/fly-customer-image/public-endpoint-guard.d.mts +28 -0
  15. package/lib/sellable-agent/fly-customer-image/public-endpoint-guard.mjs +209 -0
  16. package/lib/sellable-agent/fly-customer-image/runs-api-connections.d.mts +1 -1
  17. package/lib/sellable-agent/fly-customer-image/runs-api-connections.mjs +97 -24
  18. package/lib/sellable-agent/fly-customer-image/runs-api-health-check.mjs +11 -42
  19. package/lib/sellable-agent/fly-customer-image/workspace-connection-client.d.mts +22 -0
  20. package/lib/sellable-agent/fly-customer-image/workspace-connection-client.mjs +98 -0
  21. package/lib/sellable-agent/fly-runtime-identity.mjs +8 -0
  22. package/lib/sellable-agent/host-bootstrap.mjs +1 -1
  23. package/lib/sellable-agent/host-worker.mjs +1 -1
  24. package/lib/sellable-agent/profile-materializer.mjs +1 -1
  25. package/lib/sellable-agent/provisioning-adapter.mjs +1 -1
  26. 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.803";
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
- const stepClaim = (index) =>
1983
- chain.length === 0
1984
- ? claim
1985
- : { ...claim, run: { ...claim.run, modelSelection: chain[index] } };
1986
- const runs = createRunsClient({
1987
- apiKeyFile,
1988
- claim: stepClaim(0),
1989
- activeRuntime,
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
- try {
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(error.message)
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
- (custom
62
- ? "model,provider,reasoningEffort,runsApiBaseUrl"
63
- : "model,provider,reasoningEffort")
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(key => !["x-sellable-workspace-id", "x-sellable-agent-id"].includes(key)) ||
601
- Object.values(scopeHeaders).some(value => typeof value !== "string" || !/^[A-Za-z0-9_-]{1,128}$/.test(value)) ||
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 (compiled, { signal } = {}, openerStage = false, verification = false) => {
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) => submit(compiled, options, true, true),
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
- apiKeyFile: connection.apiKeyFile,
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
+ }
@@ -33,4 +33,4 @@ export function runsApiConnectionsForRuntime(
33
33
  agentId: string;
34
34
  }
35
35
  ): RunsApiConnection[];
36
- export function createConfiguredRunsClient(options: any): any;
36
+ export function createConfiguredRunsClient(options: any): Promise<any>;