@sellable/install 0.1.802 → 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 +216 -62
  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
@@ -3,12 +3,12 @@ FROM ${HERMES_IMAGE}
3
3
 
4
4
  USER root
5
5
 
6
- ARG INSTALLER_PACKAGE=@sellable/install@0.1.802
6
+ ARG INSTALLER_PACKAGE=@sellable/install@0.1.804
7
7
  ARG MCP_PACKAGE=@sellable/mcp@0.1.957
8
8
  ARG INSTALLER_INTEGRITY
9
9
  ARG MCP_INTEGRITY=sha512-tIZuO5IEanT/afamDIWdorUNMdqZA6xnjL9jGAmGqYIlIqkXdHB9slWiwfLfmM44iEp7OT98IBHJlvbpMThk6A==
10
10
 
11
- RUN test "${INSTALLER_PACKAGE}" = "@sellable/install@0.1.802" \
11
+ RUN test "${INSTALLER_PACKAGE}" = "@sellable/install@0.1.804" \
12
12
  && test "${MCP_PACKAGE}" = "@sellable/mcp@0.1.957" \
13
13
  && test -n "${INSTALLER_INTEGRITY}" \
14
14
  && test "$(npm view "${INSTALLER_PACKAGE}" dist.integrity)" = "${INSTALLER_INTEGRITY}" \
@@ -23,9 +23,9 @@ RUN test "${INSTALLER_PACKAGE}" = "@sellable/install@0.1.802" \
23
23
  && mkdir -p /opt/sellable-agent/install-root /opt/sellable-agent/mcp-root \
24
24
  && npm install --prefix /opt/sellable-agent/install-root --omit=dev --ignore-scripts --no-audit --no-fund "${INSTALLER_PACKAGE}" \
25
25
  && npm install --prefix /opt/sellable-agent/mcp-root --omit=dev --ignore-scripts --no-audit --no-fund "${MCP_PACKAGE}" \
26
- && node --input-type=module -e "import { readFileSync } from 'node:fs'; const install = JSON.parse(readFileSync('/opt/sellable-agent/install-root/node_modules/@sellable/install/package.json')); const mcp = JSON.parse(readFileSync('/opt/sellable-agent/mcp-root/node_modules/@sellable/mcp/package.json')); if (install.name !== '@sellable/install' || install.version !== '0.1.802' || mcp.name !== '@sellable/mcp' || mcp.version !== '0.1.957') throw new Error('package identity rejected');" \
26
+ && node --input-type=module -e "import { readFileSync } from 'node:fs'; const install = JSON.parse(readFileSync('/opt/sellable-agent/install-root/node_modules/@sellable/install/package.json')); const mcp = JSON.parse(readFileSync('/opt/sellable-agent/mcp-root/node_modules/@sellable/mcp/package.json')); if (install.name !== '@sellable/install' || install.version !== '0.1.804' || mcp.name !== '@sellable/mcp' || mcp.version !== '0.1.957') throw new Error('package identity rejected');" \
27
27
  && node --input-type=module -e "import { buildExternalRuntimeClosure } from '/opt/sellable-agent/install-root/node_modules/@sellable/install/lib/sellable-agent/external-runtime-builder.mjs'; const built = buildExternalRuntimeClosure({ mcpPackageRoot: '/opt/sellable-agent/mcp-root/node_modules/@sellable/mcp', mcpNodeModulesRoot: '/opt/sellable-agent/mcp-root/node_modules', hermesSourceRoot: '/opt/hermes', ownerUid: 0, ownerGid: 0 }); if (!built.closureDigest) throw new Error('runtime closure rejected');" \
28
- && node --input-type=module -e "import { chmodSync, writeFileSync } from 'node:fs'; const release = { installerPackage: '@sellable/install@0.1.802', installerIntegrity: process.argv[1], mcpPackage: '@sellable/mcp@0.1.957', mcpIntegrity: process.argv[2] }; writeFileSync('/opt/sellable-agent/release.json', JSON.stringify(release) + '\\n', { mode: 0o444 }); chmodSync('/opt/sellable-agent/release.json', 0o444);" "${INSTALLER_INTEGRITY}" "${MCP_INTEGRITY}" \
28
+ && node --input-type=module -e "import { chmodSync, writeFileSync } from 'node:fs'; const release = { installerPackage: '@sellable/install@0.1.804', installerIntegrity: process.argv[1], mcpPackage: '@sellable/mcp@0.1.957', mcpIntegrity: process.argv[2] }; writeFileSync('/opt/sellable-agent/release.json', JSON.stringify(release) + '\\n', { mode: 0o444 }); chmodSync('/opt/sellable-agent/release.json', 0o444);" "${INSTALLER_INTEGRITY}" "${MCP_INTEGRITY}" \
29
29
  && npm cache clean --force
30
30
 
31
31
  COPY entrypoint.sh /usr/local/bin/sellable-agent-container-entrypoint
@@ -3,7 +3,7 @@
3
3
  This image is a dedicated Agent worker/runtime boundary beside an existing Hermes dashboard. It never replaces or reconfigures the dashboard container.
4
4
 
5
5
  Build from this directory after
6
- `@sellable/install@0.1.802` is
6
+ `@sellable/install@0.1.804` is
7
7
  published:
8
8
 
9
9
  ```sh
@@ -169,7 +169,7 @@ COPY fly-admin-image/start-admin-runtime /usr/local/lib/sellable-agent/admin-run
169
169
  COPY fly-customer-image/customer-runtime.mjs /usr/local/lib/sellable-agent/fly-customer-image/customer-runtime.mjs
170
170
  COPY hermes-first-welcome.mjs /usr/local/lib/sellable-agent/hermes-first-welcome.mjs
171
171
  COPY fly-customer-image/hermes-runs-client.mjs /usr/local/lib/sellable-agent/fly-customer-image/hermes-runs-client.mjs
172
- COPY fly-customer-image/endpoint-model-selection.mjs fly-customer-image/runs-api-connections.mjs fly-customer-image/claude-code-runs-api.mjs fly-customer-image/claude-code-mcp-session.mjs fly-customer-image/openai-runs-client.mjs fly-customer-image/runs-api-health-check.mjs /usr/local/lib/sellable-agent/fly-customer-image/
172
+ COPY fly-customer-image/endpoint-model-selection.mjs fly-customer-image/runs-api-connections.mjs fly-customer-image/claude-code-runs-api.mjs fly-customer-image/claude-code-mcp-session.mjs fly-customer-image/openai-runs-client.mjs fly-customer-image/runs-api-health-check.mjs fly-customer-image/public-endpoint-guard.mjs fly-customer-image/workspace-connection-client.mjs /usr/local/lib/sellable-agent/fly-customer-image/
173
173
  COPY fly-customer-image/opener-stage-execution.mjs /usr/local/lib/sellable-agent/fly-customer-image/opener-stage-execution.mjs
174
174
  COPY fly-customer-image/endpoint-uat-completion-gate.mjs /usr/local/lib/sellable-agent/fly-customer-image/endpoint-uat-completion-gate.mjs
175
175
  COPY fly-customer-image/customer-runtime-phase-telemetry.mjs /usr/local/lib/sellable-agent/fly-customer-image/customer-runtime-phase-telemetry.mjs
@@ -277,6 +277,8 @@ RUN set -eux; \
277
277
  /usr/local/lib/sellable-agent/fly-customer-image/runs-api-connections.mjs \
278
278
  /usr/local/lib/sellable-agent/fly-customer-image/claude-code-runs-api.mjs \
279
279
  /usr/local/lib/sellable-agent/fly-customer-image/claude-code-mcp-session.mjs \
280
+ /usr/local/lib/sellable-agent/fly-customer-image/public-endpoint-guard.mjs \
281
+ /usr/local/lib/sellable-agent/fly-customer-image/workspace-connection-client.mjs \
280
282
  /usr/local/lib/sellable-agent/fly-customer-image/opener-stage-execution.mjs \
281
283
  /usr/local/lib/sellable-agent/fly-customer-image/endpoint-uat-completion-gate.mjs \
282
284
  /usr/local/lib/sellable-agent/admin-native-gateway-supervisor.mjs \
@@ -61,9 +61,12 @@ import {
61
61
  SELLABLE_BROWSER_TOOLSET_DEFAULT_MARKER,
62
62
  sellableSlackToolsetsForMaterialization,
63
63
  } from "../fly-capabilities-bridge.mjs";
64
+ import {
65
+ startConfiguredClaudeRunsApis,
66
+ startPublicClaudeRunsApi,
67
+ } from "../fly-customer-image/claude-code-runs-api.mjs";
64
68
  import { createCustomerEndpointRelay } from "../fly-customer-image/customer-runtime.mjs";
65
69
  import { createHermesSessionRetention } from "../fly-customer-image/hermes-runs-client.mjs";
66
- import { startConfiguredClaudeRunsApis } from "../fly-customer-image/claude-code-runs-api.mjs";
67
70
  import {
68
71
  claimWithSoulObservation,
69
72
  createFlyCustomerControlClient,
@@ -3847,7 +3850,7 @@ async function runAdminProduction() {
3847
3850
  let customRunsServices = null;
3848
3851
  try {
3849
3852
  customRunsServices = await startConfiguredClaudeRunsApis({
3850
- diagnostic: value => process.stderr.write(`${JSON.stringify(value)}\n`),
3853
+ diagnostic: (value) => process.stderr.write(`${JSON.stringify(value)}\n`),
3851
3854
  toolLanes: {
3852
3855
  turnDescriptorPath: join(MCP_TURN_CONTEXT_ROOT, "turn-context.json"),
3853
3856
  server: () =>
@@ -3857,7 +3860,19 @@ async function runAdminProduction() {
3857
3860
  },
3858
3861
  });
3859
3862
  } catch {
3860
- process.stderr.write(`${JSON.stringify({ ok: false, code: "custom_runs_api_start_failed" })}\n`);
3863
+ process.stderr.write(
3864
+ `${JSON.stringify({ ok: false, code: "custom_runs_api_start_failed" })}\n`
3865
+ );
3866
+ }
3867
+ let publicRunsService = null;
3868
+ try {
3869
+ publicRunsService = await startPublicClaudeRunsApi({
3870
+ diagnostic: (value) => process.stderr.write(`${JSON.stringify(value)}\n`),
3871
+ });
3872
+ } catch {
3873
+ process.stderr.write(
3874
+ `${JSON.stringify({ ok: false, code: "public_runs_api_start_failed" })}\n`
3875
+ );
3861
3876
  }
3862
3877
  const endpointRelay = createCustomerEndpointRelay({
3863
3878
  getControlClient: () => client,
@@ -4496,6 +4511,7 @@ async function runAdminProduction() {
4496
4511
  }
4497
4512
  await endpointLoop;
4498
4513
  await customRunsServices?.stop();
4514
+ await publicRunsService?.stop();
4499
4515
  await activeModelHealthReporter?.stop();
4500
4516
  await stopNativeGateway(activeGateway);
4501
4517
  await clearRuntimeSecrets();
@@ -8,7 +8,7 @@ ENV PLAYWRIGHT_BROWSERS_PATH=/opt/hermes/.playwright
8
8
 
9
9
  ARG MCP_PACKAGE=@sellable/mcp@0.1.957
10
10
  ARG MCP_INTEGRITY=sha512-tIZuO5IEanT/afamDIWdorUNMdqZA6xnjL9jGAmGqYIlIqkXdHB9slWiwfLfmM44iEp7OT98IBHJlvbpMThk6A==
11
- ARG INSTALLER_PACKAGE=@sellable/install@0.1.802
11
+ ARG INSTALLER_PACKAGE=@sellable/install@0.1.804
12
12
  ARG ADMIN_MCP_PACKAGE=@sellable/admin-mcp@0.1.202
13
13
  ARG ADMIN_MCP_INTEGRITY=sha512-ym3aye/0vQ8tfULJlKz9JFOxvIQsl1G+n3np+TTgK/tecwNblBo/j3AkTnRqH9iomKzPbZzP+n4tEQsvsU8ByA==
14
14
  ARG TUS_JS_CLIENT_VERSION=4.3.1
@@ -98,7 +98,7 @@ COPY hermes-memory-reconcile.sh /opt/sellable/share/hermes-memory-reconcile.sh
98
98
  COPY hermes-endpoint-cron.mjs /opt/sellable/bin/hermes-endpoint-cron
99
99
  COPY fly-customer-image/model-auth-store.mjs /usr/local/lib/sellable-agent/fly-customer-image/model-auth-store.mjs
100
100
  COPY fly-customer-image/hermes-runs-client.mjs /usr/local/lib/sellable-agent/fly-customer-image/hermes-runs-client.mjs
101
- COPY fly-customer-image/endpoint-model-selection.mjs fly-customer-image/runs-api-connections.mjs fly-customer-image/openai-runs-client.mjs fly-customer-image/runs-api-health-check.mjs /usr/local/lib/sellable-agent/fly-customer-image/
101
+ COPY fly-customer-image/endpoint-model-selection.mjs fly-customer-image/runs-api-connections.mjs fly-customer-image/openai-runs-client.mjs fly-customer-image/runs-api-health-check.mjs fly-customer-image/public-endpoint-guard.mjs fly-customer-image/workspace-connection-client.mjs /usr/local/lib/sellable-agent/fly-customer-image/
102
102
  COPY fly-customer-image/opener-stage-execution.mjs /usr/local/lib/sellable-agent/fly-customer-image/opener-stage-execution.mjs
103
103
  COPY fly-customer-image/endpoint-uat-completion-gate.mjs /usr/local/lib/sellable-agent/fly-customer-image/endpoint-uat-completion-gate.mjs
104
104
  COPY fly-customer-image/customer-runtime-phase-telemetry.mjs /usr/local/lib/sellable-agent/fly-customer-image/customer-runtime-phase-telemetry.mjs
@@ -10,7 +10,9 @@ export type ClaudeRunsRequest = {
10
10
  model: string;
11
11
  effort: string;
12
12
  sessionId: string;
13
- tools: ClaudeRunsTools | null;
13
+ tools: ClaudeRunsTools | ClaudeRunsRemoteTools | null;
14
+ /** Remote tools: the run's private MCP config file, written by the service. */
15
+ mcpConfigPath?: string;
14
16
  };
15
17
  export type ClaudeRunsToolServer = {
16
18
  command: string;
@@ -18,6 +20,11 @@ export type ClaudeRunsToolServer = {
18
20
  env: Record<string, string>;
19
21
  };
20
22
  export type ClaudeRunsTools = { names: string[]; server: ClaudeRunsToolServer };
23
+ /** The run's own remote MCP server and grant (public mode). */
24
+ export type ClaudeRunsRemoteTools = {
25
+ names: string[];
26
+ remote: { url: string; authorization: string };
27
+ };
21
28
  export type ClaudeRunsToolLanes = {
22
29
  turnDescriptorPath: string;
23
30
  server(): ClaudeRunsToolServer | null;
@@ -30,7 +37,11 @@ export type ClaudeRunsResult = {
30
37
  export type ClaudeRunsServer = Server & { stopActiveRuns(): Promise<void> };
31
38
  export type ClaudeRunsApiOptions = {
32
39
  token: string;
33
- connection: RunsApiConnection;
40
+ /** Required in loopback mode; the public listener has none. */
41
+ connection?: RunsApiConnection;
42
+ mode?: "loopback" | "public";
43
+ /** The CLI and account root; defaults to the connection's localRoot. */
44
+ root?: string;
34
45
  execute?:
35
46
  | ((request: ClaudeRunsRequest) => {
36
47
  promise: Promise<ClaudeRunsResult>;
@@ -53,7 +64,8 @@ export type ClaudeRunsApiOptions = {
53
64
  };
54
65
  export function validateClaudeRunsRequest(
55
66
  body: unknown,
56
- models: RunsApiConnection["models"]
67
+ /** null: the public rule (claude-* model, low/medium/high effort). */
68
+ models: RunsApiConnection["models"] | null
57
69
  ): ClaudeRunsRequest;
58
70
  export function endpointToolNames(options: {
59
71
  turnDescriptorPath: string;
@@ -96,6 +108,14 @@ export function startConfiguredClaudeRunsApis(options?: {
96
108
  toolLanes?: ClaudeRunsToolLanes | null;
97
109
  }): Promise<{ stop(): Promise<void> }>;
98
110
 
111
+ export function startPublicClaudeRunsApi(options?: {
112
+ env?: Record<string, string | undefined>;
113
+ diagnostic?: (entry: Record<string, unknown>) => void;
114
+ root?: string;
115
+ createApi?: typeof createClaudeRunsApi;
116
+ readToken?: (path: string) => string;
117
+ }): Promise<{ stop(): Promise<void> } | null>;
118
+
99
119
  export function readClaudeAuthentication(options: {
100
120
  root: string;
101
121
  configDir?: string;
@@ -2,8 +2,10 @@ import { execFile, spawn } from "node:child_process";
2
2
  import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
3
3
  import {
4
4
  appendFileSync,
5
+ chmodSync,
5
6
  existsSync,
6
7
  lstatSync,
8
+ mkdirSync,
7
9
  readdirSync,
8
10
  readFileSync,
9
11
  rmSync,
@@ -16,6 +18,7 @@ import {
16
18
  endpointHermesSessionId,
17
19
  endpointMcpDescriptorPath,
18
20
  } from "../mcp-context-proxy.mjs";
21
+ import { normalizeRunsApiBaseUrl } from "./endpoint-model-selection.mjs";
19
22
  import { validateOpenerOutputContract } from "./hermes-runs-client.mjs";
20
23
  import {
21
24
  parseRunsApiConnections,
@@ -33,6 +36,42 @@ const MCP_SESSION = fileURLToPath(
33
36
  );
34
37
  const hash = (s) => createHash("sha256").update(s).digest("hex");
35
38
  const record = (v) => v && typeof v === "object" && !Array.isArray(v);
39
+ // The public service (models null) has no model list: any Claude model, at
40
+ // the efforts the CLI takes.
41
+ const publicModel = (body) =>
42
+ typeof body.model === "string" &&
43
+ /^claude-[a-z0-9-]{1,100}$/.test(body.model) &&
44
+ ["low", "medium", "high"].includes(body.model_options.reasoning_effort);
45
+
46
+ // The run's one remote MCP server, in the shape the Runs client sends it.
47
+ function remoteTools(tools) {
48
+ const entry = Array.isArray(tools) && tools.length === 1 ? tools[0] : null;
49
+ const url = normalizeRunsApiBaseUrl(entry?.server_url);
50
+ const names = entry?.allowed_tools;
51
+ if (
52
+ !record(entry) ||
53
+ Object.keys(entry).sort().join(",") !==
54
+ "allowed_tools,authorization,require_approval,server_label,server_url,type" ||
55
+ entry.type !== "mcp" ||
56
+ entry.server_label !== "sellable" ||
57
+ entry.require_approval !== "never" ||
58
+ !url?.startsWith("https://") ||
59
+ typeof entry.authorization !== "string" ||
60
+ !/^srt_[A-Za-z0-9-]{1,128}_[A-Za-z0-9_-]{43}$/.test(entry.authorization) ||
61
+ !Array.isArray(names) ||
62
+ names.length < 1 ||
63
+ names.length > 32 ||
64
+ new Set(names).size !== names.length ||
65
+ names.some(
66
+ (name) => typeof name !== "string" || !/^[a-z][a-z0-9_]{0,63}$/.test(name)
67
+ )
68
+ )
69
+ throw new Error("claude_runs_tools_rejected");
70
+ return {
71
+ names: [...names],
72
+ remote: { url, authorization: entry.authorization },
73
+ };
74
+ }
36
75
 
37
76
  export function validateClaudeRunsRequest(body, models) {
38
77
  const allowed = [
@@ -43,6 +82,7 @@ export function validateClaudeRunsRequest(body, models) {
43
82
  "provider",
44
83
  "model",
45
84
  "model_options",
85
+ ...(models === null ? ["tools"] : []),
46
86
  ];
47
87
  if (
48
88
  !record(body) ||
@@ -70,12 +110,14 @@ export function validateClaudeRunsRequest(body, models) {
70
110
  (key) =>
71
111
  !["reasoning_effort", "sellable_opener_output_contract"].includes(key)
72
112
  ) ||
73
- !models.some(
74
- (model) =>
75
- model.provider === body.provider &&
76
- model.model === body.model &&
77
- model.reasoningEfforts.includes(body.model_options.reasoning_effort)
78
- )
113
+ !(models === null
114
+ ? publicModel(body)
115
+ : models.some(
116
+ (model) =>
117
+ model.provider === body.provider &&
118
+ model.model === body.model &&
119
+ model.reasoningEfforts.includes(body.model_options.reasoning_effort)
120
+ ))
79
121
  )
80
122
  throw new Error("claude_runs_request_rejected");
81
123
  const contract = body.model_options.sellable_opener_output_contract;
@@ -95,7 +137,7 @@ export function validateClaudeRunsRequest(body, models) {
95
137
  model: body.model,
96
138
  effort: body.model_options.reasoning_effort,
97
139
  sessionId: body.session_id,
98
- tools: null,
140
+ tools: body.tools === undefined ? null : remoteTools(body.tools),
99
141
  };
100
142
  }
101
143
 
@@ -162,18 +204,22 @@ export function claudeInvocation(
162
204
  "dontAsk",
163
205
  "--strict-mcp-config",
164
206
  "--mcp-config",
165
- // A tool lane's only MCP server is the run's own session wrapper; its
166
- // allowed tools are exactly the descriptor's. Every other run has none.
207
+ // A tool lane's only MCP server is the run's own session wrapper, and a
208
+ // public run's is its own remote server, read from its private file.
209
+ // Either way its allowed tools are exactly the run's. Every other run
210
+ // has none.
167
211
  ...(request.tools
168
212
  ? [
169
- JSON.stringify({
170
- mcpServers: {
171
- sellable: {
172
- command: process.execPath,
173
- args: [MCP_SESSION, mcpSpecPath],
174
- },
175
- },
176
- }),
213
+ request.tools.remote
214
+ ? request.mcpConfigPath
215
+ : JSON.stringify({
216
+ mcpServers: {
217
+ sellable: {
218
+ command: process.execPath,
219
+ args: [MCP_SESSION, mcpSpecPath],
220
+ },
221
+ },
222
+ }),
177
223
  "--allowedTools",
178
224
  request.tools.names
179
225
  .map((name) => `mcp__sellable__${name}`)
@@ -332,6 +378,9 @@ export function claudeUsageLimit(stdout, now = Date.now()) {
332
378
  return { retryAt };
333
379
  }
334
380
 
381
+ // A failure reason the run may report: a fixed code, never raw CLI text.
382
+ const RUN_FAILURE_CODE = /^claude_runs_[a-z_]{1,48}$/;
383
+
335
384
  export function parseClaudeResult(stdout, request) {
336
385
  const result = JSON.parse(stdout);
337
386
  const models = Object.keys(result.modelUsage ?? {});
@@ -341,15 +390,19 @@ export function parseClaudeResult(stdout, request) {
341
390
  result.subtype !== "success" ||
342
391
  (request.schema
343
392
  ? !record(result.structured_output)
344
- : typeof result.result !== "string") ||
345
- (result.permission_denials ?? []).length ||
393
+ : typeof result.result !== "string")
394
+ )
395
+ throw new Error("claude_runs_output_rejected");
396
+ if ((result.permission_denials ?? []).length)
397
+ throw new Error("claude_runs_permission_denied");
398
+ if (
346
399
  !models.length ||
347
400
  models.some(
348
401
  (model) =>
349
402
  model !== request.model && !model.startsWith(`${request.model}-`)
350
403
  )
351
404
  )
352
- throw new Error("claude_runs_output_rejected");
405
+ throw new Error("claude_runs_model_mismatch");
353
406
  const output = request.schema
354
407
  ? JSON.stringify(result.structured_output)
355
408
  : result.result;
@@ -379,7 +432,7 @@ export function startClaudeRun(
379
432
  ) {
380
433
  // The tool server's environment travels in a private per-run file, never
381
434
  // in argv, and is removed when the run ends.
382
- const mcpSpecPath = request.tools
435
+ const mcpSpecPath = request.tools?.server
383
436
  ? join(root, "work", `mcp-session-${randomBytes(12).toString("hex")}.json`)
384
437
  : null;
385
438
  if (mcpSpecPath)
@@ -447,8 +500,12 @@ export function startClaudeRun(
447
500
  return reject(new Error(reason ?? "claude_runs_cli_failed"));
448
501
  try {
449
502
  resolve(parseClaudeResult(stdout, request));
450
- } catch {
451
- reject(new Error("claude_runs_output_rejected"));
503
+ } catch (error) {
504
+ reject(
505
+ error instanceof Error && RUN_FAILURE_CODE.test(error.message)
506
+ ? error
507
+ : new Error("claude_runs_output_rejected")
508
+ );
452
509
  }
453
510
  });
454
511
  child.stdin.on("error", () => {});
@@ -457,9 +514,38 @@ export function startClaudeRun(
457
514
  return { promise, stop };
458
515
  }
459
516
 
517
+ // A public run's remote server and grant, in a 0600 file under a 0700
518
+ // directory of its own: never in argv or the CLI's environment.
519
+ function writeRemoteMcpConfig(dir, remote) {
520
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
521
+ chmodSync(dir, 0o700);
522
+ const stat = lstatSync(dir);
523
+ if (stat.isSymbolicLink() || !stat.isDirectory() || (stat.mode & 0o077) !== 0)
524
+ throw new Error("claude_runs_run_directory_rejected");
525
+ const path = join(dir, "mcp.json");
526
+ writeFileSync(
527
+ path,
528
+ JSON.stringify({
529
+ mcpServers: {
530
+ sellable: {
531
+ type: "http",
532
+ url: remote.url,
533
+ headers: { Authorization: `Bearer ${remote.authorization}` },
534
+ },
535
+ },
536
+ }),
537
+ { mode: 0o600, flag: "wx" }
538
+ );
539
+ return path;
540
+ }
541
+
460
542
  export function createClaudeRunsApi({
461
543
  token,
462
544
  connection,
545
+ // "public": the Admin's internet-facing listener. The bearer is its only
546
+ // scope, and a run's tools come from its own request, never a local lane.
547
+ mode = "loopback",
548
+ root = connection?.localRoot,
463
549
  execute = null,
464
550
  audit = () => {},
465
551
  maxConcurrency = CLAUDE_RUNS_MAX_CONCURRENCY,
@@ -468,8 +554,8 @@ export function createClaudeRunsApi({
468
554
  maxToolConcurrency = CLAUDE_RUNS_MAX_CONCURRENCY,
469
555
  toolLanes = null,
470
556
  checkAuthentication = (configDir) =>
471
- readClaudeAuthentication({ root: connection.localRoot, configDir }),
472
- accounts = () => claudeAccountDirs(connection.localRoot),
557
+ readClaudeAuthentication({ root, configDir }),
558
+ accounts = () => claudeAccountDirs(root),
473
559
  startRun = startClaudeRun,
474
560
  now = Date.now,
475
561
  } = {}) {
@@ -522,10 +608,7 @@ export function createClaudeRunsApi({
522
608
  failure ??= "claude_runs_sign_in_required";
523
609
  continue;
524
610
  }
525
- activeRun = startRun(request, {
526
- root: connection.localRoot,
527
- configDir,
528
- });
611
+ activeRun = startRun(request, { root, configDir });
529
612
  try {
530
613
  return await activeRun.promise;
531
614
  } catch (error) {
@@ -650,12 +733,21 @@ export function createClaudeRunsApi({
650
733
  )
651
734
  return json(res, 401, { error: "claude_runs_auth_required" });
652
735
  if (
653
- req.headers["x-sellable-workspace-id"] !== connection.workspaceId ||
654
- req.headers["x-sellable-agent-id"] !== connection.agentId
736
+ mode !== "public" &&
737
+ (req.headers["x-sellable-workspace-id"] !== connection.workspaceId ||
738
+ req.headers["x-sellable-agent-id"] !== connection.agentId)
655
739
  )
656
740
  return json(res, 403, { error: "claude_runs_scope_rejected" });
657
741
  if (req.method === "GET" && req.url === "/health") {
658
742
  const { authentication, accounts: accountHealth } = await readHealth();
743
+ // Public health names no accounts, load, or scope.
744
+ if (mode === "public")
745
+ return json(res, 200, {
746
+ ok: authentication === "READY",
747
+ authentication,
748
+ provider: "claude-code",
749
+ tools: ["mcp"],
750
+ });
659
751
  return json(res, 200, {
660
752
  ok: authentication === "READY",
661
753
  authentication,
@@ -670,7 +762,7 @@ export function createClaudeRunsApi({
670
762
  });
671
763
  }
672
764
  if (req.method === "POST" && req.url === "/v1/runs") {
673
- if (full("plain") && (!toolLanes || full("tools")))
765
+ if (full("plain") && ((mode !== "public" && !toolLanes) || full("tools")))
674
766
  return json(res, 429, { error: "claude_runs_busy" });
675
767
  let request;
676
768
  try {
@@ -685,8 +777,12 @@ export function createClaudeRunsApi({
685
777
  chunks.push(chunk);
686
778
  }
687
779
  const body = JSON.parse(Buffer.concat(chunks).toString("utf8"));
688
- request = validateClaudeRunsRequest(body, connection.models);
689
- request.tools = resolveTools(request);
780
+ request = validateClaudeRunsRequest(
781
+ body,
782
+ mode === "public" ? null : connection.models
783
+ );
784
+ // A public run's tools are its own entry's, never a local lane's.
785
+ if (mode !== "public") request.tools = resolveTools(request);
690
786
  } catch (error) {
691
787
  recordAudit({
692
788
  status: "request-rejected",
@@ -709,7 +805,18 @@ export function createClaudeRunsApi({
709
805
  };
710
806
  inFlight[pool]++;
711
807
  runs.set(run.run_id, run);
808
+ const runDir = request.tools?.remote
809
+ ? join(root, "work", "runs", run.run_id)
810
+ : null;
811
+ const removeRunDir = () => {
812
+ if (runDir) rmSync(runDir, { recursive: true, force: true });
813
+ };
712
814
  try {
815
+ if (runDir)
816
+ request.mcpConfigPath = writeRemoteMcpConfig(
817
+ runDir,
818
+ request.tools.remote
819
+ );
713
820
  run.execution = executeRun(request);
714
821
  run.execution.promise
715
822
  .then(
@@ -728,19 +835,21 @@ export function createClaudeRunsApi({
728
835
  ? { status: "cancelled" }
729
836
  : {
730
837
  status: "failed",
731
- error: [
732
- "claude_runs_sign_in_required",
733
- "claude_runs_auth_check_unavailable",
734
- "claude_runs_usage_limited",
735
- ].includes(error.message)
838
+ // The reason, so a failure can be told apart later;
839
+ // anything else the CLI threw stays generic.
840
+ error: RUN_FAILURE_CODE.test(error?.message ?? "")
736
841
  ? error.message
737
842
  : "claude_runs_generation_failed",
738
843
  }
739
844
  )
740
845
  )
741
- .finally(() => inFlight[pool]--);
846
+ .finally(() => {
847
+ inFlight[pool]--;
848
+ removeRunDir();
849
+ });
742
850
  } catch {
743
851
  inFlight[pool]--;
852
+ removeRunDir();
744
853
  terminal(run, "run.failed", {
745
854
  status: "failed",
746
855
  error: "claude_runs_cli_unavailable",
@@ -812,13 +921,34 @@ export function createClaudeRunsApi({
812
921
  return server;
813
922
  }
814
923
 
924
+ const readKey = (path) => readFileSync(path, "utf8").trim();
925
+ // Every listener audits to its own root and stops its runs before closing.
926
+ const auditTo = (root) => (entry) =>
927
+ appendFileSync(
928
+ `${root}/audit.jsonl`,
929
+ `${JSON.stringify({ at: new Date().toISOString(), ...entry })}\n`,
930
+ { mode: 0o600 }
931
+ );
932
+ const listen = (server, port, host) =>
933
+ new Promise((resolve, reject) => {
934
+ server.once("error", reject);
935
+ server.listen(port, host, resolve);
936
+ });
937
+ async function stopServer(server) {
938
+ await server.stopActiveRuns();
939
+ await new Promise((resolve) => {
940
+ server.close(resolve);
941
+ server.closeAllConnections();
942
+ });
943
+ }
944
+
815
945
  // The Admin runtime owns these listeners for its lifetime. Image-owned startup
816
946
  // plus the persistent CLI/auth directory makes normal restarts repeatable.
817
947
  export async function startConfiguredClaudeRunsApis({
818
948
  env = process.env,
819
949
  diagnostic = () => {},
820
950
  createApi = createClaudeRunsApi,
821
- readToken = (path) => readFileSync(path, "utf8").trim(),
951
+ readToken = readKey,
822
952
  toolLanes = null,
823
953
  } = {}) {
824
954
  const services = [];
@@ -836,21 +966,13 @@ export async function startConfiguredClaudeRunsApis({
836
966
  token,
837
967
  connection,
838
968
  toolLanes,
839
- audit: (entry) =>
840
- appendFileSync(
841
- `${connection.localRoot}/audit.jsonl`,
842
- `${JSON.stringify({ at: new Date().toISOString(), ...entry })}\n`,
843
- { mode: 0o600 }
844
- ),
845
- });
846
- await new Promise((resolve, reject) => {
847
- server.once("error", reject);
848
- server.listen(
849
- Number(new URL(connection.baseUrl).port),
850
- "127.0.0.1",
851
- resolve
852
- );
969
+ audit: auditTo(connection.localRoot),
853
970
  });
971
+ await listen(
972
+ server,
973
+ Number(new URL(connection.baseUrl).port),
974
+ "127.0.0.1"
975
+ );
854
976
  services.push(server);
855
977
  diagnostic({
856
978
  ok: true,
@@ -863,13 +985,45 @@ export async function startConfiguredClaudeRunsApis({
863
985
  }
864
986
  return {
865
987
  async stop() {
866
- for (const server of services) {
867
- await server.stopActiveRuns();
868
- await new Promise((resolve) => {
869
- server.close(resolve);
870
- server.closeAllConnections();
871
- });
872
- }
988
+ for (const server of services) await stopServer(server);
873
989
  },
874
990
  };
875
991
  }
992
+
993
+ // The Admin's internet-facing listener; Fly terminates TLS at the edge. It
994
+ // starts only on a declared port with the machine's private connection key.
995
+ export async function startPublicClaudeRunsApi({
996
+ env = process.env,
997
+ diagnostic = () => {},
998
+ root = "/opt/data/private-opener",
999
+ createApi = createClaudeRunsApi,
1000
+ readToken = readKey,
1001
+ } = {}) {
1002
+ const value = env.SELLABLE_AGENT_PUBLIC_RUNS_SERVICE_PORT ?? "";
1003
+ const port = Number(value);
1004
+ const keyFile = `${root}/api-key`;
1005
+ let key = null;
1006
+ try {
1007
+ key = lstatSync(keyFile);
1008
+ } catch {
1009
+ /* No key, no public listener. */
1010
+ }
1011
+ if (
1012
+ !/^[0-9]{4,5}$/.test(value) ||
1013
+ port < 1024 ||
1014
+ port > 65535 ||
1015
+ // lstat: a symlink is not a file.
1016
+ !key?.isFile() ||
1017
+ (key.mode & 0o077) !== 0
1018
+ )
1019
+ return null;
1020
+ const server = createApi({
1021
+ mode: "public",
1022
+ token: readToken(keyFile),
1023
+ root,
1024
+ audit: auditTo(root),
1025
+ });
1026
+ await listen(server, port, "0.0.0.0");
1027
+ diagnostic({ ok: true, code: "claude_runs_public_api_ready", port });
1028
+ return { stop: () => stopServer(server) };
1029
+ }