@sellable/install 0.1.803 → 0.1.805
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/agents/post-find-leads-message-scout.md +4 -4
- package/agents/registry.json +1 -1
- package/bin/sellable-install.mjs +36 -20
- package/container/Dockerfile +4 -4
- package/container/README.md +1 -1
- package/lib/sellable-agent/default-profile-bundles/shared/manifest.fragment.json +1 -1
- package/lib/sellable-agent/default-profile-bundles/shared/skills/sellable/sellable-create-campaign/SKILL.md +3 -3
- package/lib/sellable-agent/fly-admin-image/Dockerfile +4 -2
- package/lib/sellable-agent/fly-admin-image/admin-runtime.mjs +19 -3
- package/lib/sellable-agent/fly-customer-image/Dockerfile +3 -3
- 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/lib/sellable-agent/public-skills.json +4 -4
- package/lib/sellable-agent/public-skills.mjs +35 -0
- package/package.json +1 -1
- package/skill-templates/create-campaign.md +3 -3
- package/skill-templates/{conversation-chess.md → draft-message.md} +7 -6
- package/skill-templates/first-campaign-onboarding.md +8 -7
|
@@ -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
|
+
}
|
|
@@ -5,6 +5,8 @@ import {
|
|
|
5
5
|
} from "./endpoint-model-selection.mjs";
|
|
6
6
|
import { createHermesRunsClient } from "./hermes-runs-client.mjs";
|
|
7
7
|
import { createOpenAiRunsClient } from "./openai-runs-client.mjs";
|
|
8
|
+
import { createPinnedFetch } from "./public-endpoint-guard.mjs";
|
|
9
|
+
import { createWorkspaceConnectionRunsClient } from "./workspace-connection-client.mjs";
|
|
8
10
|
|
|
9
11
|
export const RUNS_API_CONNECTIONS_ENV =
|
|
10
12
|
"SELLABLE_AGENT_RUNS_API_CONNECTIONS_JSON";
|
|
@@ -202,14 +204,107 @@ export function runsApiConnectionsForRuntime(raw, runtime) {
|
|
|
202
204
|
);
|
|
203
205
|
}
|
|
204
206
|
|
|
207
|
+
// Exactly the members the endpoint-connection-key verb returns. The key follows
|
|
208
|
+
// the saved key's rule (16-1024 characters), never the key-file rule; the
|
|
209
|
+
// remote tool's values are checked where the create body is built.
|
|
210
|
+
const connectionRelease = (value) =>
|
|
211
|
+
record(value) &&
|
|
212
|
+
Object.keys(value).sort().join(",") ===
|
|
213
|
+
"apiKey,keyGeneration,protocol,structuredOutputs,tools" &&
|
|
214
|
+
typeof value.apiKey === "string" &&
|
|
215
|
+
value.apiKey.length >= 16 &&
|
|
216
|
+
value.apiKey.length <= 1024 &&
|
|
217
|
+
!/[\s\x00-\x1f\x7f]/.test(value.apiKey) &&
|
|
218
|
+
Number.isSafeInteger(value.keyGeneration) &&
|
|
219
|
+
["runs-api", "openai-chat", "openai-responses"].includes(value.protocol) &&
|
|
220
|
+
typeof value.structuredOutputs === "boolean" &&
|
|
221
|
+
(value.tools === null ||
|
|
222
|
+
(value.protocol === "runs-api" &&
|
|
223
|
+
record(value.tools) &&
|
|
224
|
+
Object.keys(value.tools).sort().join(",") ===
|
|
225
|
+
"allowedTools,authorization,expiresAt,serverUrl" &&
|
|
226
|
+
typeof value.tools.serverUrl === "string" &&
|
|
227
|
+
typeof value.tools.authorization === "string" &&
|
|
228
|
+
Array.isArray(value.tools.allowedTools) &&
|
|
229
|
+
typeof value.tools.expiresAt === "string"));
|
|
230
|
+
|
|
231
|
+
// Adds the opener output contract (and a granted remote tool) to every submit.
|
|
232
|
+
function customRunsClient(client, claim, remoteTools) {
|
|
233
|
+
return Object.freeze({
|
|
234
|
+
...client,
|
|
235
|
+
submit(compiled, options) {
|
|
236
|
+
const request = remoteTools ? { ...compiled, remoteTools } : compiled;
|
|
237
|
+
// Public semantic endpoints have a published schema but no product tuple.
|
|
238
|
+
// Preserve that existing contract when calling a custom Runs service.
|
|
239
|
+
const schema = claim.revision?.outputSchema;
|
|
240
|
+
if (compiled.outputContract || !schema?.properties?.proposal)
|
|
241
|
+
return client.submit(request, options);
|
|
242
|
+
return client.submit(
|
|
243
|
+
{
|
|
244
|
+
...request,
|
|
245
|
+
outputContract: {
|
|
246
|
+
schemaVersion: "sellable-opener-output-contract/v1",
|
|
247
|
+
schema,
|
|
248
|
+
nativeProposalSchemaDigest: createHash("sha256")
|
|
249
|
+
.update(stable(schema.properties.proposal))
|
|
250
|
+
.digest("hex"),
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
options
|
|
254
|
+
);
|
|
255
|
+
},
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
205
259
|
/** @param {any} options */
|
|
206
|
-
export function createConfiguredRunsClient({
|
|
260
|
+
export async function createConfiguredRunsClient({
|
|
207
261
|
apiKeyFile,
|
|
208
262
|
claim,
|
|
209
263
|
env = process.env,
|
|
210
264
|
clientFactory = createHermesRunsClient,
|
|
265
|
+
fetchConnectionKey,
|
|
266
|
+
chainStep = 0,
|
|
267
|
+
createFetch = createPinnedFetch,
|
|
211
268
|
}) {
|
|
212
269
|
const selection = claim?.run?.modelSelection;
|
|
270
|
+
// A workspace connection never uses the registry, even on a matching URL:
|
|
271
|
+
// its key is fetched for this attempt and lives only in these closures, and
|
|
272
|
+
// every request goes through the address guard. URL and model come from
|
|
273
|
+
// the signed claim, never from the key response.
|
|
274
|
+
if (selection?.modelConnectionId) {
|
|
275
|
+
const step = compileEndpointModelSelection(selection);
|
|
276
|
+
if (!step || step === "invalid") throw new Error("runs_api_model_rejected");
|
|
277
|
+
let release = null;
|
|
278
|
+
try {
|
|
279
|
+
release = await fetchConnectionKey(step.modelConnectionId, chainStep);
|
|
280
|
+
} catch {
|
|
281
|
+
// Every refusal is one fixed code: no server reason, no response field.
|
|
282
|
+
}
|
|
283
|
+
if (!connectionRelease(release))
|
|
284
|
+
throw new Error("runs_api_connection_unavailable");
|
|
285
|
+
const { apiKey, protocol, structuredOutputs, tools } = release;
|
|
286
|
+
const client = createWorkspaceConnectionRunsClient({
|
|
287
|
+
connection: { baseUrl: step.runsApiBaseUrl, protocol, structuredOutputs },
|
|
288
|
+
apiKey,
|
|
289
|
+
// An OpenAI-protocol provider sends its headers only when it is done, so
|
|
290
|
+
// the adapter's 750 s run signal bounds that call, not the guard.
|
|
291
|
+
fetchImpl:
|
|
292
|
+
protocol === "runs-api"
|
|
293
|
+
? createFetch()
|
|
294
|
+
: createFetch({ deadlineMs: 760_000 }),
|
|
295
|
+
modelSelection: step,
|
|
296
|
+
responseSchema: claim.revision?.outputSchema,
|
|
297
|
+
});
|
|
298
|
+
return customRunsClient(
|
|
299
|
+
client,
|
|
300
|
+
claim,
|
|
301
|
+
tools && {
|
|
302
|
+
serverUrl: tools.serverUrl,
|
|
303
|
+
authorization: tools.authorization,
|
|
304
|
+
allowedTools: tools.allowedTools,
|
|
305
|
+
}
|
|
306
|
+
);
|
|
307
|
+
}
|
|
213
308
|
if (!selection?.runsApiBaseUrl) return clientFactory({ apiKeyFile });
|
|
214
309
|
const model = compileEndpointModelSelection(selection);
|
|
215
310
|
if (!model || model === "invalid") throw new Error("runs_api_model_rejected");
|
|
@@ -251,27 +346,5 @@ export function createConfiguredRunsClient({
|
|
|
251
346
|
"x-sellable-agent-id": connection.agentId,
|
|
252
347
|
},
|
|
253
348
|
});
|
|
254
|
-
return
|
|
255
|
-
...client,
|
|
256
|
-
submit(compiled, options) {
|
|
257
|
-
// Public semantic endpoints have a published schema but no product tuple.
|
|
258
|
-
// Preserve that existing contract when calling a custom Runs service.
|
|
259
|
-
const schema = claim.revision?.outputSchema;
|
|
260
|
-
if (compiled.outputContract || !schema?.properties?.proposal)
|
|
261
|
-
return client.submit(compiled, options);
|
|
262
|
-
return client.submit(
|
|
263
|
-
{
|
|
264
|
-
...compiled,
|
|
265
|
-
outputContract: {
|
|
266
|
-
schemaVersion: "sellable-opener-output-contract/v1",
|
|
267
|
-
schema,
|
|
268
|
-
nativeProposalSchemaDigest: createHash("sha256")
|
|
269
|
-
.update(stable(schema.properties.proposal))
|
|
270
|
-
.digest("hex"),
|
|
271
|
-
},
|
|
272
|
-
},
|
|
273
|
-
options
|
|
274
|
-
);
|
|
275
|
-
},
|
|
276
|
-
});
|
|
349
|
+
return customRunsClient(client, claim);
|
|
277
350
|
}
|