@sellable/install 0.1.798 → 0.1.800

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.
@@ -3,12 +3,12 @@ FROM ${HERMES_IMAGE}
3
3
 
4
4
  USER root
5
5
 
6
- ARG INSTALLER_PACKAGE=@sellable/install@0.1.798
6
+ ARG INSTALLER_PACKAGE=@sellable/install@0.1.800
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.798" \
11
+ RUN test "${INSTALLER_PACKAGE}" = "@sellable/install@0.1.800" \
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.798" \
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.798' || 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.800' || 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.798', 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.800', 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.798` is
6
+ `@sellable/install@0.1.800` is
7
7
  published:
8
8
 
9
9
  ```sh
@@ -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.798
11
+ ARG INSTALLER_PACKAGE=@sellable/install@0.1.800
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
@@ -31,17 +31,25 @@ export type ClaudeRunsServer = Server & { stopActiveRuns(): Promise<void> };
31
31
  export type ClaudeRunsApiOptions = {
32
32
  token: string;
33
33
  connection: RunsApiConnection;
34
- execute?: (request: ClaudeRunsRequest) => {
35
- promise: Promise<ClaudeRunsResult>;
36
- stop(): void;
37
- };
34
+ execute?:
35
+ | ((request: ClaudeRunsRequest) => {
36
+ promise: Promise<ClaudeRunsResult>;
37
+ stop(): void;
38
+ })
39
+ | null;
38
40
  audit?: (entry: Record<string, unknown>) => void;
39
41
  maxConcurrency?: number;
40
42
  maxToolConcurrency?: number;
41
43
  toolLanes?: ClaudeRunsToolLanes | null;
42
- checkAuthentication?: () => Promise<
43
- "READY" | "SIGN_IN_REQUIRED" | "UNAVAILABLE"
44
- >;
44
+ checkAuthentication?: (
45
+ configDir: string
46
+ ) => Promise<"READY" | "SIGN_IN_REQUIRED" | "UNAVAILABLE">;
47
+ accounts?: () => string[];
48
+ startRun?: (
49
+ request: ClaudeRunsRequest,
50
+ options: { root: string; configDir: string }
51
+ ) => { promise: Promise<ClaudeRunsResult>; stop(code?: string): void };
52
+ now?: () => number;
45
53
  };
46
54
  export function validateClaudeRunsRequest(
47
55
  body: unknown,
@@ -50,13 +58,20 @@ export function validateClaudeRunsRequest(
50
58
  export function endpointToolNames(options: {
51
59
  turnDescriptorPath: string;
52
60
  sessionId: string;
53
- toolEndpointIds: string[];
61
+ /** Absent: every run gets the tools its own turn descriptor allows. */
62
+ toolEndpointIds?: string[];
54
63
  }): string[] | null;
55
64
  export function claudeInvocation(
56
65
  request: ClaudeRunsRequest,
57
66
  root: string,
58
- mcpSpecPath?: string | null
67
+ mcpSpecPath?: string | null,
68
+ configDir?: string
59
69
  ): { command: string; args: string[]; options: SpawnOptions };
70
+ export function claudeAccountDirs(root: string): string[];
71
+ export function claudeUsageLimit(
72
+ stdout: string,
73
+ now?: number
74
+ ): { retryAt: number } | null;
60
75
  export function parseClaudeResult(
61
76
  stdout: string,
62
77
  request: ClaudeRunsRequest
@@ -65,6 +80,7 @@ export function startClaudeRun(
65
80
  request: ClaudeRunsRequest,
66
81
  options: {
67
82
  root: string;
83
+ configDir?: string;
68
84
  spawnImpl?: (...args: any[]) => any;
69
85
  timeoutMs?: number;
70
86
  }
@@ -82,6 +98,7 @@ export function startConfiguredClaudeRunsApis(options?: {
82
98
 
83
99
  export function readClaudeAuthentication(options: {
84
100
  root: string;
101
+ configDir?: string;
85
102
  execFileImpl?: any;
86
103
  timeoutMs?: number;
87
104
  }): Promise<"READY" | "SIGN_IN_REQUIRED" | "UNAVAILABLE">;
@@ -2,7 +2,9 @@ import { execFile, spawn } from "node:child_process";
2
2
  import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
3
3
  import {
4
4
  appendFileSync,
5
+ existsSync,
5
6
  lstatSync,
7
+ readdirSync,
6
8
  readFileSync,
7
9
  rmSync,
8
10
  writeFileSync,
@@ -98,15 +100,17 @@ export function validateClaudeRunsRequest(body, models) {
98
100
  }
99
101
 
100
102
  /**
101
- * A tool-lane run's own allowed tools, read from the signed turn descriptor
102
- * the runtime installed for its session before submitting; null when the run
103
- * is not on a tool endpoint. This only narrows: the context proxy verifies the
104
- * descriptor itself before it lists or calls any tool.
103
+ * A run's own allowed tools, read from the signed turn descriptor the runtime
104
+ * installed for its session before submitting. With a tool list, only runs on
105
+ * those endpoints get tools (and must have some); without one, every run gets
106
+ * exactly the tools its descriptor allows, and a run allowed none is tool-free
107
+ * (null). This only narrows: the context proxy verifies the descriptor itself
108
+ * before it lists or calls any tool.
105
109
  */
106
110
  export function endpointToolNames({
107
111
  turnDescriptorPath,
108
112
  sessionId,
109
- toolEndpointIds,
113
+ toolEndpointIds = undefined,
110
114
  }) {
111
115
  const path = endpointMcpDescriptorPath(turnDescriptorPath, sessionId);
112
116
  if (!path) return null;
@@ -118,21 +122,27 @@ export function endpointToolNames({
118
122
  throw error;
119
123
  }
120
124
  const body = JSON.parse(readFileSync(path, "utf8"))?.body;
121
- if (!toolEndpointIds.includes(body?.endpointId)) return null;
122
- const names = body.allowedToolNames;
125
+ if (toolEndpointIds && !toolEndpointIds.includes(body?.endpointId))
126
+ return null;
127
+ const names = body?.allowedToolNames;
123
128
  if (
124
129
  endpointHermesSessionId(body) !== sessionId ||
125
130
  !Array.isArray(names) ||
126
- !names.length ||
131
+ (toolEndpointIds && !names.length) ||
127
132
  names.some(
128
133
  (name) => typeof name !== "string" || !/^[A-Za-z0-9_-]{1,128}$/.test(name)
129
134
  )
130
135
  )
131
136
  throw new Error("claude_runs_tool_descriptor_rejected");
132
- return [...names];
137
+ return names.length ? [...names] : null;
133
138
  }
134
139
 
135
- export function claudeInvocation(request, root, mcpSpecPath = null) {
140
+ export function claudeInvocation(
141
+ request,
142
+ root,
143
+ mcpSpecPath = null,
144
+ configDir = `${root}/claude`
145
+ ) {
136
146
  return {
137
147
  command: `${root}/cli/node_modules/.bin/claude`,
138
148
  args: [
@@ -189,8 +199,8 @@ export function claudeInvocation(request, root, mcpSpecPath = null) {
189
199
  stdio: ["pipe", "pipe", "pipe"],
190
200
  env: {
191
201
  PATH: "/usr/local/bin:/usr/bin:/bin",
192
- HOME: `${root}/claude`,
193
- CLAUDE_CONFIG_DIR: `${root}/claude`,
202
+ HOME: configDir,
203
+ CLAUDE_CONFIG_DIR: configDir,
194
204
  LANG: "C.UTF-8",
195
205
  CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1",
196
206
  },
@@ -202,12 +212,15 @@ export function claudeInvocation(request, root, mcpSpecPath = null) {
202
212
  // this boundary; CLI stdout can contain account identity and is never forwarded.
203
213
  export function readClaudeAuthentication({
204
214
  root,
215
+ configDir = `${root}/claude`,
205
216
  execFileImpl = execFile,
206
217
  timeoutMs = 8_000,
207
218
  } = {}) {
208
219
  const invocation = claudeInvocation(
209
220
  { instructions: "", input: "", model: "claude-opus", effort: "high" },
210
- root
221
+ root,
222
+ null,
223
+ configDir
211
224
  );
212
225
  return new Promise((resolve) => {
213
226
  execFileImpl(
@@ -234,6 +247,91 @@ export function readClaudeAuthentication({
234
247
  });
235
248
  }
236
249
 
250
+ // Each sign-in is its own Claude config folder: the primary `claude`, then
251
+ // `accounts/<name>` in name order. Refresh tokens rotate inside a folder, so a
252
+ // login is never copied between folders.
253
+ export function claudeAccountDirs(root) {
254
+ const dirs = [`${root}/claude`];
255
+ let names = [];
256
+ try {
257
+ names = readdirSync(`${root}/accounts`).sort();
258
+ } catch {
259
+ /* No extra accounts. */
260
+ }
261
+ for (const name of names)
262
+ if (
263
+ /^[a-z0-9][a-z0-9-]{0,62}$/.test(name) &&
264
+ existsSync(`${root}/accounts/${name}/.credentials.json`)
265
+ )
266
+ dirs.push(`${root}/accounts/${name}`);
267
+ return dirs;
268
+ }
269
+
270
+ const MONTHS = "janfebmaraprmayjunjulaugsepoctnovdec";
271
+ const USAGE_LIMIT_RETRY_MS = 15 * 60_000;
272
+ const SIGN_IN_TRUST_MS = 5 * 60_000;
273
+
274
+ // Claude Code reports a used-up plan as an API 429 result, for example
275
+ // "You've hit your weekly limit · resets Oct 7, 10am (UTC)". Returns when that
276
+ // account can be tried again, or null for any other result.
277
+ export function claudeUsageLimit(stdout, now = Date.now()) {
278
+ let value;
279
+ try {
280
+ value = JSON.parse(stdout);
281
+ } catch {
282
+ return null;
283
+ }
284
+ if (
285
+ !record(value) ||
286
+ value.is_error !== true ||
287
+ value.api_error_status !== 429
288
+ )
289
+ return null;
290
+ const reset =
291
+ /resets\s+(?:([a-z]{3})[a-z]*\s+(\d{1,2}),?\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)\s*\(UTC\)/i.exec(
292
+ String(value.result ?? "")
293
+ );
294
+ let retryAt = null;
295
+ if (reset) {
296
+ const hour =
297
+ (Number(reset[3]) % 12) + (reset[5].toLowerCase() === "pm" ? 12 : 0);
298
+ const minute = Number(reset[4] ?? 0);
299
+ const today = new Date(now);
300
+ if (reset[1]) {
301
+ const month = MONTHS.indexOf(reset[1].toLowerCase()) / 3;
302
+ if (Number.isInteger(month)) {
303
+ retryAt = Date.UTC(
304
+ today.getUTCFullYear(),
305
+ month,
306
+ Number(reset[2]),
307
+ hour,
308
+ minute
309
+ );
310
+ if (retryAt < now - 86_400_000)
311
+ retryAt = Date.UTC(
312
+ today.getUTCFullYear() + 1,
313
+ month,
314
+ Number(reset[2]),
315
+ hour,
316
+ minute
317
+ );
318
+ }
319
+ } else {
320
+ retryAt = Date.UTC(
321
+ today.getUTCFullYear(),
322
+ today.getUTCMonth(),
323
+ today.getUTCDate(),
324
+ hour,
325
+ minute
326
+ );
327
+ if (retryAt <= now) retryAt += 86_400_000;
328
+ }
329
+ }
330
+ if (retryAt === null || retryAt <= now || retryAt > now + 8 * 86_400_000)
331
+ retryAt = now + USAGE_LIMIT_RETRY_MS;
332
+ return { retryAt };
333
+ }
334
+
237
335
  export function parseClaudeResult(stdout, request) {
238
336
  const result = JSON.parse(stdout);
239
337
  const models = Object.keys(result.modelUsage ?? {});
@@ -272,7 +370,12 @@ export function parseClaudeResult(stdout, request) {
272
370
 
273
371
  export function startClaudeRun(
274
372
  request,
275
- { root, spawnImpl = spawn, timeoutMs = 750_000 } = {}
373
+ {
374
+ root,
375
+ configDir = `${root}/claude`,
376
+ spawnImpl = spawn,
377
+ timeoutMs = 750_000,
378
+ } = {}
276
379
  ) {
277
380
  // The tool server's environment travels in a private per-run file, never
278
381
  // in argv, and is removed when the run ends.
@@ -288,7 +391,7 @@ export function startClaudeRun(
288
391
  const removeSpec = () => {
289
392
  if (mcpSpecPath) rmSync(mcpSpecPath, { force: true });
290
393
  };
291
- const invocation = claudeInvocation(request, root, mcpSpecPath);
394
+ const invocation = claudeInvocation(request, root, mcpSpecPath, configDir);
292
395
  let child;
293
396
  try {
294
397
  child = spawnImpl(invocation.command, invocation.args, invocation.options);
@@ -334,12 +437,16 @@ export function startClaudeRun(
334
437
  clearTimeout(timer);
335
438
  clearTimeout(killTimer);
336
439
  removeSpec();
440
+ const stdout = Buffer.concat(chunks).toString("utf8");
441
+ const limit = reason ? null : claudeUsageLimit(stdout);
442
+ if (limit)
443
+ return reject(
444
+ Object.assign(new Error("claude_runs_usage_limited"), limit)
445
+ );
337
446
  if (reason || code !== 0)
338
447
  return reject(new Error(reason ?? "claude_runs_cli_failed"));
339
448
  try {
340
- resolve(
341
- parseClaudeResult(Buffer.concat(chunks).toString("utf8"), request)
342
- );
449
+ resolve(parseClaudeResult(stdout, request));
343
450
  } catch {
344
451
  reject(new Error("claude_runs_output_rejected"));
345
452
  }
@@ -353,42 +460,125 @@ export function startClaudeRun(
353
460
  export function createClaudeRunsApi({
354
461
  token,
355
462
  connection,
356
- execute = (request) => {
463
+ execute = null,
464
+ audit = () => {},
465
+ maxConcurrency = CLAUDE_RUNS_MAX_CONCURRENCY,
466
+ // Tool lanes (replies, follow-ups) have their own slots so a batch of
467
+ // openers can never crowd them out.
468
+ maxToolConcurrency = CLAUDE_RUNS_MAX_CONCURRENCY,
469
+ toolLanes = null,
470
+ checkAuthentication = (configDir) =>
471
+ readClaudeAuthentication({ root: connection.localRoot, configDir }),
472
+ accounts = () => claudeAccountDirs(connection.localRoot),
473
+ startRun = startClaudeRun,
474
+ now = Date.now,
475
+ } = {}) {
476
+ if (!/^[a-f0-9]{64}$/.test(token ?? ""))
477
+ throw new Error("claude_runs_key_rejected");
478
+ // An account whose plan is used up rests until its reset; runs go to the
479
+ // next signed-in account meanwhile, and back to the first once it resets.
480
+ const usageLimitedUntil = new Map();
481
+ const resting = (configDir) =>
482
+ (usageLimitedUntil.get(configDir) ?? 0) > now();
483
+ // `claude auth status` starts a whole CLI: on the 4-CPU Admin machine seven
484
+ // at once take longer than its 8s timeout. Runs that arrive together share
485
+ // one check, a good answer is trusted for five minutes, and only a definite
486
+ // SIGN_IN_REQUIRED keeps a run off an account; an unanswered check runs and
487
+ // lets the CLI itself fail the run if the sign-in is really gone.
488
+ const signedInAt = new Map();
489
+ const signInChecks = new Map();
490
+ const signInState = (configDir) => {
491
+ if (now() - (signedInAt.get(configDir) ?? -Infinity) < SIGN_IN_TRUST_MS)
492
+ return Promise.resolve("READY");
493
+ let check = signInChecks.get(configDir);
494
+ if (!check) {
495
+ check = Promise.resolve()
496
+ .then(() => checkAuthentication(configDir))
497
+ .catch(() => "UNAVAILABLE")
498
+ .then((state) => {
499
+ signInChecks.delete(configDir);
500
+ if (state === "READY") signedInAt.set(configDir, now());
501
+ else signedInAt.delete(configDir);
502
+ return state;
503
+ });
504
+ signInChecks.set(configDir, check);
505
+ }
506
+ return check;
507
+ };
508
+ const runOnAccounts = (request) => {
357
509
  let activeRun;
358
510
  let stopped = false;
359
511
  return {
360
512
  promise: (async () => {
361
- const state = await checkAuthentication();
362
- if (stopped) throw new Error("claude_runs_cancelled");
363
- if (state !== "READY")
364
- throw new Error(
365
- state === "SIGN_IN_REQUIRED"
366
- ? "claude_runs_sign_in_required"
367
- : "claude_runs_auth_check_unavailable"
368
- );
369
- activeRun = startClaudeRun(request, { root: connection.localRoot });
370
- return activeRun.promise;
513
+ let failure = null;
514
+ for (const configDir of accounts()) {
515
+ if (resting(configDir)) {
516
+ failure = "claude_runs_usage_limited";
517
+ continue;
518
+ }
519
+ const state = await signInState(configDir);
520
+ if (stopped) throw new Error("claude_runs_cancelled");
521
+ if (state === "SIGN_IN_REQUIRED") {
522
+ failure ??= "claude_runs_sign_in_required";
523
+ continue;
524
+ }
525
+ activeRun = startRun(request, {
526
+ root: connection.localRoot,
527
+ configDir,
528
+ });
529
+ try {
530
+ return await activeRun.promise;
531
+ } catch (error) {
532
+ if (stopped || error.message !== "claude_runs_usage_limited") {
533
+ signedInAt.delete(configDir);
534
+ throw error;
535
+ }
536
+ usageLimitedUntil.set(configDir, error.retryAt);
537
+ failure = "claude_runs_usage_limited";
538
+ }
539
+ }
540
+ throw new Error(failure ?? "claude_runs_sign_in_required");
371
541
  })(),
372
542
  stop() {
373
543
  stopped = true;
374
544
  activeRun?.stop();
375
545
  },
376
546
  };
377
- },
378
- audit = () => {},
379
- maxConcurrency = CLAUDE_RUNS_MAX_CONCURRENCY,
380
- // Tool lanes (replies, follow-ups) have their own slots so a batch of
381
- // openers can never crowd them out.
382
- maxToolConcurrency = CLAUDE_RUNS_MAX_CONCURRENCY,
383
- toolLanes = null,
384
- checkAuthentication = () =>
385
- readClaudeAuthentication({ root: connection.localRoot }),
386
- } = {}) {
387
- if (!/^[a-f0-9]{64}$/.test(token ?? ""))
388
- throw new Error("claude_runs_key_rejected");
547
+ };
548
+ const executeRun = execute ?? runOnAccounts;
549
+ const readHealth = async () => {
550
+ const dirs = accounts();
551
+ let ready = 0;
552
+ let signedOut = 0;
553
+ const retryAts = [];
554
+ for (const configDir of dirs) {
555
+ if (resting(configDir)) {
556
+ retryAts.push(usageLimitedUntil.get(configDir));
557
+ continue;
558
+ }
559
+ const state = await signInState(configDir);
560
+ if (state === "READY") ready++;
561
+ if (state === "SIGN_IN_REQUIRED") signedOut++;
562
+ }
563
+ return {
564
+ authentication: ready
565
+ ? "READY"
566
+ : signedOut === dirs.length
567
+ ? "SIGN_IN_REQUIRED"
568
+ : "UNAVAILABLE",
569
+ accounts: {
570
+ total: dirs.length,
571
+ ready,
572
+ usageLimited: retryAts.length,
573
+ retryAt: retryAts.length
574
+ ? new Date(Math.min(...retryAts)).toISOString()
575
+ : null,
576
+ },
577
+ };
578
+ };
389
579
  // The run's own tools and the profile's MCP server, or null for a tool-free run.
390
580
  const resolveTools = (request) => {
391
- if (!connection.toolEndpointIds || !toolLanes) return null;
581
+ if (!toolLanes) return null;
392
582
  const names = endpointToolNames({
393
583
  turnDescriptorPath: toolLanes.turnDescriptorPath,
394
584
  sessionId: request.sessionId,
@@ -465,12 +655,11 @@ export function createClaudeRunsApi({
465
655
  )
466
656
  return json(res, 403, { error: "claude_runs_scope_rejected" });
467
657
  if (req.method === "GET" && req.url === "/health") {
468
- const authentication = await checkAuthentication().catch(
469
- () => "UNAVAILABLE"
470
- );
658
+ const { authentication, accounts: accountHealth } = await readHealth();
471
659
  return json(res, 200, {
472
660
  ok: authentication === "READY",
473
661
  authentication,
662
+ accounts: accountHealth,
474
663
  provider: "claude-code",
475
664
  active: inFlight.plain + inFlight.tools,
476
665
  toolActive: inFlight.tools,
@@ -481,7 +670,7 @@ export function createClaudeRunsApi({
481
670
  });
482
671
  }
483
672
  if (req.method === "POST" && req.url === "/v1/runs") {
484
- if (full("plain") && (!connection.toolEndpointIds || full("tools")))
673
+ if (full("plain") && (!toolLanes || full("tools")))
485
674
  return json(res, 429, { error: "claude_runs_busy" });
486
675
  let request;
487
676
  try {
@@ -521,7 +710,7 @@ export function createClaudeRunsApi({
521
710
  inFlight[pool]++;
522
711
  runs.set(run.run_id, run);
523
712
  try {
524
- run.execution = execute(request);
713
+ run.execution = executeRun(request);
525
714
  run.execution.promise
526
715
  .then(
527
716
  (result) =>
@@ -542,6 +731,7 @@ export function createClaudeRunsApi({
542
731
  error: [
543
732
  "claude_runs_sign_in_required",
544
733
  "claude_runs_auth_check_unavailable",
734
+ "claude_runs_usage_limited",
545
735
  ].includes(error.message)
546
736
  ? error.message
547
737
  : "claude_runs_generation_failed",
@@ -126,7 +126,7 @@ const RUNTIME_GENERATION = Number(
126
126
  const BOOT_SESSION_ID = randomUUID();
127
127
  const HERMES_VERSION = process.env.SELLABLE_AGENT_HERMES_VERSION ?? "0.21.5";
128
128
  const INSTALLER_PACKAGE =
129
- process.env.SELLABLE_AGENT_INSTALLER_PACKAGE ?? "@sellable/install@0.1.798";
129
+ process.env.SELLABLE_AGENT_INSTALLER_PACKAGE ?? "@sellable/install@0.1.800";
130
130
  const MCP_PACKAGE =
131
131
  process.env.SELLABLE_AGENT_MCP_PACKAGE ?? "@sellable/mcp@0.1.957";
132
132
  export const ENDPOINT_MAX_CONCURRENT_RUNS = 10;
@@ -5,7 +5,8 @@ export type RunsApiConnection = Readonly<{
5
5
  agentId: string;
6
6
  baseUrl: string;
7
7
  apiKeyFile: string;
8
- endpointIds: string[];
8
+ /** Absent: serves every endpoint of its agent. */
9
+ endpointIds?: string[];
9
10
  workKinds: Array<"STANDARD" | "OPENER" | "REPLY" | "FOLLOW_UP">;
10
11
  models: Array<{
11
12
  provider: string;
@@ -19,6 +20,10 @@ export type RunsApiConnection = Readonly<{
19
20
  structuredOutputs?: boolean;
20
21
  }>;
21
22
  export function runsApiConnectionId(connection: RunsApiConnection): string;
23
+ export function runsApiConnectionServesEndpoint(
24
+ connection: RunsApiConnection,
25
+ endpointId: string
26
+ ): boolean;
22
27
  export function parseRunsApiConnections(raw: unknown): RunsApiConnection[];
23
28
  export function runsApiConnectionsForRuntime(
24
29
  raw: unknown,
@@ -76,10 +76,12 @@ export function parseRunsApiConnections(raw) {
76
76
  entry.apiKeyFile
77
77
  .split("/")
78
78
  .some((part) => part === "." || part === "..") ||
79
- !Array.isArray(entry.endpointIds) ||
80
- !entry.endpointIds.length ||
81
- entry.endpointIds.length > 64 ||
82
- !entry.endpointIds.every(id) ||
79
+ // Absent: the connection serves every endpoint of its agent.
80
+ (entry.endpointIds !== undefined &&
81
+ (!Array.isArray(entry.endpointIds) ||
82
+ !entry.endpointIds.length ||
83
+ entry.endpointIds.length > 64 ||
84
+ !entry.endpointIds.every(id))) ||
83
85
  !Array.isArray(entry.workKinds) ||
84
86
  !entry.workKinds.length ||
85
87
  !entry.workKinds.every((kind) =>
@@ -138,24 +140,29 @@ export function parseRunsApiConnections(raw) {
138
140
  )
139
141
  fail();
140
142
  }
141
- // Runs on these endpoints get their own allowed tools from the image-owned
142
- // service; every other run stays tool-free.
143
+ // When present, only runs on these endpoints get their own allowed tools
144
+ // from the image-owned service. When absent, a run gets the tools its own
145
+ // signed turn descriptor allows, and a run allowed none stays tool-free.
143
146
  if (
144
147
  entry.toolEndpointIds !== undefined &&
145
148
  (entry.localRoot === undefined ||
146
149
  !Array.isArray(entry.toolEndpointIds) ||
147
150
  !entry.toolEndpointIds.length ||
151
+ !entry.toolEndpointIds.every(id) ||
148
152
  new Set(entry.toolEndpointIds).size !== entry.toolEndpointIds.length ||
149
- entry.toolEndpointIds.some(
150
- (value) => !entry.endpointIds.includes(value)
151
- ))
153
+ (entry.endpointIds !== undefined &&
154
+ entry.toolEndpointIds.some(
155
+ (value) => !entry.endpointIds.includes(value)
156
+ )))
152
157
  )
153
158
  fail();
154
159
  return Object.freeze({
155
160
  ...entry,
156
161
  baseUrl,
157
162
  models,
158
- endpointIds: [...entry.endpointIds],
163
+ ...(entry.endpointIds === undefined
164
+ ? {}
165
+ : { endpointIds: [...entry.endpointIds] }),
159
166
  workKinds: [...entry.workKinds],
160
167
  ...(entry.toolEndpointIds === undefined
161
168
  ? {}
@@ -164,6 +171,14 @@ export function parseRunsApiConnections(raw) {
164
171
  });
165
172
  }
166
173
 
174
+ /** Whether a trusted connection may serve runs of this endpoint. */
175
+ export function runsApiConnectionServesEndpoint(connection, endpointId) {
176
+ return (
177
+ connection.endpointIds === undefined ||
178
+ connection.endpointIds.includes(endpointId)
179
+ );
180
+ }
181
+
167
182
  export function runsApiConnectionId(connection) {
168
183
  return `runs_${createHash("sha256")
169
184
  .update(
@@ -210,7 +225,7 @@ export function createConfiguredRunsClient({
210
225
  if (
211
226
  !connection ||
212
227
  env.SELLABLE_RUNTIME_ID !== connection.runtimeId ||
213
- !connection.endpointIds.includes(claim.attempt.endpointId) ||
228
+ !runsApiConnectionServesEndpoint(connection, claim.attempt.endpointId) ||
214
229
  !connection.workKinds.includes(workKind) ||
215
230
  !connection.models.some(
216
231
  (allowed) =>
@@ -7,6 +7,7 @@ import {
7
7
  createConfiguredRunsClient,
8
8
  RUNS_API_CONNECTIONS_ENV,
9
9
  runsApiConnectionId,
10
+ runsApiConnectionServesEndpoint,
10
11
  runsApiConnectionsForRuntime,
11
12
  } from "./runs-api-connections.mjs";
12
13
 
@@ -35,7 +36,7 @@ export async function checkRunsApiConnection(
35
36
  ).find(
36
37
  (candidate) =>
37
38
  runsApiConnectionId(candidate) === request.connectionId &&
38
- candidate.endpointIds.includes(request.endpointId)
39
+ runsApiConnectionServesEndpoint(candidate, request.endpointId)
39
40
  );
40
41
  if (!connection) return result("NOT_CONFIGURED");
41
42
  try {
@@ -47,10 +48,12 @@ export async function checkRunsApiConnection(
47
48
  {
48
49
  headers: {
49
50
  authorization: `Bearer ${token}`,
50
- ...(connection.protocol?.startsWith("openai-") ? {} : {
51
- "x-sellable-workspace-id": connection.workspaceId,
52
- "x-sellable-agent-id": connection.agentId,
53
- }),
51
+ ...(connection.protocol?.startsWith("openai-")
52
+ ? {}
53
+ : {
54
+ "x-sellable-workspace-id": connection.workspaceId,
55
+ "x-sellable-agent-id": connection.agentId,
56
+ }),
54
57
  },
55
58
  redirect: "error",
56
59
  signal: AbortSignal.timeout(12_000),
@@ -141,7 +144,10 @@ export async function verifyRunsApiSelection(
141
144
  runsApiConnectionId(item) === request.connectionId &&
142
145
  item.baseUrl === selection.runsApiBaseUrl
143
146
  );
144
- if (!connection?.endpointIds.includes(request.endpointId))
147
+ if (
148
+ !connection ||
149
+ !runsApiConnectionServesEndpoint(connection, request.endpointId)
150
+ )
145
151
  return result("NOT_CONFIGURED");
146
152
  verificationWorkKind = connection.workKinds.includes("STANDARD")
147
153
  ? "STANDARD"
@@ -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.798";
41
+ const INSTALLER_PACKAGE = "@sellable/install@0.1.800";
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.798" ||
1549
+ pinned.installerPackage !== "@sellable/install@0.1.800" ||
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.798" ||
315
+ desired.installerPackage !== "@sellable/install@0.1.800" ||
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.798";
28
+ export const PINNED_INSTALL_PACKAGE = "@sellable/install@0.1.800";
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.798",
3
+ "version": "0.1.800",
4
4
  "type": "module",
5
5
  "description": "One-command installer for Sellable MCP in Claude Code, Codex, and Hermes",
6
6
  "bin": {