@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.
- package/container/Dockerfile +4 -4
- package/container/README.md +1 -1
- package/lib/sellable-agent/fly-customer-image/Dockerfile +1 -1
- package/lib/sellable-agent/fly-customer-image/claude-code-runs-api.d.mts +2 -1
- package/lib/sellable-agent/fly-customer-image/claude-code-runs-api.mjs +47 -21
- package/lib/sellable-agent/fly-customer-image/customer-runtime.mjs +1 -1
- package/lib/sellable-agent/fly-customer-image/runs-api-connections.d.mts +6 -1
- package/lib/sellable-agent/fly-customer-image/runs-api-connections.mjs +26 -11
- package/lib/sellable-agent/fly-customer-image/runs-api-health-check.mjs +19 -10
- package/lib/sellable-agent/hermes-memory-snapshot.mjs +37 -323
- package/lib/sellable-agent/host-bootstrap.mjs +1 -1
- package/lib/sellable-agent/host-worker.mjs +1 -1
- package/lib/sellable-agent/profile-materializer.mjs +1 -1
- package/lib/sellable-agent/provisioning-adapter.mjs +1 -1
- package/package.json +1 -1
package/container/Dockerfile
CHANGED
|
@@ -3,12 +3,12 @@ FROM ${HERMES_IMAGE}
|
|
|
3
3
|
|
|
4
4
|
USER root
|
|
5
5
|
|
|
6
|
-
ARG INSTALLER_PACKAGE=@sellable/install@0.1.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
package/container/README.md
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
* descriptor
|
|
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))
|
|
124
|
-
|
|
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
|
|
519
|
+
const state = await signInState(configDir);
|
|
491
520
|
if (stopped) throw new Error("claude_runs_cancelled");
|
|
492
|
-
if (state
|
|
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
|
|
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 (!
|
|
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") && (!
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
//
|
|
142
|
-
// service
|
|
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.
|
|
150
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
52
|
-
|
|
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 (
|
|
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:
|
|
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() !==
|
|
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
|
|
217
|
-
//
|
|
218
|
-
//
|
|
219
|
-
//
|
|
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
|
-
//
|
|
263
|
-
//
|
|
264
|
-
//
|
|
265
|
-
//
|
|
266
|
-
|
|
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
|
-
//
|
|
1391
|
-
//
|
|
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
|
-
!
|
|
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
|
|
1505
|
-
*
|
|
1506
|
-
*
|
|
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 =
|
|
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|
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|