@sellable/install 0.1.799 → 0.1.801

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.799
6
+ ARG INSTALLER_PACKAGE=@sellable/install@0.1.801
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.799" \
11
+ RUN test "${INSTALLER_PACKAGE}" = "@sellable/install@0.1.801" \
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.799" \
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.799' || 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.801' || 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.799', 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.801', 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.799` is
6
+ `@sellable/install@0.1.801` 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.799
11
+ ARG INSTALLER_PACKAGE=@sellable/install@0.1.801
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
@@ -58,7 +58,8 @@ export function validateClaudeRunsRequest(
58
58
  export function endpointToolNames(options: {
59
59
  turnDescriptorPath: string;
60
60
  sessionId: string;
61
- toolEndpointIds: string[];
61
+ /** Absent: every run gets the tools its own turn descriptor allows. */
62
+ toolEndpointIds?: string[];
62
63
  }): string[] | null;
63
64
  export function claudeInvocation(
64
65
  request: ClaudeRunsRequest,
@@ -100,15 +100,17 @@ export function validateClaudeRunsRequest(body, models) {
100
100
  }
101
101
 
102
102
  /**
103
- * A tool-lane run's own allowed tools, read from the signed turn descriptor
104
- * the runtime installed for its session before submitting; null when the run
105
- * is not on a tool endpoint. This only narrows: the context proxy verifies the
106
- * 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.
107
109
  */
108
110
  export function endpointToolNames({
109
111
  turnDescriptorPath,
110
112
  sessionId,
111
- toolEndpointIds,
113
+ toolEndpointIds = undefined,
112
114
  }) {
113
115
  const path = endpointMcpDescriptorPath(turnDescriptorPath, sessionId);
114
116
  if (!path) return null;
@@ -120,18 +122,19 @@ export function endpointToolNames({
120
122
  throw error;
121
123
  }
122
124
  const body = JSON.parse(readFileSync(path, "utf8"))?.body;
123
- if (!toolEndpointIds.includes(body?.endpointId)) return null;
124
- const names = body.allowedToolNames;
125
+ if (toolEndpointIds && !toolEndpointIds.includes(body?.endpointId))
126
+ return null;
127
+ const names = body?.allowedToolNames;
125
128
  if (
126
129
  endpointHermesSessionId(body) !== sessionId ||
127
130
  !Array.isArray(names) ||
128
- !names.length ||
131
+ (toolEndpointIds && !names.length) ||
129
132
  names.some(
130
133
  (name) => typeof name !== "string" || !/^[A-Za-z0-9_-]{1,128}$/.test(name)
131
134
  )
132
135
  )
133
136
  throw new Error("claude_runs_tool_descriptor_rejected");
134
- return [...names];
137
+ return names.length ? [...names] : null;
135
138
  }
136
139
 
137
140
  export function claudeInvocation(
@@ -266,6 +269,7 @@ export function claudeAccountDirs(root) {
266
269
 
267
270
  const MONTHS = "janfebmaraprmayjunjulaugsepoctnovdec";
268
271
  const USAGE_LIMIT_RETRY_MS = 15 * 60_000;
272
+ const SIGN_IN_TRUST_MS = 5 * 60_000;
269
273
 
270
274
  // Claude Code reports a used-up plan as an API 429 result, for example
271
275
  // "You've hit your weekly limit · resets Oct 7, 10am (UTC)". Returns when that
@@ -476,6 +480,31 @@ export function createClaudeRunsApi({
476
480
  const usageLimitedUntil = new Map();
477
481
  const resting = (configDir) =>
478
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
+ };
479
508
  const runOnAccounts = (request) => {
480
509
  let activeRun;
481
510
  let stopped = false;
@@ -487,13 +516,10 @@ export function createClaudeRunsApi({
487
516
  failure = "claude_runs_usage_limited";
488
517
  continue;
489
518
  }
490
- const state = await checkAuthentication(configDir);
519
+ const state = await signInState(configDir);
491
520
  if (stopped) throw new Error("claude_runs_cancelled");
492
- if (state !== "READY") {
493
- failure ??=
494
- state === "SIGN_IN_REQUIRED"
495
- ? "claude_runs_sign_in_required"
496
- : "claude_runs_auth_check_unavailable";
521
+ if (state === "SIGN_IN_REQUIRED") {
522
+ failure ??= "claude_runs_sign_in_required";
497
523
  continue;
498
524
  }
499
525
  activeRun = startRun(request, {
@@ -503,8 +529,10 @@ export function createClaudeRunsApi({
503
529
  try {
504
530
  return await activeRun.promise;
505
531
  } catch (error) {
506
- if (stopped || error.message !== "claude_runs_usage_limited")
532
+ if (stopped || error.message !== "claude_runs_usage_limited") {
533
+ signedInAt.delete(configDir);
507
534
  throw error;
535
+ }
508
536
  usageLimitedUntil.set(configDir, error.retryAt);
509
537
  failure = "claude_runs_usage_limited";
510
538
  }
@@ -528,9 +556,7 @@ export function createClaudeRunsApi({
528
556
  retryAts.push(usageLimitedUntil.get(configDir));
529
557
  continue;
530
558
  }
531
- const state = await checkAuthentication(configDir).catch(
532
- () => "UNAVAILABLE"
533
- );
559
+ const state = await signInState(configDir);
534
560
  if (state === "READY") ready++;
535
561
  if (state === "SIGN_IN_REQUIRED") signedOut++;
536
562
  }
@@ -552,7 +578,7 @@ export function createClaudeRunsApi({
552
578
  };
553
579
  // The run's own tools and the profile's MCP server, or null for a tool-free run.
554
580
  const resolveTools = (request) => {
555
- if (!connection.toolEndpointIds || !toolLanes) return null;
581
+ if (!toolLanes) return null;
556
582
  const names = endpointToolNames({
557
583
  turnDescriptorPath: toolLanes.turnDescriptorPath,
558
584
  sessionId: request.sessionId,
@@ -644,7 +670,7 @@ export function createClaudeRunsApi({
644
670
  });
645
671
  }
646
672
  if (req.method === "POST" && req.url === "/v1/runs") {
647
- if (full("plain") && (!connection.toolEndpointIds || full("tools")))
673
+ if (full("plain") && (!toolLanes || full("tools")))
648
674
  return json(res, 429, { error: "claude_runs_busy" });
649
675
  let request;
650
676
  try {
@@ -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.799";
129
+ process.env.SELLABLE_AGENT_INSTALLER_PACKAGE ?? "@sellable/install@0.1.801";
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"
@@ -177,10 +183,13 @@ export async function verifyRunsApiSelection(
177
183
  },
178
184
  })
179
185
  : makeDefaultClient({ apiKeyFile });
186
+ // A fresh token per check. Claude Opus 5.5 answers one fixed string
187
+ // ("SELLABLE_CONNECTION_OK") with nothing, so Claude Code fell back to
188
+ // another model and every Claude Code verification failed.
189
+ const token = `ready-${randomUUID().replaceAll("-", "").slice(0, 8)}`;
180
190
  const compiled = {
181
- input: "Connection verification. Reply with SELLABLE_CONNECTION_OK.",
182
- instructions:
183
- "This is a connection check. Do not use tools or take actions. Return exactly SELLABLE_CONNECTION_OK and nothing else.",
191
+ input: `Connection verification. Reply with ${token}.`,
192
+ instructions: `This is a connection check. Do not use tools or take actions. Return exactly ${token} and nothing else.`,
184
193
  conversationHistory: [],
185
194
  correlation: `connection-check-${randomUUID()}`,
186
195
  ...(selection ? { modelSelection: selection } : {}),
@@ -197,7 +206,7 @@ export async function verifyRunsApiSelection(
197
206
  if (
198
207
  status.status !== "completed" ||
199
208
  typeof status.output !== "string" ||
200
- status.output.trim() !== "SELLABLE_CONNECTION_OK" ||
209
+ status.output.trim() !== token ||
201
210
  !served
202
211
  )
203
212
  return result("UNAVAILABLE");
@@ -16,10 +16,8 @@ import {
16
16
  readdirSync,
17
17
  renameSync,
18
18
  rmSync,
19
- statfsSync,
20
19
  writeFileSync,
21
20
  } from "node:fs";
22
- import { tmpdir } from "node:os";
23
21
  import { basename, dirname, isAbsolute, join, resolve } from "node:path";
24
22
  import { promisify } from "node:util";
25
23
  import { parseDocument } from "yaml";
@@ -213,18 +211,11 @@ export const HERMES_SNAPSHOT_MAX_ARCHIVE_BYTES = 512 * 1024 * 1024;
213
211
  const MAX_PLUGIN_DATA_BYTES = 10 * 1024 * 1024;
214
212
  const MAX_PORTABLE_EXTERNAL_BYTES = 32 * 1024 * 1024;
215
213
  const MAX_PORTABLE_CONFIG_BYTES = 64 * 1024;
216
- // Portable snapshots contain user-authored/customizable state plus the compact
217
- // Hermes session database and routing records required to continue an existing
218
- // Slack thread after a Machine/volume migration. If native Hermes cannot
219
- // package that bounded set within two minutes, abort the helper and let the
220
- // persistent retry scheduler back off instead of contending with the live
221
- // Slack gateway for a full ten minutes.
214
+ // Portable snapshots contain only state that compounds (see
215
+ // HERMES_SNAPSHOT_USER_STATE_ENTRIES), a few megabytes. If native Hermes cannot
216
+ // package that within two minutes, abort the helper and let the persistent
217
+ // retry scheduler back off instead of contending with the live Slack gateway.
222
218
  const NATIVE_COMMAND_TIMEOUT_MS = 120_000;
223
- // A healthy established Admin state DB can exceed 500 MiB. Keep compaction
224
- // bounded inside the 15-minute Upgrade backup stage without killing SQLite's
225
- // final VACUUM on shared Fly CPU.
226
- const STATE_DB_COMPACTION_TIMEOUT_MS = 600_000;
227
- export const HERMES_SNAPSHOT_CRON_SESSION_RETENTION_HOURS = 48;
228
219
  const TRANSIENT_PROFILE_ROOTS = new Set([
229
220
  ".artifact-venv",
230
221
  ".cache",
@@ -259,11 +250,11 @@ export function isHermesTransientProfilePath(relativePath) {
259
250
  TRANSIENT_PROFILE_PATHS.has(relativePath)
260
251
  );
261
252
  }
262
- // The encrypted Fly volume remains the authoritative full profile. The
263
- // portable disaster-recovery archive also carries state.db and sessions so a
264
- // replacement volume retains chat history and Slack thread routing. Caches,
265
- // dependency trees, secrets, and live logs remain excluded.
266
- export const HERMES_SNAPSHOT_USER_STATE_ENTRIES = Object.freeze([
253
+ // Durable profile entries the pre-profile layout kept directly under
254
+ // HERMES_HOME. The one-time legacy move carries all of them, run history
255
+ // included, because it moves live state on the volume rather than packing a
256
+ // recovery archive.
257
+ const HERMES_LEGACY_USER_STATE_ENTRIES = Object.freeze([
267
258
  "AGENTS.md",
268
259
  "SOUL.md",
269
260
  "artifacts",
@@ -286,6 +277,26 @@ export const HERMES_SNAPSHOT_USER_STATE_ENTRIES = Object.freeze([
286
277
  "state.db",
287
278
  "workspace",
288
279
  ]);
280
+ // The encrypted Fly volume remains the authoritative full profile and keeps
281
+ // everything across an in-place Upgrade. The portable archive, used only to
282
+ // rebuild an Agent on a replacement volume, carries just the state that
283
+ // compounds: who the Agent is (SOUL, AGENTS), what it remembers, the skills it
284
+ // learned, its scheduled work (cron and the scripts cron runs), hooks and
285
+ // plugins. Run history (state.db, sessions), task boards and scratch areas
286
+ // never travel: a rebuilt Agent starts fresh conversations with all of the
287
+ // above. Caches, dependency trees, secrets and live logs remain excluded.
288
+ export const HERMES_SNAPSHOT_USER_STATE_ENTRIES = Object.freeze([
289
+ "AGENTS.md",
290
+ "SOUL.md",
291
+ "cron",
292
+ "hooks",
293
+ "memories",
294
+ "memory",
295
+ "plugin-data",
296
+ "plugins",
297
+ "scripts",
298
+ "skills",
299
+ ]);
289
300
  export const HERMES_SNAPSHOT_RETRY_BACKOFF_MS = Object.freeze([
290
301
  15 * 60_000,
291
302
  60 * 60_000,
@@ -998,272 +1009,6 @@ const childRunner = (runChild) => runChild ?? runBoundedHermesChild;
998
1009
  const childFailed = (result) =>
999
1010
  result?.exitCode !== 0 || result?.timedOut || result?.outputLimitExceeded;
1000
1011
 
1001
- const STATE_DB_COMPACTION_SCRIPT = String.raw`
1002
- import json
1003
- import os
1004
- import sqlite3
1005
- import sys
1006
- import time
1007
-
1008
- source_path, target_path = sys.argv[1], sys.argv[2]
1009
- retention_hours = int(sys.argv[3])
1010
- # VACUUM INTO writes one consistent, compact copy of the live pages only, so
1011
- # space the live database has freed never travels with the backup.
1012
- source = sqlite3.connect("file:" + source_path + "?mode=ro", uri=True)
1013
- try:
1014
- source.execute("VACUUM INTO ?", (target_path,))
1015
- finally:
1016
- source.close()
1017
- destination = sqlite3.connect(target_path)
1018
-
1019
- try:
1020
- if destination.execute("PRAGMA quick_check").fetchone()[0] != "ok":
1021
- raise RuntimeError("source backup failed quick_check")
1022
- destination.execute("PRAGMA foreign_keys=ON")
1023
- table_names = {
1024
- row[0]
1025
- for row in destination.execute(
1026
- "SELECT name FROM sqlite_master WHERE type='table'"
1027
- )
1028
- }
1029
- pruned_sessions = 0
1030
- pruned_messages = 0
1031
- if {"sessions", "messages"}.issubset(table_names):
1032
- cutoff = time.time() - retention_hours * 60 * 60
1033
- session_columns = {
1034
- row[1] for row in destination.execute("PRAGMA table_info(sessions)")
1035
- }
1036
- pinned_clause = (
1037
- "AND COALESCE(pinned, 0) = 0" if "pinned" in session_columns else ""
1038
- )
1039
- destination.execute(
1040
- "CREATE TEMP TABLE snapshot_pruned_sessions(id TEXT PRIMARY KEY)"
1041
- )
1042
- destination.execute(
1043
- "WITH RECURSIVE doomed(id) AS ("
1044
- # Hermes never ends an endpoint run's (api_server) session; past
1045
- # the retention it is as finished as an ended cron session.
1046
- " SELECT id FROM sessions"
1047
- " WHERE ((source = 'cron' AND ended_at IS NOT NULL)"
1048
- " OR source = 'api_server')"
1049
- " AND COALESCE(last_activity_at, ended_at, started_at) < ? "
1050
- + pinned_clause
1051
- + " UNION"
1052
- " SELECT child.id FROM sessions child"
1053
- " JOIN doomed parent ON child.parent_session_id = parent.id"
1054
- ") INSERT OR IGNORE INTO snapshot_pruned_sessions(id)"
1055
- " SELECT id FROM doomed",
1056
- (cutoff,),
1057
- )
1058
- pruned_sessions = destination.execute(
1059
- "SELECT COUNT(*) FROM snapshot_pruned_sessions"
1060
- ).fetchone()[0]
1061
- pruned_messages = destination.execute(
1062
- "SELECT COUNT(*) FROM messages WHERE session_id IN "
1063
- "(SELECT id FROM snapshot_pruned_sessions)"
1064
- ).fetchone()[0]
1065
- destination.execute(
1066
- "DELETE FROM messages WHERE session_id IN "
1067
- "(SELECT id FROM snapshot_pruned_sessions)"
1068
- )
1069
- if "session_model_usage" in table_names:
1070
- destination.execute(
1071
- "DELETE FROM session_model_usage WHERE session_id IN "
1072
- "(SELECT id FROM snapshot_pruned_sessions)"
1073
- )
1074
- destination.execute(
1075
- "DELETE FROM sessions WHERE id IN "
1076
- "(SELECT id FROM snapshot_pruned_sessions)"
1077
- )
1078
- if "system_prompts" in table_names:
1079
- destination.execute(
1080
- "DELETE FROM system_prompts WHERE NOT EXISTS ("
1081
- " SELECT 1 FROM sessions"
1082
- " WHERE sessions.system_prompt_hash = system_prompts.hash"
1083
- ")"
1084
- )
1085
- destination.commit()
1086
- if pruned_sessions:
1087
- for table in ("messages_fts", "messages_fts_trigram", "messages_fts_cjk"):
1088
- if table in table_names:
1089
- destination.execute(
1090
- 'INSERT INTO "' + table + '" ("' + table + '") VALUES (\'optimize\')'
1091
- )
1092
- destination.commit()
1093
- destination.execute("VACUUM")
1094
- if destination.execute("PRAGMA quick_check").fetchone()[0] != "ok":
1095
- raise RuntimeError("compacted backup failed quick_check")
1096
- if destination.execute("PRAGMA foreign_key_check").fetchone() is not None:
1097
- raise RuntimeError("compacted backup failed foreign_key_check")
1098
- finally:
1099
- destination.close()
1100
-
1101
- os.chmod(target_path, 0o600)
1102
- print(json.dumps({
1103
- "ok": True,
1104
- "prunedSessions": pruned_sessions,
1105
- "prunedMessages": pruned_messages,
1106
- "retentionHours": retention_hours,
1107
- "archiveStateBytes": os.path.getsize(target_path),
1108
- }, separators=(",", ":")))
1109
- `;
1110
-
1111
- // Room the volume keeps after the compact copy lands, so a backup never
1112
- // leaves the live gateway without space to write.
1113
- const STATE_DB_VOLUME_HEADROOM_BYTES = 64 * 1024 * 1024;
1114
-
1115
- function freeBytesAt(directory) {
1116
- const stats = statfsSync(directory);
1117
- return stats.bavail * stats.bsize;
1118
- }
1119
-
1120
- const compactionFailure = (reason) =>
1121
- new Error("hermes_snapshot_state_db_compaction_failed", { cause: reason });
1122
-
1123
- // A bounded reason for the operator log; never the child's raw output.
1124
- function compactionChildFailureReason(result) {
1125
- if (result?.timedOut) return "timeout";
1126
- if (result?.outputLimitExceeded) return "output_limit";
1127
- const stderr = String(result?.stderr ?? "");
1128
- if (/database or disk is full|No space left on device/i.test(stderr)) {
1129
- return "disk_full";
1130
- }
1131
- if (/quick_check|foreign_key_check/.test(stderr)) return "integrity";
1132
- if (/sqlite3\.\w*Error/.test(stderr)) return "sqlite_error";
1133
- return "child_failed";
1134
- }
1135
-
1136
- export async function compactHermesSnapshotStateDb({
1137
- sourcePath,
1138
- targetPath,
1139
- retentionHours = HERMES_SNAPSHOT_CRON_SESSION_RETENTION_HOURS,
1140
- hermesPython = HERMES_PYTHON,
1141
- scratchDirectory = tmpdir(),
1142
- volumeFreeBytes = freeBytesAt,
1143
- runChild = /** @type {typeof runBoundedHermesChild | undefined} */ (
1144
- undefined
1145
- ),
1146
- }) {
1147
- if (
1148
- !isAbsolute(sourcePath) ||
1149
- !isAbsolute(targetPath) ||
1150
- !isAbsolute(scratchDirectory) ||
1151
- resolve(sourcePath) === resolve(targetPath) ||
1152
- !existsSync(sourcePath) ||
1153
- existsSync(targetPath) ||
1154
- !existsSync(scratchDirectory) ||
1155
- !Number.isSafeInteger(retentionHours) ||
1156
- retentionHours < 1 ||
1157
- retentionHours > 30 * 24
1158
- ) {
1159
- throw new Error("hermes_snapshot_state_db_compaction_rejected");
1160
- }
1161
- regularFile(sourcePath);
1162
- const scratchStat = lstatSync(scratchDirectory);
1163
- if (scratchStat.isSymbolicLink() || !scratchStat.isDirectory()) {
1164
- throw new Error("hermes_snapshot_state_db_compaction_rejected");
1165
- }
1166
- mkdirSync(dirname(targetPath), { recursive: true, mode: 0o700 });
1167
- let scratchRoot;
1168
- let finalTemporaryPath;
1169
- let targetCreated = false;
1170
- try {
1171
- // Keep SQLite's destination plus VACUUM journal on the Machine's
1172
- // ephemeral root filesystem. Building both beside the protected staging
1173
- // tree can briefly require multiple full state.db copies on the small
1174
- // persistent Fly volume and strand an otherwise healthy Upgrade at
1175
- // BACKING_UP with ENOSPC. Only the verified compact database crosses back
1176
- // onto the volume.
1177
- scratchRoot = mkdtempSync(
1178
- join(scratchDirectory, ".sellable-hermes-state-db-")
1179
- );
1180
- chmodSync(scratchRoot, 0o700);
1181
- const scratchTargetPath = join(scratchRoot, "state.db");
1182
- const result = await childRunner(runChild)({
1183
- command: hermesPython,
1184
- args: [
1185
- "-c",
1186
- STATE_DB_COMPACTION_SCRIPT,
1187
- sourcePath,
1188
- scratchTargetPath,
1189
- String(retentionHours),
1190
- ],
1191
- env: { ...process.env, NO_COLOR: "1" },
1192
- shell: false,
1193
- timeoutMs: STATE_DB_COMPACTION_TIMEOUT_MS,
1194
- maxOutputBytes: 64 * 1024,
1195
- });
1196
- if (childFailed(result) || !existsSync(scratchTargetPath)) {
1197
- throw compactionFailure(compactionChildFailureReason(result));
1198
- }
1199
- const scratchTarget = regularFile(scratchTargetPath);
1200
- const summary = JSON.parse(String(result.stdout ?? ""));
1201
- if (
1202
- summary?.ok !== true ||
1203
- summary.retentionHours !== retentionHours ||
1204
- !Number.isSafeInteger(summary.prunedSessions) ||
1205
- summary.prunedSessions < 0 ||
1206
- !Number.isSafeInteger(summary.prunedMessages) ||
1207
- summary.prunedMessages < 0 ||
1208
- summary.archiveStateBytes !== scratchTarget.size
1209
- ) {
1210
- throw compactionFailure("invalid_result");
1211
- }
1212
- // Only this compact copy lands on the persistent volume (the rest of the
1213
- // staged profile is hard links). Refuse up front when it cannot fit,
1214
- // rather than half-writing it and failing as a generic error.
1215
- if (
1216
- volumeFreeBytes(dirname(targetPath)) <
1217
- scratchTarget.size + STATE_DB_VOLUME_HEADROOM_BYTES
1218
- ) {
1219
- throw compactionFailure("disk_full");
1220
- }
1221
- finalTemporaryPath = join(
1222
- dirname(targetPath),
1223
- `.${basename(targetPath)}.${randomUUID()}`
1224
- );
1225
- copyFileSync(
1226
- scratchTargetPath,
1227
- finalTemporaryPath,
1228
- constants.COPYFILE_EXCL
1229
- );
1230
- chmodSync(finalTemporaryPath, 0o600);
1231
- const targetFd = openSync(finalTemporaryPath, constants.O_RDONLY);
1232
- try {
1233
- fsyncSync(targetFd);
1234
- } finally {
1235
- closeSync(targetFd);
1236
- }
1237
- // link(2) gives this staged publication no-replace semantics. If another
1238
- // writer created targetPath after admission, fail without deleting or
1239
- // overwriting the other writer's file.
1240
- linkSync(finalTemporaryPath, targetPath);
1241
- targetCreated = true;
1242
- rmSync(finalTemporaryPath, { force: true });
1243
- finalTemporaryPath = undefined;
1244
- fsyncDirectory(dirname(targetPath));
1245
- if (regularFile(targetPath).size !== scratchTarget.size) {
1246
- throw new Error("hermes_snapshot_state_db_compaction_failed");
1247
- }
1248
- return summary;
1249
- } catch (error) {
1250
- if (targetCreated) rmSync(targetPath, { force: true });
1251
- const reason =
1252
- error?.cause ?? (error?.code === "ENOSPC" ? "disk_full" : "copy_failed");
1253
- process.stderr.write(
1254
- `${JSON.stringify({
1255
- ok: false,
1256
- code: "hermes_snapshot_state_db_compaction_failed",
1257
- reason,
1258
- })}\n`
1259
- );
1260
- throw new Error("hermes_snapshot_state_db_compaction_failed");
1261
- } finally {
1262
- if (finalTemporaryPath) rmSync(finalTemporaryPath, { force: true });
1263
- if (scratchRoot) rmSync(scratchRoot, { recursive: true, force: true });
1264
- }
1265
- }
1266
-
1267
1012
  function nativeSummaryAccepted(result, complete, incomplete) {
1268
1013
  const output = `${result?.stdout ?? ""}\n${result?.stderr ?? ""}`;
1269
1014
  return (
@@ -1387,8 +1132,8 @@ function stageHermesUserState({ profileId, profileRoot, hermesRoot }) {
1387
1132
  }
1388
1133
  const target = join(stagedProfileRoot, name);
1389
1134
  // Hermes rejects the live SOUL when its link count is not exactly one.
1390
- // Snapshot compaction can run for minutes, so a staged hard link would
1391
- // make otherwise healthy Agent turns fail for the whole capture window.
1135
+ // A capture can run for a while, so a staged hard link would make
1136
+ // otherwise healthy Agent turns fail for the whole capture window.
1392
1137
  copyFileSync(source, target, constants.COPYFILE_EXCL);
1393
1138
  chmodSync(target, 0o600);
1394
1139
  if (regularFile(target).nlink !== 1) {
@@ -1396,10 +1141,6 @@ function stageHermesUserState({ profileId, profileRoot, hermesRoot }) {
1396
1141
  }
1397
1142
  continue;
1398
1143
  }
1399
- // state.db is copied online and compacted after the rest of the staged
1400
- // tree is assembled. Never hard-link this one file: pruning the staged
1401
- // recovery copy must be physically incapable of touching the live DB.
1402
- if (name === "state.db") continue;
1403
1144
  stageUserStateEntry(
1404
1145
  source,
1405
1146
  join(stagedProfileRoot, name),
@@ -1411,20 +1152,6 @@ function stageHermesUserState({ profileId, profileRoot, hermesRoot }) {
1411
1152
  }
1412
1153
  : null
1413
1154
  );
1414
- // Keep live SQLite sidecars beside their database hard link so Hermes'
1415
- // sqlite3.backup() observes committed WAL data. Hermes excludes the
1416
- // sidecars themselves from the resulting ZIP.
1417
- if (name.endsWith(".db")) {
1418
- for (const suffix of ["-wal", "-shm", "-journal"]) {
1419
- const sidecar = `${source}${suffix}`;
1420
- if (existsSync(sidecar)) {
1421
- stageUserStateEntry(
1422
- sidecar,
1423
- join(stagedProfileRoot, `${name}${suffix}`)
1424
- );
1425
- }
1426
- }
1427
- }
1428
1155
  }
1429
1156
  }
1430
1157
  for (const external of HERMES_SNAPSHOT_EXTERNAL_USER_STATE) {
@@ -1485,7 +1212,7 @@ function readLegacyUserStateMigration(path, profileId) {
1485
1212
  value.entries.some(
1486
1213
  (name) =>
1487
1214
  typeof name !== "string" ||
1488
- !HERMES_SNAPSHOT_USER_STATE_ENTRIES.includes(name)
1215
+ !HERMES_LEGACY_USER_STATE_ENTRIES.includes(name)
1489
1216
  ) ||
1490
1217
  new Set(value.entries).size !== value.entries.length
1491
1218
  ) {
@@ -1501,9 +1228,9 @@ function readLegacyUserStateMigration(path, profileId) {
1501
1228
  * (for example /opt/data/skills and /opt/data/cron). Profile isolation moved
1502
1229
  * those paths under profiles/<id>. Snapshot restore cannot discover that
1503
1230
  * unscoped state because it intentionally restores profile archives only.
1504
- * Stage only the same bounded user-state allowlist used by snapshots, merge
1505
- * without overwriting newer profile bytes, then mark the resulting profile
1506
- * dirty so the next portable snapshot becomes self-contained.
1231
+ * Stage only the bounded legacy user-state allowlist, merge without
1232
+ * overwriting newer profile bytes, then mark the resulting profile dirty so the
1233
+ * next portable snapshot becomes self-contained.
1507
1234
  */
1508
1235
  export function migrateLegacyHermesUserState({
1509
1236
  profileId,
@@ -1524,7 +1251,7 @@ export function migrateLegacyHermesUserState({
1524
1251
  entries: receipt.entries,
1525
1252
  };
1526
1253
  }
1527
- const entries = HERMES_SNAPSHOT_USER_STATE_ENTRIES.filter((name) =>
1254
+ const entries = HERMES_LEGACY_USER_STATE_ENTRIES.filter((name) =>
1528
1255
  existsSync(join(hermesRoot, name))
1529
1256
  );
1530
1257
  if (entries.length === 0) {
@@ -2565,10 +2292,7 @@ export async function captureHermesBackup({
2565
2292
  hermesVersion,
2566
2293
  archiveDirectory = "/tmp",
2567
2294
  hermesCli = HERMES,
2568
- hermesPython = HERMES_PYTHON,
2569
2295
  maxArchiveBytes = HERMES_SNAPSHOT_MAX_ARCHIVE_BYTES,
2570
- cronSessionRetentionHours = HERMES_SNAPSHOT_CRON_SESSION_RETENTION_HOURS,
2571
- stateDbCompactor = compactHermesSnapshotStateDb,
2572
2296
  runChild,
2573
2297
  operationIdentity = /** @type {any} */ (undefined),
2574
2298
  }) {
@@ -2607,16 +2331,6 @@ export async function captureHermesBackup({
2607
2331
  try {
2608
2332
  stagingRoot = stageHermesUserState({ profileId, profileRoot, hermesRoot });
2609
2333
  const stagedProfileRoot = join(stagingRoot, "profiles", profileId);
2610
- const sourceStateDb = join(profileRoot, "state.db");
2611
- if (existsSync(sourceStateDb)) {
2612
- await stateDbCompactor({
2613
- sourcePath: sourceStateDb,
2614
- targetPath: join(stagedProfileRoot, "state.db"),
2615
- retentionHours: cronSessionRetentionHours,
2616
- hermesPython,
2617
- runChild,
2618
- });
2619
- }
2620
2334
  assertStagedCronDependencies(stagedProfileRoot);
2621
2335
  const result = await childRunner(runChild)(
2622
2336
  invocation(["backup", "-o", archivePath], stagingRoot, hermesCli)
@@ -2652,7 +2366,7 @@ export async function captureHermesBackup({
2652
2366
  } catch (error) {
2653
2367
  const code = error instanceof Error ? error.message : "";
2654
2368
  return failed(
2655
- /^hermes_snapshot_(?:external_state|plugin_data|portable_config|dependency|state_db|soul_metadata)/.test(
2369
+ /^hermes_snapshot_(?:external_state|plugin_data|portable_config|dependency|soul_metadata)/.test(
2656
2370
  code
2657
2371
  )
2658
2372
  ? code
@@ -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.799";
41
+ const INSTALLER_PACKAGE = "@sellable/install@0.1.801";
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.799" ||
1549
+ pinned.installerPackage !== "@sellable/install@0.1.801" ||
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.799" ||
315
+ desired.installerPackage !== "@sellable/install@0.1.801" ||
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.799";
28
+ export const PINNED_INSTALL_PACKAGE = "@sellable/install@0.1.801";
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.799",
3
+ "version": "0.1.801",
4
4
  "type": "module",
5
5
  "description": "One-command installer for Sellable MCP in Claude Code, Codex, and Hermes",
6
6
  "bin": {