@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.
Files changed (36) hide show
  1. package/agents/post-find-leads-message-scout.md +4 -4
  2. package/agents/registry.json +1 -1
  3. package/bin/sellable-install.mjs +36 -20
  4. package/container/Dockerfile +4 -4
  5. package/container/README.md +1 -1
  6. package/lib/sellable-agent/default-profile-bundles/shared/manifest.fragment.json +1 -1
  7. package/lib/sellable-agent/default-profile-bundles/shared/skills/sellable/sellable-create-campaign/SKILL.md +3 -3
  8. package/lib/sellable-agent/fly-admin-image/Dockerfile +4 -2
  9. package/lib/sellable-agent/fly-admin-image/admin-runtime.mjs +19 -3
  10. package/lib/sellable-agent/fly-customer-image/Dockerfile +3 -3
  11. package/lib/sellable-agent/fly-customer-image/claude-code-runs-api.d.mts +23 -3
  12. package/lib/sellable-agent/fly-customer-image/claude-code-runs-api.mjs +197 -52
  13. package/lib/sellable-agent/fly-customer-image/customer-runtime.mjs +48 -22
  14. package/lib/sellable-agent/fly-customer-image/endpoint-model-selection.d.mts +1 -0
  15. package/lib/sellable-agent/fly-customer-image/endpoint-model-selection.mjs +21 -4
  16. package/lib/sellable-agent/fly-customer-image/hermes-runs-client.mjs +61 -5
  17. package/lib/sellable-agent/fly-customer-image/openai-runs-client.d.mts +3 -1
  18. package/lib/sellable-agent/fly-customer-image/openai-runs-client.mjs +4 -1
  19. package/lib/sellable-agent/fly-customer-image/public-endpoint-guard.d.mts +28 -0
  20. package/lib/sellable-agent/fly-customer-image/public-endpoint-guard.mjs +209 -0
  21. package/lib/sellable-agent/fly-customer-image/runs-api-connections.d.mts +1 -1
  22. package/lib/sellable-agent/fly-customer-image/runs-api-connections.mjs +97 -24
  23. package/lib/sellable-agent/fly-customer-image/runs-api-health-check.mjs +11 -42
  24. package/lib/sellable-agent/fly-customer-image/workspace-connection-client.d.mts +22 -0
  25. package/lib/sellable-agent/fly-customer-image/workspace-connection-client.mjs +98 -0
  26. package/lib/sellable-agent/fly-runtime-identity.mjs +8 -0
  27. package/lib/sellable-agent/host-bootstrap.mjs +1 -1
  28. package/lib/sellable-agent/host-worker.mjs +1 -1
  29. package/lib/sellable-agent/profile-materializer.mjs +1 -1
  30. package/lib/sellable-agent/provisioning-adapter.mjs +1 -1
  31. package/lib/sellable-agent/public-skills.json +4 -4
  32. package/lib/sellable-agent/public-skills.mjs +35 -0
  33. package/package.json +1 -1
  34. package/skill-templates/create-campaign.md +3 -3
  35. package/skill-templates/{conversation-chess.md → draft-message.md} +7 -6
  36. 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
- (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>;
@@ -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 Object.freeze({
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
  }