@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
@@ -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
  }
@@ -1,4 +1,3 @@
1
- import { randomUUID } from "node:crypto";
2
1
  import { readFile } from "node:fs/promises";
3
2
  import { pathToFileURL } from "node:url";
4
3
  import {
@@ -14,6 +13,7 @@ import {
14
13
  runsApiConnectionServesEndpoint,
15
14
  runsApiConnectionsForRuntime,
16
15
  } from "./runs-api-connections.mjs";
16
+ import { probeRunsClient } from "./workspace-connection-client.mjs";
17
17
 
18
18
  // Fixed read-only machine command. Callers supply identities, never a URL,
19
19
  // credential path, shell command, or prompt. The installed registry authorizes
@@ -142,6 +142,10 @@ export async function verifyRunsApiSelection(
142
142
  // the primary step's check the whole chain, so a machine whose compiler
143
143
  // cannot run a chain refuses it here and no chain is saved for it.
144
144
  const selection = endpointModelSelectionChain(compiled)[0] ?? null;
145
+ // A workspace connection's key is fetched per attempt by the run, so the
146
+ // machine holds none to probe with. This is a shape check only; the server
147
+ // performs the connectivity probe (228-05).
148
+ if (selection?.modelConnectionId) return result("REACHABLE");
145
149
  let verificationWorkKind = "STANDARD";
146
150
  if (selection?.runsApiBaseUrl) {
147
151
  const connection = runsApiConnectionsForRuntime(
@@ -171,10 +175,9 @@ export async function verifyRunsApiSelection(
171
175
  ? "/run/sellable-agent/admin-runtime/api-server-key"
172
176
  : `/run/sellable-agent/${request.profileId}/api-server-key`;
173
177
  let client;
174
- let run;
175
178
  try {
176
179
  client = selection?.runsApiBaseUrl
177
- ? makeConfiguredClient({
180
+ ? await makeConfiguredClient({
178
181
  apiKeyFile,
179
182
  env,
180
183
  claim: {
@@ -191,48 +194,14 @@ export async function verifyRunsApiSelection(
191
194
  },
192
195
  })
193
196
  : makeDefaultClient({ apiKeyFile });
194
- // A fresh token per check. Claude Opus 5.5 answers one fixed string
195
- // ("SELLABLE_CONNECTION_OK") with nothing, so Claude Code fell back to
196
- // another model and every Claude Code verification failed.
197
- const token = `ready-${randomUUID().replaceAll("-", "").slice(0, 8)}`;
198
- const compiled = {
199
- input: `Connection verification. Reply with ${token}.`,
200
- instructions: `This is a connection check. Do not use tools or take actions. Return exactly ${token} and nothing else.`,
201
- conversationHistory: [],
202
- correlation: `connection-check-${randomUUID()}`,
203
- ...(selection ? { modelSelection: selection } : {}),
204
- };
205
- const signal = AbortSignal.timeout(70_000);
206
- // Default Hermes gets the tool-free verification prefix. A Runs service
207
- // owns its own tool policy; the Claude and OpenAI adapters are tool-free.
208
- run = await (
209
- selection?.runsApiBaseUrl ? client.submit : client.submitVerification
210
- )(compiled, { signal });
211
- await client.streamEvents(run.runId, { signal, onEvent: async () => {} });
212
- const status = await client.status(run.runId, { signal });
213
- const served = status.servedRuntime;
214
- if (
215
- status.status !== "completed" ||
216
- typeof status.output !== "string" ||
217
- status.output.trim() !== token ||
218
- !served
219
- )
220
- return result("UNAVAILABLE");
221
- if (
222
- selection &&
223
- (served.provider !== selection.provider ||
224
- (served.model !== selection.model &&
225
- !served.model.startsWith(`${selection.model}-`)))
226
- )
227
- return result("UNAVAILABLE");
228
- return result("READY", served);
229
197
  } catch {
230
- if (client && run)
231
- await client
232
- .stop(run.runId, { signal: AbortSignal.timeout(3_000) })
233
- .catch(() => {});
234
198
  return result("UNAVAILABLE");
235
199
  }
200
+ const probe = await probeRunsClient(client, selection, {
201
+ signal: AbortSignal.timeout(70_000),
202
+ submitVerification: !selection?.runsApiBaseUrl,
203
+ });
204
+ return result(probe.state, probe.servedRuntime);
236
205
  }
237
206
 
238
207
  if (
@@ -0,0 +1,22 @@
1
+ import type { EndpointModelStep } from "./endpoint-model-selection.mjs";
2
+ export type WorkspaceConnection = Readonly<{
3
+ baseUrl: string;
4
+ protocol: "runs-api" | "openai-chat" | "openai-responses";
5
+ structuredOutputs?: boolean;
6
+ }>;
7
+ /** Every request goes through `fetchImpl`; the key is held in memory only. */
8
+ export function createWorkspaceConnectionRunsClient(options: {
9
+ connection: WorkspaceConnection;
10
+ apiKey: string;
11
+ fetchImpl: typeof fetch;
12
+ modelSelection: EndpointModelStep;
13
+ responseSchema?: unknown;
14
+ }): any;
15
+ export function probeRunsClient(
16
+ client: any,
17
+ selection: EndpointModelStep | null,
18
+ options?: { signal?: AbortSignal; submitVerification?: boolean }
19
+ ): Promise<
20
+ | { state: "READY"; servedRuntime: { provider: string; model: string } }
21
+ | { state: "UNAVAILABLE" }
22
+ >;
@@ -0,0 +1,98 @@
1
+ // Builds the Runs client for a workspace's own model connection. The Next
2
+ // server and the agent runtime both import only this module for workspace
3
+ // connections, so it has no top-level code beyond imports and declarations.
4
+ import { randomUUID } from "node:crypto";
5
+ import { createHermesRunsClient } from "./hermes-runs-client.mjs";
6
+ import { createOpenAiRunsClient } from "./openai-runs-client.mjs";
7
+
8
+ /** @param {any} options */
9
+ export function createWorkspaceConnectionRunsClient({
10
+ connection,
11
+ apiKey,
12
+ fetchImpl,
13
+ modelSelection,
14
+ responseSchema,
15
+ }) {
16
+ // The caller's fetchImpl is the address guard (createPinnedFetch). Without it
17
+ // both clients would fall back to the global fetch and dial unguarded.
18
+ if (typeof fetchImpl !== "function")
19
+ throw new Error("workspace_connection_fetch_required");
20
+ const { baseUrl, protocol, structuredOutputs } = connection;
21
+ // No registry entry and no scope headers: the key belongs to the workspace's
22
+ // connection, and every request goes through the caller's fetchImpl.
23
+ if (protocol === "runs-api")
24
+ return createHermesRunsClient({
25
+ baseUrl,
26
+ apiKey,
27
+ fetchImpl,
28
+ scopeHeaders: {},
29
+ });
30
+ if (protocol !== "openai-chat" && protocol !== "openai-responses")
31
+ throw new Error("workspace_connection_protocol_rejected");
32
+ const { provider, model, reasoningEffort } = modelSelection;
33
+ return createOpenAiRunsClient({
34
+ connection: {
35
+ baseUrl,
36
+ protocol,
37
+ structuredOutputs,
38
+ models: [{ provider, model, reasoningEfforts: [reasoningEffort] }],
39
+ },
40
+ apiKey,
41
+ fetchImpl,
42
+ modelSelection,
43
+ responseSchema,
44
+ });
45
+ }
46
+
47
+ // One ready-token probe for the machine verify and the server's connection
48
+ // check. It never reads a key: the caller hands it a built client.
49
+ /** @param {any} client @param {any} selection @param {any} [options] */
50
+ export async function probeRunsClient(
51
+ client,
52
+ selection,
53
+ { signal, submitVerification = false } = {}
54
+ ) {
55
+ let run;
56
+ try {
57
+ // A fresh token per check. Claude Opus 5.5 answers one fixed string
58
+ // ("SELLABLE_CONNECTION_OK") with nothing, so Claude Code fell back to
59
+ // another model and every Claude Code verification failed.
60
+ const token = `ready-${randomUUID().replaceAll("-", "").slice(0, 8)}`;
61
+ const compiled = {
62
+ input: `Connection verification. Reply with ${token}.`,
63
+ instructions: `This is a connection check. Do not use tools or take actions. Return exactly ${token} and nothing else.`,
64
+ conversationHistory: [],
65
+ correlation: `connection-check-${randomUUID()}`,
66
+ ...(selection ? { modelSelection: selection } : {}),
67
+ };
68
+ // Default Hermes gets the tool-free verification prefix. A Runs service
69
+ // owns its own tool policy; the Claude and OpenAI adapters are tool-free.
70
+ run = await (
71
+ submitVerification ? client.submitVerification : client.submit
72
+ )(compiled, { signal });
73
+ await client.streamEvents(run.runId, { signal, onEvent: async () => {} });
74
+ const status = await client.status(run.runId, { signal });
75
+ const served = status.servedRuntime;
76
+ if (
77
+ status.status !== "completed" ||
78
+ typeof status.output !== "string" ||
79
+ status.output.trim() !== token ||
80
+ !served
81
+ )
82
+ return { state: "UNAVAILABLE" };
83
+ if (
84
+ selection &&
85
+ (served.provider !== selection.provider ||
86
+ (served.model !== selection.model &&
87
+ !served.model.startsWith(`${selection.model}-`)))
88
+ )
89
+ return { state: "UNAVAILABLE" };
90
+ return { state: "READY", servedRuntime: served };
91
+ } catch {
92
+ if (run)
93
+ await client
94
+ .stop(run.runId, { signal: AbortSignal.timeout(3_000) })
95
+ .catch(() => {});
96
+ return { state: "UNAVAILABLE" };
97
+ }
98
+ }
@@ -1305,6 +1305,14 @@ export function createFlyCustomerControlClient({
1305
1305
  { replaySafe: true }
1306
1306
  );
1307
1307
  },
1308
+ async fetchEndpointConnectionKey(identityBody) {
1309
+ return post(
1310
+ "endpoint-connection-key",
1311
+ "/api/v3/sellable-agent/worker/endpoint-connection-key",
1312
+ identityBody,
1313
+ { replaySafe: true }
1314
+ );
1315
+ },
1308
1316
  async consumeSecret(identityBody) {
1309
1317
  // The broker makes only this verb replay-safe for the exact claim ID.
1310
1318
  return post(
@@ -38,7 +38,7 @@ import { installAgentHostServices } from "./service-installer.mjs";
38
38
  const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
39
39
  const VERSION = "sellable-agent-host-bootstrap/v1";
40
40
  const RECEIPT_VERSION = "sellable-agent-host-registration/v1";
41
- const INSTALLER_PACKAGE = "@sellable/install@0.1.803";
41
+ const INSTALLER_PACKAGE = "@sellable/install@0.1.804";
42
42
  const MCP_PACKAGE = "@sellable/mcp@0.1.957";
43
43
  const HERMES_VERSION = "0.18.0";
44
44
  const SHA256 = /^[a-f0-9]{64}$/;
@@ -1546,7 +1546,7 @@ export function compileClaimToProfileDesired(activeClaim, config) {
1546
1546
  ]) ||
1547
1547
  pinned.hermesCli !== "hermes" ||
1548
1548
  pinned.hermesVersion !== "0.18.0" ||
1549
- pinned.installerPackage !== "@sellable/install@0.1.803" ||
1549
+ pinned.installerPackage !== "@sellable/install@0.1.804" ||
1550
1550
  pinned.mcpPackage !== "@sellable/mcp@0.1.957" ||
1551
1551
  !Array.isArray(policy.toolInclude) ||
1552
1552
  policy.toolInclude.length === 0 ||
@@ -312,7 +312,7 @@ function validateDesired(desired) {
312
312
  desired.serviceCredentialGeneration < 1 ||
313
313
  desired.hermesCli !== "hermes" ||
314
314
  desired.hermesVersion !== "0.18.0" ||
315
- desired.installerPackage !== "@sellable/install@0.1.803" ||
315
+ desired.installerPackage !== "@sellable/install@0.1.804" ||
316
316
  desired.mcpPackage !== "@sellable/mcp@0.1.957" ||
317
317
  !Array.isArray(desired.toolInclude) ||
318
318
  desired.toolInclude.length === 0 ||
@@ -25,7 +25,7 @@ import { fileURLToPath } from "node:url";
25
25
  import { deriveContainedProfileId } from "./profile-materializer.mjs";
26
26
 
27
27
  export const PROVISIONING_ACTION = "PROVISION_HERMES_PROFILE";
28
- export const PINNED_INSTALL_PACKAGE = "@sellable/install@0.1.803";
28
+ export const PINNED_INSTALL_PACKAGE = "@sellable/install@0.1.804";
29
29
  export const PINNED_MCP_PACKAGE = "@sellable/mcp@0.1.957";
30
30
  const PINNED_INSTALL_VERSION = PINNED_INSTALL_PACKAGE.slice(
31
31
  PINNED_INSTALL_PACKAGE.lastIndexOf("@") + 1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sellable/install",
3
- "version": "0.1.803",
3
+ "version": "0.1.804",
4
4
  "type": "module",
5
5
  "description": "One-command installer for Sellable MCP in Claude Code, Codex, and Hermes",
6
6
  "bin": {