@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
@@ -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.803
11
+ ARG INSTALLER_PACKAGE=@sellable/install@0.1.805
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
@@ -22,7 +22,7 @@ ARG PLAYWRIGHT_BROWSER_REVISION=1243
22
22
  ARG TIRITH_ARCHIVE_SHA256=6cdbe35e8f9ccf42e70ad95b501c93cd218ac18201c3df958d54f6ba0d995ce2
23
23
  ARG DEFAULT_PROFILE_BUNDLE_FIXTURE_VERSION=1
24
24
  ARG DEFAULT_PROFILE_BUNDLE_VERSION=4
25
- ARG DEFAULT_PROFILE_BUNDLE_DIGEST=7687c64bf378e5b317374669780b61227d09c4adef9530a86b94f65a11e4fb2f
25
+ ARG DEFAULT_PROFILE_BUNDLE_DIGEST=fe00b8da3617af34c4edef6b0f40b4f48b45a1da11332137fad400deefd92352
26
26
 
27
27
  RUN --mount=type=secret,id=mcp_trust_root,required=true \
28
28
  test -s /run/secrets/mcp_trust_root \
@@ -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}`)
@@ -386,7 +432,7 @@ export function startClaudeRun(
386
432
  ) {
387
433
  // The tool server's environment travels in a private per-run file, never
388
434
  // in argv, and is removed when the run ends.
389
- const mcpSpecPath = request.tools
435
+ const mcpSpecPath = request.tools?.server
390
436
  ? join(root, "work", `mcp-session-${randomBytes(12).toString("hex")}.json`)
391
437
  : null;
392
438
  if (mcpSpecPath)
@@ -468,9 +514,38 @@ export function startClaudeRun(
468
514
  return { promise, stop };
469
515
  }
470
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
+
471
542
  export function createClaudeRunsApi({
472
543
  token,
473
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,
474
549
  execute = null,
475
550
  audit = () => {},
476
551
  maxConcurrency = CLAUDE_RUNS_MAX_CONCURRENCY,
@@ -479,8 +554,8 @@ export function createClaudeRunsApi({
479
554
  maxToolConcurrency = CLAUDE_RUNS_MAX_CONCURRENCY,
480
555
  toolLanes = null,
481
556
  checkAuthentication = (configDir) =>
482
- readClaudeAuthentication({ root: connection.localRoot, configDir }),
483
- accounts = () => claudeAccountDirs(connection.localRoot),
557
+ readClaudeAuthentication({ root, configDir }),
558
+ accounts = () => claudeAccountDirs(root),
484
559
  startRun = startClaudeRun,
485
560
  now = Date.now,
486
561
  } = {}) {
@@ -533,10 +608,7 @@ export function createClaudeRunsApi({
533
608
  failure ??= "claude_runs_sign_in_required";
534
609
  continue;
535
610
  }
536
- activeRun = startRun(request, {
537
- root: connection.localRoot,
538
- configDir,
539
- });
611
+ activeRun = startRun(request, { root, configDir });
540
612
  try {
541
613
  return await activeRun.promise;
542
614
  } catch (error) {
@@ -661,12 +733,21 @@ export function createClaudeRunsApi({
661
733
  )
662
734
  return json(res, 401, { error: "claude_runs_auth_required" });
663
735
  if (
664
- req.headers["x-sellable-workspace-id"] !== connection.workspaceId ||
665
- 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)
666
739
  )
667
740
  return json(res, 403, { error: "claude_runs_scope_rejected" });
668
741
  if (req.method === "GET" && req.url === "/health") {
669
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
+ });
670
751
  return json(res, 200, {
671
752
  ok: authentication === "READY",
672
753
  authentication,
@@ -681,7 +762,7 @@ export function createClaudeRunsApi({
681
762
  });
682
763
  }
683
764
  if (req.method === "POST" && req.url === "/v1/runs") {
684
- if (full("plain") && (!toolLanes || full("tools")))
765
+ if (full("plain") && ((mode !== "public" && !toolLanes) || full("tools")))
685
766
  return json(res, 429, { error: "claude_runs_busy" });
686
767
  let request;
687
768
  try {
@@ -696,8 +777,12 @@ export function createClaudeRunsApi({
696
777
  chunks.push(chunk);
697
778
  }
698
779
  const body = JSON.parse(Buffer.concat(chunks).toString("utf8"));
699
- request = validateClaudeRunsRequest(body, connection.models);
700
- 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);
701
786
  } catch (error) {
702
787
  recordAudit({
703
788
  status: "request-rejected",
@@ -720,7 +805,18 @@ export function createClaudeRunsApi({
720
805
  };
721
806
  inFlight[pool]++;
722
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
+ };
723
814
  try {
815
+ if (runDir)
816
+ request.mcpConfigPath = writeRemoteMcpConfig(
817
+ runDir,
818
+ request.tools.remote
819
+ );
724
820
  run.execution = executeRun(request);
725
821
  run.execution.promise
726
822
  .then(
@@ -747,9 +843,13 @@ export function createClaudeRunsApi({
747
843
  }
748
844
  )
749
845
  )
750
- .finally(() => inFlight[pool]--);
846
+ .finally(() => {
847
+ inFlight[pool]--;
848
+ removeRunDir();
849
+ });
751
850
  } catch {
752
851
  inFlight[pool]--;
852
+ removeRunDir();
753
853
  terminal(run, "run.failed", {
754
854
  status: "failed",
755
855
  error: "claude_runs_cli_unavailable",
@@ -821,13 +921,34 @@ export function createClaudeRunsApi({
821
921
  return server;
822
922
  }
823
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
+
824
945
  // The Admin runtime owns these listeners for its lifetime. Image-owned startup
825
946
  // plus the persistent CLI/auth directory makes normal restarts repeatable.
826
947
  export async function startConfiguredClaudeRunsApis({
827
948
  env = process.env,
828
949
  diagnostic = () => {},
829
950
  createApi = createClaudeRunsApi,
830
- readToken = (path) => readFileSync(path, "utf8").trim(),
951
+ readToken = readKey,
831
952
  toolLanes = null,
832
953
  } = {}) {
833
954
  const services = [];
@@ -845,21 +966,13 @@ export async function startConfiguredClaudeRunsApis({
845
966
  token,
846
967
  connection,
847
968
  toolLanes,
848
- audit: (entry) =>
849
- appendFileSync(
850
- `${connection.localRoot}/audit.jsonl`,
851
- `${JSON.stringify({ at: new Date().toISOString(), ...entry })}\n`,
852
- { mode: 0o600 }
853
- ),
854
- });
855
- await new Promise((resolve, reject) => {
856
- server.once("error", reject);
857
- server.listen(
858
- Number(new URL(connection.baseUrl).port),
859
- "127.0.0.1",
860
- resolve
861
- );
969
+ audit: auditTo(connection.localRoot),
862
970
  });
971
+ await listen(
972
+ server,
973
+ Number(new URL(connection.baseUrl).port),
974
+ "127.0.0.1"
975
+ );
863
976
  services.push(server);
864
977
  diagnostic({
865
978
  ok: true,
@@ -872,13 +985,45 @@ export async function startConfiguredClaudeRunsApis({
872
985
  }
873
986
  return {
874
987
  async stop() {
875
- for (const server of services) {
876
- await server.stopActiveRuns();
877
- await new Promise((resolve) => {
878
- server.close(resolve);
879
- server.closeAllConnections();
880
- });
881
- }
988
+ for (const server of services) await stopServer(server);
882
989
  },
883
990
  };
884
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
+ }
@@ -131,7 +131,7 @@ const RUNTIME_GENERATION = Number(
131
131
  const BOOT_SESSION_ID = randomUUID();
132
132
  const HERMES_VERSION = process.env.SELLABLE_AGENT_HERMES_VERSION ?? "0.21.5";
133
133
  const INSTALLER_PACKAGE =
134
- process.env.SELLABLE_AGENT_INSTALLER_PACKAGE ?? "@sellable/install@0.1.803";
134
+ process.env.SELLABLE_AGENT_INSTALLER_PACKAGE ?? "@sellable/install@0.1.805";
135
135
  const MCP_PACKAGE =
136
136
  process.env.SELLABLE_AGENT_MCP_PACKAGE ?? "@sellable/mcp@0.1.957";
137
137
  export const ENDPOINT_MAX_CONCURRENT_RUNS = 10;
@@ -1979,15 +1979,44 @@ export function createCustomerEndpointRelay(options = {}) {
1979
1979
  const chained = chain.length > 1;
1980
1980
  // A one-model run is a chain of one; no selection is the Agent default.
1981
1981
  const steps = chain.length ? chain : [null];
1982
- const stepClaim = (index) =>
1983
- chain.length === 0
1984
- ? claim
1985
- : { ...claim, run: { ...claim.run, modelSelection: chain[index] } };
1986
- const runs = createRunsClient({
1987
- apiKeyFile,
1988
- claim: stepClaim(0),
1989
- activeRuntime,
1990
- });
1982
+ // No selection is the Agent default, built here as before (the opener
1983
+ // stages use it). A chain opens each step inside the run loop instead.
1984
+ const runs = chain.length
1985
+ ? null
1986
+ : await createRunsClient({ apiKeyFile, claim, activeRuntime });
1987
+ // A workspace connection step's key, released to this attempt only. Its
1988
+ // literal values are scrubbed from this attempt's error code: the
1989
+ // diagnostic patterns miss a hex key and an srt_ grant.
1990
+ const released = [];
1991
+ const fetchConnectionKey = async (connectionId, chainStep) => {
1992
+ const value = await getControlClient().fetchEndpointConnectionKey({
1993
+ ...identity,
1994
+ schemaVersion: "sellable-agent-endpoint-connection-key/v1",
1995
+ connectionId,
1996
+ chainStep,
1997
+ });
1998
+ for (const secret of [value?.apiKey, value?.tools?.authorization])
1999
+ if (typeof secret === "string" && secret) released.push(secret);
2000
+ return value;
2001
+ };
2002
+ // A step whose client cannot be opened sent nothing for this step; it
2003
+ // counts as a clean failure (STEP_UNAVAILABLE).
2004
+ const openStep = async (index) => {
2005
+ try {
2006
+ return await createRunsClient({
2007
+ apiKeyFile,
2008
+ claim: {
2009
+ ...claim,
2010
+ run: { ...claim.run, modelSelection: chain[index] },
2011
+ },
2012
+ activeRuntime,
2013
+ chainStep: index,
2014
+ fetchConnectionKey,
2015
+ });
2016
+ } catch {
2017
+ return null;
2018
+ }
2019
+ };
1991
2020
  const controller = new AbortController();
1992
2021
  const abortFromOuter = () => controller.abort(outerSignal.reason);
1993
2022
  outerSignal.addEventListener("abort", abortFromOuter, { once: true });
@@ -2460,7 +2489,7 @@ export function createCustomerEndpointRelay(options = {}) {
2460
2489
  (descriptorBinding.expected.allowedToolNames ?? []).length === 0;
2461
2490
  const history = [];
2462
2491
  let stepIndex = 0;
2463
- let stepRuns = runs;
2492
+ let stepRuns = chain.length ? await openStep(0) : runs;
2464
2493
  for (;;) {
2465
2494
  const outcome = await runNativeStep(stepIndex, stepRuns);
2466
2495
  if (outcome.done) return outcome.result;
@@ -2492,16 +2521,7 @@ export function createCustomerEndpointRelay(options = {}) {
2492
2521
  );
2493
2522
  history.push(outcome.failure);
2494
2523
  stepIndex = decision.stepIndex;
2495
- try {
2496
- stepRuns = createRunsClient({
2497
- apiKeyFile,
2498
- claim: stepClaim(stepIndex),
2499
- activeRuntime,
2500
- });
2501
- } catch {
2502
- // Nothing was sent for this step; it counts as a clean failure.
2503
- stepRuns = null;
2504
- }
2524
+ stepRuns = await openStep(stepIndex);
2505
2525
  }
2506
2526
  } catch (error) {
2507
2527
  if (heartbeatFailure) throw heartbeatFailure;
@@ -2554,10 +2574,16 @@ export function createCustomerEndpointRelay(options = {}) {
2554
2574
  ok: false,
2555
2575
  code:
2556
2576
  error instanceof Error
2557
- ? redactRuntimeDiagnostic(error.message)
2577
+ ? redactRuntimeDiagnostic(
2578
+ released.reduce(
2579
+ (text, secret) => text.replaceAll(secret, "[redacted]"),
2580
+ error.message
2581
+ )
2582
+ )
2558
2583
  : "customer_endpoint_relay_failed",
2559
2584
  };
2560
2585
  } finally {
2586
+ released.length = 0;
2561
2587
  clearTimeout(deadlineTimer);
2562
2588
  outerSignal.removeEventListener("abort", abortFromOuter);
2563
2589
  heartbeatStopped = true;
@@ -20,6 +20,7 @@ export type EndpointModelStep = Readonly<{
20
20
  model: string;
21
21
  reasoningEffort: (typeof ENDPOINT_MODEL_REASONING_EFFORTS)[number];
22
22
  runsApiBaseUrl?: string;
23
+ modelConnectionId?: string;
23
24
  }>;
24
25
  export type EndpointModelSelection = Readonly<
25
26
  EndpointModelStep & {