@sellable/install 0.1.812 → 0.1.813

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.
@@ -13,17 +13,8 @@ import {
13
13
  } from "node:fs";
14
14
  import { createServer } from "node:http";
15
15
  import { join } from "node:path";
16
- import { fileURLToPath } from "node:url";
17
- import {
18
- endpointHermesSessionId,
19
- endpointMcpDescriptorPath,
20
- } from "../mcp-context-proxy.mjs";
21
16
  import { normalizeRunsApiBaseUrl } from "./endpoint-model-selection.mjs";
22
17
  import { validateOpenerOutputContract } from "./hermes-runs-client.mjs";
23
- import {
24
- parseRunsApiConnections,
25
- RUNS_API_CONNECTIONS_ENV,
26
- } from "./runs-api-connections.mjs";
27
18
 
28
19
  // Each pool runs as many generations at once as one Hermes endpoint may
29
20
  // (ENDPOINT_USER_MAX_CONCURRENCY); the runtime still caps the machine at 10.
@@ -31,13 +22,12 @@ export const CLAUDE_RUNS_MAX_CONCURRENCY = 8;
31
22
  const MAX_BODY = 320 * 1024;
32
23
  const MAX_OUTPUT = 48 * 1024;
33
24
  const MAX_CLI_OUTPUT = 512 * 1024;
34
- const MCP_SESSION = fileURLToPath(
35
- new URL("./claude-code-mcp-session.mjs", import.meta.url)
36
- );
25
+ // The CLI, its sign-ins and the audit log live here on the Admin machine.
26
+ const SERVICE_ROOT = "/opt/data/private-opener";
37
27
  const hash = (s) => createHash("sha256").update(s).digest("hex");
38
28
  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.
29
+ // The service has no model list: any Claude model, at the efforts the CLI
30
+ // takes.
41
31
  const publicModel = (body) =>
42
32
  typeof body.model === "string" &&
43
33
  /^claude-[a-z0-9-]{1,100}$/.test(body.model) &&
@@ -73,7 +63,7 @@ function remoteTools(tools) {
73
63
  };
74
64
  }
75
65
 
76
- export function validateClaudeRunsRequest(body, models) {
66
+ export function validateClaudeRunsRequest(body) {
77
67
  const allowed = [
78
68
  "input",
79
69
  "instructions",
@@ -82,7 +72,7 @@ export function validateClaudeRunsRequest(body, models) {
82
72
  "provider",
83
73
  "model",
84
74
  "model_options",
85
- ...(models === null ? ["tools"] : []),
75
+ "tools",
86
76
  ];
87
77
  if (
88
78
  !record(body) ||
@@ -110,14 +100,7 @@ export function validateClaudeRunsRequest(body, models) {
110
100
  (key) =>
111
101
  !["reasoning_effort", "sellable_opener_output_contract"].includes(key)
112
102
  ) ||
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
- ))
103
+ !publicModel(body)
121
104
  )
122
105
  throw new Error("claude_runs_request_rejected");
123
106
  const contract = body.model_options.sellable_opener_output_contract;
@@ -141,50 +124,7 @@ export function validateClaudeRunsRequest(body, models) {
141
124
  };
142
125
  }
143
126
 
144
- /**
145
- * A run's own allowed tools, read from the signed turn descriptor the runtime
146
- * installed for its session before submitting. With a tool list, only runs on
147
- * those endpoints get tools (and must have some); without one, every run gets
148
- * exactly the tools its descriptor allows, and a run allowed none is tool-free
149
- * (null). This only narrows: the context proxy verifies the descriptor itself
150
- * before it lists or calls any tool.
151
- */
152
- export function endpointToolNames({
153
- turnDescriptorPath,
154
- sessionId,
155
- toolEndpointIds = undefined,
156
- }) {
157
- const path = endpointMcpDescriptorPath(turnDescriptorPath, sessionId);
158
- if (!path) return null;
159
- try {
160
- if (!lstatSync(path).isFile())
161
- throw new Error("claude_runs_tool_descriptor_rejected");
162
- } catch (error) {
163
- if (error.code === "ENOENT") return null;
164
- throw error;
165
- }
166
- const body = JSON.parse(readFileSync(path, "utf8"))?.body;
167
- if (toolEndpointIds && !toolEndpointIds.includes(body?.endpointId))
168
- return null;
169
- const names = body?.allowedToolNames;
170
- if (
171
- endpointHermesSessionId(body) !== sessionId ||
172
- !Array.isArray(names) ||
173
- (toolEndpointIds && !names.length) ||
174
- names.some(
175
- (name) => typeof name !== "string" || !/^[A-Za-z0-9_-]{1,128}$/.test(name)
176
- )
177
- )
178
- throw new Error("claude_runs_tool_descriptor_rejected");
179
- return names.length ? [...names] : null;
180
- }
181
-
182
- export function claudeInvocation(
183
- request,
184
- root,
185
- mcpSpecPath = null,
186
- configDir = `${root}/claude`
187
- ) {
127
+ export function claudeInvocation(request, root, configDir = `${root}/claude`) {
188
128
  return {
189
129
  command: `${root}/cli/node_modules/.bin/claude`,
190
130
  args: [
@@ -204,22 +144,12 @@ export function claudeInvocation(
204
144
  "dontAsk",
205
145
  "--strict-mcp-config",
206
146
  "--mcp-config",
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.
147
+ // A run's only MCP server is its own remote server, read from its
148
+ // private file, and its allowed tools are exactly the run's. Every other
149
+ // run has none.
211
150
  ...(request.tools
212
151
  ? [
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
- }),
152
+ request.mcpConfigPath,
223
153
  "--allowedTools",
224
154
  request.tools.names
225
155
  .map((name) => `mcp__sellable__${name}`)
@@ -265,7 +195,6 @@ export function readClaudeAuthentication({
265
195
  const invocation = claudeInvocation(
266
196
  { instructions: "", input: "", model: "claude-opus", effort: "high" },
267
197
  root,
268
- null,
269
198
  configDir
270
199
  );
271
200
  return new Promise((resolve) => {
@@ -378,6 +307,31 @@ export function claudeUsageLimit(stdout, now = Date.now()) {
378
307
  return { retryAt };
379
308
  }
380
309
 
310
+ // Why a CLI run failed, for the audit only: sanitized facts from its JSON
311
+ // result, never its text, result or stderr.
312
+ export function claudeFailureFacts(stdout, exitCode) {
313
+ let value = null;
314
+ try {
315
+ value = JSON.parse(stdout);
316
+ } catch {
317
+ /* No JSON result. */
318
+ }
319
+ const fact = (item, pattern) =>
320
+ typeof item === "string" && pattern.test(item) ? item : null;
321
+ const status = record(value) ? value.api_error_status : null;
322
+ return {
323
+ exitCode: Number.isInteger(exitCode) ? exitCode : null,
324
+ apiErrorStatus:
325
+ Number.isInteger(status) && status >= 100 && status <= 599
326
+ ? status
327
+ : null,
328
+ terminalReason: record(value)
329
+ ? fact(value.terminal_reason, /^[a-z_]{1,32}$/)
330
+ : null,
331
+ subtype: record(value) ? fact(value.subtype, /^[a-z_]{1,48}$/) : null,
332
+ };
333
+ }
334
+
381
335
  // A failure reason the run may report: a fixed code, never raw CLI text.
382
336
  const RUN_FAILURE_CODE = /^claude_runs_[a-z_]{1,48}$/;
383
337
 
@@ -430,28 +384,12 @@ export function startClaudeRun(
430
384
  timeoutMs = 750_000,
431
385
  } = {}
432
386
  ) {
433
- // The tool server's environment travels in a private per-run file, never
434
- // in argv, and is removed when the run ends.
435
- const mcpSpecPath = request.tools?.server
436
- ? join(root, "work", `mcp-session-${randomBytes(12).toString("hex")}.json`)
437
- : null;
438
- if (mcpSpecPath)
439
- writeFileSync(
440
- mcpSpecPath,
441
- JSON.stringify({ sessionId: request.sessionId, ...request.tools.server }),
442
- { mode: 0o600, flag: "wx" }
443
- );
444
- const removeSpec = () => {
445
- if (mcpSpecPath) rmSync(mcpSpecPath, { force: true });
446
- };
447
- const invocation = claudeInvocation(request, root, mcpSpecPath, configDir);
448
- let child;
449
- try {
450
- child = spawnImpl(invocation.command, invocation.args, invocation.options);
451
- } catch (error) {
452
- removeSpec();
453
- throw error;
454
- }
387
+ const invocation = claudeInvocation(request, root, configDir);
388
+ const child = spawnImpl(
389
+ invocation.command,
390
+ invocation.args,
391
+ invocation.options
392
+ );
455
393
  let reason = null;
456
394
  let killTimer;
457
395
  const stop = (code = "claude_runs_cancelled") => {
@@ -483,28 +421,32 @@ export function startClaudeRun(
483
421
  child.stderr.on("data", () => {});
484
422
  child.once("error", () => {
485
423
  clearTimeout(timer);
486
- removeSpec();
487
424
  reject(new Error("claude_runs_cli_unavailable"));
488
425
  });
489
426
  child.once("close", (code) => {
490
427
  clearTimeout(timer);
491
428
  clearTimeout(killTimer);
492
- removeSpec();
493
429
  const stdout = Buffer.concat(chunks).toString("utf8");
494
430
  const limit = reason ? null : claudeUsageLimit(stdout);
495
431
  if (limit)
496
432
  return reject(
497
433
  Object.assign(new Error("claude_runs_usage_limited"), limit)
498
434
  );
499
- if (reason || code !== 0)
500
- return reject(new Error(reason ?? "claude_runs_cli_failed"));
435
+ const failed = (message) =>
436
+ Object.assign(new Error(message), {
437
+ failure: claudeFailureFacts(stdout, code),
438
+ });
439
+ if (reason) return reject(new Error(reason));
440
+ if (code !== 0) return reject(failed("claude_runs_cli_failed"));
501
441
  try {
502
442
  resolve(parseClaudeResult(stdout, request));
503
443
  } catch (error) {
504
- reject(
444
+ const message =
505
445
  error instanceof Error && RUN_FAILURE_CODE.test(error.message)
506
- ? error
507
- : new Error("claude_runs_output_rejected")
446
+ ? error.message
447
+ : "claude_runs_output_rejected";
448
+ reject(
449
+ message === "claude_runs_output_rejected" ? failed(message) : error
508
450
  );
509
451
  }
510
452
  });
@@ -539,20 +481,17 @@ function writeRemoteMcpConfig(dir, remote) {
539
481
  return path;
540
482
  }
541
483
 
484
+ // The Admin's internet-facing service. The bearer is its only scope, and a
485
+ // run's tools come from its own request.
542
486
  export function createClaudeRunsApi({
543
487
  token,
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,
488
+ root = SERVICE_ROOT,
549
489
  execute = null,
550
490
  audit = () => {},
551
491
  maxConcurrency = CLAUDE_RUNS_MAX_CONCURRENCY,
552
- // Tool lanes (replies, follow-ups) have their own slots so a batch of
492
+ // Runs with tools (replies, follow-ups) have their own slots so a batch of
553
493
  // openers can never crowd them out.
554
494
  maxToolConcurrency = CLAUDE_RUNS_MAX_CONCURRENCY,
555
- toolLanes = null,
556
495
  checkAuthentication = (configDir) =>
557
496
  readClaudeAuthentication({ root, configDir }),
558
497
  accounts = () => claudeAccountDirs(root),
@@ -629,55 +568,21 @@ export function createClaudeRunsApi({
629
568
  };
630
569
  };
631
570
  const executeRun = execute ?? runOnAccounts;
632
- const readHealth = async () => {
571
+ const readAuthentication = async () => {
633
572
  const dirs = accounts();
634
573
  let ready = 0;
635
574
  let signedOut = 0;
636
- const retryAts = [];
637
575
  for (const configDir of dirs) {
638
- if (resting(configDir)) {
639
- retryAts.push(usageLimitedUntil.get(configDir));
640
- continue;
641
- }
576
+ if (resting(configDir)) continue;
642
577
  const state = await signInState(configDir);
643
578
  if (state === "READY") ready++;
644
579
  if (state === "SIGN_IN_REQUIRED") signedOut++;
645
580
  }
646
- return {
647
- authentication: ready
648
- ? "READY"
649
- : signedOut === dirs.length
650
- ? "SIGN_IN_REQUIRED"
651
- : "UNAVAILABLE",
652
- accounts: {
653
- total: dirs.length,
654
- ready,
655
- usageLimited: retryAts.length,
656
- retryAt: retryAts.length
657
- ? new Date(Math.min(...retryAts)).toISOString()
658
- : null,
659
- },
660
- };
661
- };
662
- // The run's own tools and the profile's MCP server, or null for a tool-free run.
663
- const resolveTools = (request) => {
664
- if (!toolLanes) return null;
665
- const names = endpointToolNames({
666
- turnDescriptorPath: toolLanes.turnDescriptorPath,
667
- sessionId: request.sessionId,
668
- toolEndpointIds: connection.toolEndpointIds,
669
- });
670
- if (!names) return null;
671
- const server = toolLanes.server();
672
- if (!server) throw new Error("claude_runs_tool_server_unavailable");
673
- return {
674
- names,
675
- server: {
676
- command: server.command,
677
- args: [...server.args],
678
- env: { PATH: "/usr/local/bin:/usr/bin:/bin", ...server.env },
679
- },
680
- };
581
+ return ready
582
+ ? "READY"
583
+ : signedOut === dirs.length
584
+ ? "SIGN_IN_REQUIRED"
585
+ : "UNAVAILABLE";
681
586
  };
682
587
  const recordAudit = (entry) => {
683
588
  try {
@@ -721,6 +626,7 @@ export function createClaudeRunsApi({
721
626
  usage: run.usage ?? null,
722
627
  durationMs: Date.now() - run.startedAt,
723
628
  outputDigest: run.output ? hash(run.output) : null,
629
+ ...(run.status === "failed" ? { failure: run.failure ?? null } : {}),
724
630
  });
725
631
  setTimeout(() => runs.delete(run.run_id), 3_600_000).unref();
726
632
  };
@@ -732,37 +638,18 @@ export function createClaudeRunsApi({
732
638
  !timingSafeEqual(provided, expected)
733
639
  )
734
640
  return json(res, 401, { error: "claude_runs_auth_required" });
735
- if (
736
- mode !== "public" &&
737
- (req.headers["x-sellable-workspace-id"] !== connection.workspaceId ||
738
- req.headers["x-sellable-agent-id"] !== connection.agentId)
739
- )
740
- return json(res, 403, { error: "claude_runs_scope_rejected" });
741
641
  if (req.method === "GET" && req.url === "/health") {
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
- });
642
+ const authentication = await readAuthentication();
643
+ // Health names no accounts, load, or scope.
751
644
  return json(res, 200, {
752
645
  ok: authentication === "READY",
753
646
  authentication,
754
- accounts: accountHealth,
755
647
  provider: "claude-code",
756
- active: inFlight.plain + inFlight.tools,
757
- toolActive: inFlight.tools,
758
- scope: {
759
- workspaceId: connection.workspaceId,
760
- agentId: connection.agentId,
761
- },
648
+ tools: ["mcp"],
762
649
  });
763
650
  }
764
651
  if (req.method === "POST" && req.url === "/v1/runs") {
765
- if (full("plain") && ((mode !== "public" && !toolLanes) || full("tools")))
652
+ if (full("plain") && full("tools"))
766
653
  return json(res, 429, { error: "claude_runs_busy" });
767
654
  let request;
768
655
  try {
@@ -777,12 +664,7 @@ export function createClaudeRunsApi({
777
664
  chunks.push(chunk);
778
665
  }
779
666
  const body = JSON.parse(Buffer.concat(chunks).toString("utf8"));
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);
667
+ request = validateClaudeRunsRequest(body);
786
668
  } catch (error) {
787
669
  recordAudit({
788
670
  status: "request-rejected",
@@ -805,7 +687,7 @@ export function createClaudeRunsApi({
805
687
  };
806
688
  inFlight[pool]++;
807
689
  runs.set(run.run_id, run);
808
- const runDir = request.tools?.remote
690
+ const runDir = request.tools
809
691
  ? join(root, "work", "runs", run.run_id)
810
692
  : null;
811
693
  const removeRunDir = () => {
@@ -840,6 +722,8 @@ export function createClaudeRunsApi({
840
722
  error: RUN_FAILURE_CODE.test(error?.message ?? "")
841
723
  ? error.message
842
724
  : "claude_runs_generation_failed",
725
+ // Why the CLI failed, for the audit only.
726
+ failure: error?.failure ?? null,
843
727
  }
844
728
  )
845
729
  )
@@ -942,60 +826,12 @@ async function stopServer(server) {
942
826
  });
943
827
  }
944
828
 
945
- // The Admin runtime owns these listeners for its lifetime. Image-owned startup
946
- // plus the persistent CLI/auth directory makes normal restarts repeatable.
947
- export async function startConfiguredClaudeRunsApis({
948
- env = process.env,
949
- diagnostic = () => {},
950
- createApi = createClaudeRunsApi,
951
- readToken = readKey,
952
- toolLanes = null,
953
- } = {}) {
954
- const services = [];
955
- for (const connection of parseRunsApiConnections(
956
- env[RUNS_API_CONNECTIONS_ENV]
957
- )) {
958
- if (
959
- !connection.localRoot ||
960
- connection.runtimeId !== env.SELLABLE_RUNTIME_ID
961
- )
962
- continue;
963
- try {
964
- const token = readToken(connection.apiKeyFile);
965
- const server = createApi({
966
- token,
967
- connection,
968
- toolLanes,
969
- audit: auditTo(connection.localRoot),
970
- });
971
- await listen(
972
- server,
973
- Number(new URL(connection.baseUrl).port),
974
- "127.0.0.1"
975
- );
976
- services.push(server);
977
- diagnostic({
978
- ok: true,
979
- code: "claude_runs_api_ready",
980
- baseUrl: connection.baseUrl,
981
- });
982
- } catch {
983
- diagnostic({ ok: false, code: "claude_runs_api_unavailable" });
984
- }
985
- }
986
- return {
987
- async stop() {
988
- for (const server of services) await stopServer(server);
989
- },
990
- };
991
- }
992
-
993
829
  // The Admin's internet-facing listener; Fly terminates TLS at the edge. It
994
830
  // starts only on a declared port with the machine's private connection key.
995
831
  export async function startPublicClaudeRunsApi({
996
832
  env = process.env,
997
833
  diagnostic = () => {},
998
- root = "/opt/data/private-opener",
834
+ root = SERVICE_ROOT,
999
835
  createApi = createClaudeRunsApi,
1000
836
  readToken = readKey,
1001
837
  } = {}) {
@@ -1018,7 +854,6 @@ export async function startPublicClaudeRunsApi({
1018
854
  )
1019
855
  return null;
1020
856
  const server = createApi({
1021
- mode: "public",
1022
857
  token: readToken(keyFile),
1023
858
  root,
1024
859
  audit: auditTo(root),
@@ -44,6 +44,7 @@ import {
44
44
  obtainFlyOidcToken,
45
45
  renewFlyCustomerControlSession,
46
46
  signedWorkerAuthorization,
47
+ WorkerControlResponseError,
47
48
  } from "../fly-runtime-identity.mjs";
48
49
  import {
49
50
  executeExternalSoulBridgeOperation,
@@ -131,7 +132,7 @@ const RUNTIME_GENERATION = Number(
131
132
  const BOOT_SESSION_ID = randomUUID();
132
133
  const HERMES_VERSION = process.env.SELLABLE_AGENT_HERMES_VERSION ?? "0.21.5";
133
134
  const INSTALLER_PACKAGE =
134
- process.env.SELLABLE_AGENT_INSTALLER_PACKAGE ?? "@sellable/install@0.1.812";
135
+ process.env.SELLABLE_AGENT_INSTALLER_PACKAGE ?? "@sellable/install@0.1.813";
135
136
  const MCP_PACKAGE =
136
137
  process.env.SELLABLE_AGENT_MCP_PACKAGE ?? "@sellable/mcp@0.1.959";
137
138
  export const ENDPOINT_MAX_CONCURRENT_RUNS = 10;
@@ -1181,6 +1182,29 @@ function endpointCompletionBody({
1181
1182
  };
1182
1183
  }
1183
1184
 
1185
+ // Completion refusals the server returns before it records anything.
1186
+ const ENDPOINT_COMPLETION_REFUSALS = new Map([
1187
+ ["product_completion_contract_invalid", "server_contract_invalid"],
1188
+ ["completion_output_invalid", "server_contract_invalid"],
1189
+ ["endpoint_worker_secret_material", "server_secret_material"],
1190
+ ]);
1191
+
1192
+ /**
1193
+ * Why the server refused a product draft's answer, or null when the outcome is
1194
+ * unknown (lease, fence, 5xx, network). Matched on the code: a stale fence is
1195
+ * also a 409.
1196
+ */
1197
+ function endpointCompletionRefusal(claim, error) {
1198
+ if (
1199
+ claim?.run?.productExecution == null ||
1200
+ !(error instanceof WorkerControlResponseError) ||
1201
+ error.retryable !== false ||
1202
+ ![409, 413, 422].includes(error.status)
1203
+ )
1204
+ return null;
1205
+ return ENDPOINT_COMPLETION_REFUSALS.get(error.message) ?? null;
1206
+ }
1207
+
1184
1208
  /**
1185
1209
  * Why endpointProductProjection refused an answer, as one short code for the
1186
1210
  * run's events: the answer is never stored, so this is all that explains it.
@@ -2363,33 +2387,42 @@ export function createCustomerEndpointRelay(options = {}) {
2363
2387
  // a second completion or expose the late output as canonical.
2364
2388
  return done({ ok: true, code: "LATE_NOTICE_ONLY" });
2365
2389
  }
2390
+ // The output was never used, so another model may still write.
2391
+ const refused = (reason) => ({
2392
+ failure: "OUTPUT_CONTRACT_FAILED",
2393
+ reason,
2394
+ finish: async () => {
2395
+ await complete(claim, identity, {
2396
+ attemptStatus: "SUCCEEDED",
2397
+ status: "OUTPUT_CONTRACT_FAILED",
2398
+ });
2399
+ return { ok: false, code: "OUTPUT_CONTRACT_FAILED" };
2400
+ },
2401
+ });
2366
2402
  const productProjection = endpointProductProjection(
2367
2403
  claim.run.productExecution,
2368
2404
  terminalEvent.output
2369
2405
  );
2370
2406
  if (!productProjection.ok) {
2371
- // The output was never used, so another model may still write.
2372
- return {
2373
- failure: "OUTPUT_CONTRACT_FAILED",
2374
- reason: endpointProductProjectionReason(
2407
+ return refused(
2408
+ endpointProductProjectionReason(
2375
2409
  claim.run.productExecution,
2376
2410
  terminalEvent.output
2377
- ),
2378
- finish: async () => {
2379
- await complete(claim, identity, {
2380
- attemptStatus: "SUCCEEDED",
2381
- status: "OUTPUT_CONTRACT_FAILED",
2382
- });
2383
- return { ok: false, code: "OUTPUT_CONTRACT_FAILED" };
2384
- },
2385
- };
2411
+ )
2412
+ );
2413
+ }
2414
+ try {
2415
+ await complete(claim, identity, {
2416
+ attemptStatus: "SUCCEEDED",
2417
+ status: "SUCCEEDED",
2418
+ output: terminalEvent.output,
2419
+ ...productProjection.completion,
2420
+ });
2421
+ } catch (error) {
2422
+ const reason = endpointCompletionRefusal(claim, error);
2423
+ if (!reason) throw error;
2424
+ return refused(reason);
2386
2425
  }
2387
- await complete(claim, identity, {
2388
- attemptStatus: "SUCCEEDED",
2389
- status: "SUCCEEDED",
2390
- output: terminalEvent.output,
2391
- ...productProjection.completion,
2392
- });
2393
2426
  return done({ ok: true, code: "SUCCEEDED" });
2394
2427
  }
2395
2428
  if (
@@ -2556,7 +2589,22 @@ export function createCustomerEndpointRelay(options = {}) {
2556
2589
  outcome.failure,
2557
2590
  deadlineMs - now().getTime()
2558
2591
  );
2559
- if (!decision.advance) return await outcome.finish();
2592
+ if (!decision.advance) {
2593
+ // A refused answer that ends the run says why, once.
2594
+ if (outcome.reason)
2595
+ await append(
2596
+ claim,
2597
+ identity,
2598
+ {
2599
+ kind: "OUTPUT_REFUSED",
2600
+ classification: outcome.failure,
2601
+ chainStep: stepIndex,
2602
+ reason: outcome.reason,
2603
+ },
2604
+ ordinal++
2605
+ );
2606
+ return await outcome.finish();
2607
+ }
2560
2608
  await append(
2561
2609
  claim,
2562
2610
  identity,
@@ -19,6 +19,7 @@ export type EndpointModelStep = Readonly<{
19
19
  provider: string;
20
20
  model: string;
21
21
  reasoningEffort: (typeof ENDPOINT_MODEL_REASONING_EFFORTS)[number];
22
+ /** Both or neither: a custom Runs URL is served by a workspace connection. */
22
23
  runsApiBaseUrl?: string;
23
24
  modelConnectionId?: string;
24
25
  }>;