@sellable/install 0.1.794 → 0.1.795-wip.runs-api.20261004.1
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-admin-image/Dockerfile +4 -0
- package/lib/sellable-agent/fly-admin-image/admin-runtime.mjs +10 -0
- package/lib/sellable-agent/fly-customer-image/Dockerfile +2 -1
- package/lib/sellable-agent/fly-customer-image/claude-code-runs-api.d.mts +48 -0
- package/lib/sellable-agent/fly-customer-image/claude-code-runs-api.mjs +492 -0
- package/lib/sellable-agent/fly-customer-image/customer-runtime.mjs +3 -4
- package/lib/sellable-agent/fly-customer-image/endpoint-model-selection.d.mts +22 -0
- package/lib/sellable-agent/fly-customer-image/endpoint-model-selection.mjs +84 -0
- package/lib/sellable-agent/fly-customer-image/hermes-runs-client.mjs +18 -29
- package/lib/sellable-agent/fly-customer-image/runs-api-connections.d.mts +26 -0
- package/lib/sellable-agent/fly-customer-image/runs-api-connections.mjs +211 -0
- 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.795-wip.runs-api.20261004.1
|
|
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.795-wip.runs-api.20261004.1" \
|
|
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.794" \
|
|
|
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.795-wip.runs-api.20261004.1' || 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.795-wip.runs-api.20261004.1', 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.795-wip.runs-api.20261004.1` is
|
|
7
7
|
published:
|
|
8
8
|
|
|
9
9
|
```sh
|
|
@@ -169,6 +169,7 @@ COPY fly-admin-image/start-admin-runtime /usr/local/lib/sellable-agent/admin-run
|
|
|
169
169
|
COPY fly-customer-image/customer-runtime.mjs /usr/local/lib/sellable-agent/fly-customer-image/customer-runtime.mjs
|
|
170
170
|
COPY hermes-first-welcome.mjs /usr/local/lib/sellable-agent/hermes-first-welcome.mjs
|
|
171
171
|
COPY fly-customer-image/hermes-runs-client.mjs /usr/local/lib/sellable-agent/fly-customer-image/hermes-runs-client.mjs
|
|
172
|
+
COPY fly-customer-image/endpoint-model-selection.mjs fly-customer-image/runs-api-connections.mjs fly-customer-image/claude-code-runs-api.mjs /usr/local/lib/sellable-agent/fly-customer-image/
|
|
172
173
|
COPY fly-customer-image/opener-stage-execution.mjs /usr/local/lib/sellable-agent/fly-customer-image/opener-stage-execution.mjs
|
|
173
174
|
COPY fly-customer-image/endpoint-uat-completion-gate.mjs /usr/local/lib/sellable-agent/fly-customer-image/endpoint-uat-completion-gate.mjs
|
|
174
175
|
COPY fly-customer-image/customer-runtime-phase-telemetry.mjs /usr/local/lib/sellable-agent/fly-customer-image/customer-runtime-phase-telemetry.mjs
|
|
@@ -272,6 +273,9 @@ RUN set -eux; \
|
|
|
272
273
|
/usr/local/lib/sellable-agent/fly-customer-image/customer-runtime.mjs \
|
|
273
274
|
/usr/local/lib/sellable-agent/fly-customer-image/customer-runtime-phase-telemetry.mjs \
|
|
274
275
|
/usr/local/lib/sellable-agent/fly-customer-image/hermes-runs-client.mjs \
|
|
276
|
+
/usr/local/lib/sellable-agent/fly-customer-image/endpoint-model-selection.mjs \
|
|
277
|
+
/usr/local/lib/sellable-agent/fly-customer-image/runs-api-connections.mjs \
|
|
278
|
+
/usr/local/lib/sellable-agent/fly-customer-image/claude-code-runs-api.mjs \
|
|
275
279
|
/usr/local/lib/sellable-agent/fly-customer-image/opener-stage-execution.mjs \
|
|
276
280
|
/usr/local/lib/sellable-agent/fly-customer-image/endpoint-uat-completion-gate.mjs \
|
|
277
281
|
/usr/local/lib/sellable-agent/admin-native-gateway-supervisor.mjs \
|
|
@@ -63,6 +63,7 @@ import {
|
|
|
63
63
|
} from "../fly-capabilities-bridge.mjs";
|
|
64
64
|
import { createCustomerEndpointRelay } from "../fly-customer-image/customer-runtime.mjs";
|
|
65
65
|
import { createHermesSessionRetention } from "../fly-customer-image/hermes-runs-client.mjs";
|
|
66
|
+
import { startConfiguredClaudeRunsApis } from "../fly-customer-image/claude-code-runs-api.mjs";
|
|
66
67
|
import {
|
|
67
68
|
claimWithSoulObservation,
|
|
68
69
|
createFlyCustomerControlClient,
|
|
@@ -3817,6 +3818,14 @@ async function runAdminProduction() {
|
|
|
3817
3818
|
let activeLaunch = null;
|
|
3818
3819
|
let activeModelHealthReporter = null;
|
|
3819
3820
|
let shutdownMemorySnapshot = null;
|
|
3821
|
+
let customRunsServices = null;
|
|
3822
|
+
try {
|
|
3823
|
+
customRunsServices = await startConfiguredClaudeRunsApis({
|
|
3824
|
+
diagnostic: value => process.stderr.write(`${JSON.stringify(value)}\n`),
|
|
3825
|
+
});
|
|
3826
|
+
} catch {
|
|
3827
|
+
process.stderr.write(`${JSON.stringify({ ok: false, code: "custom_runs_api_start_failed" })}\n`);
|
|
3828
|
+
}
|
|
3820
3829
|
const endpointRelay = createCustomerEndpointRelay({
|
|
3821
3830
|
getControlClient: () => client,
|
|
3822
3831
|
getActiveRuntime: () =>
|
|
@@ -4453,6 +4462,7 @@ async function runAdminProduction() {
|
|
|
4453
4462
|
);
|
|
4454
4463
|
}
|
|
4455
4464
|
await endpointLoop;
|
|
4465
|
+
await customRunsServices?.stop();
|
|
4456
4466
|
await activeModelHealthReporter?.stop();
|
|
4457
4467
|
await stopNativeGateway(activeGateway);
|
|
4458
4468
|
await clearRuntimeSecrets();
|
|
@@ -8,7 +8,7 @@ ENV PLAYWRIGHT_BROWSERS_PATH=/opt/hermes/.playwright
|
|
|
8
8
|
|
|
9
9
|
ARG MCP_PACKAGE=@sellable/mcp@0.1.957
|
|
10
10
|
ARG MCP_INTEGRITY=sha512-tIZuO5IEanT/afamDIWdorUNMdqZA6xnjL9jGAmGqYIlIqkXdHB9slWiwfLfmM44iEp7OT98IBHJlvbpMThk6A==
|
|
11
|
-
ARG INSTALLER_PACKAGE=@sellable/install@0.1.
|
|
11
|
+
ARG INSTALLER_PACKAGE=@sellable/install@0.1.795-wip.runs-api.20261004.1
|
|
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
|
|
@@ -98,6 +98,7 @@ COPY hermes-memory-reconcile.sh /opt/sellable/share/hermes-memory-reconcile.sh
|
|
|
98
98
|
COPY hermes-endpoint-cron.mjs /opt/sellable/bin/hermes-endpoint-cron
|
|
99
99
|
COPY fly-customer-image/model-auth-store.mjs /usr/local/lib/sellable-agent/fly-customer-image/model-auth-store.mjs
|
|
100
100
|
COPY fly-customer-image/hermes-runs-client.mjs /usr/local/lib/sellable-agent/fly-customer-image/hermes-runs-client.mjs
|
|
101
|
+
COPY fly-customer-image/endpoint-model-selection.mjs fly-customer-image/runs-api-connections.mjs /usr/local/lib/sellable-agent/fly-customer-image/
|
|
101
102
|
COPY fly-customer-image/opener-stage-execution.mjs /usr/local/lib/sellable-agent/fly-customer-image/opener-stage-execution.mjs
|
|
102
103
|
COPY fly-customer-image/endpoint-uat-completion-gate.mjs /usr/local/lib/sellable-agent/fly-customer-image/endpoint-uat-completion-gate.mjs
|
|
103
104
|
COPY fly-customer-image/customer-runtime-phase-telemetry.mjs /usr/local/lib/sellable-agent/fly-customer-image/customer-runtime-phase-telemetry.mjs
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { SpawnOptions } from "node:child_process";
|
|
2
|
+
import type { Server } from "node:http";
|
|
3
|
+
import type { RunsApiConnection } from "./runs-api-connections.mjs";
|
|
4
|
+
|
|
5
|
+
export type ClaudeRunsRequest = {
|
|
6
|
+
input: string;
|
|
7
|
+
instructions: string;
|
|
8
|
+
schema: Record<string, unknown> | null;
|
|
9
|
+
model: string;
|
|
10
|
+
effort: string;
|
|
11
|
+
};
|
|
12
|
+
export type ClaudeRunsResult = {
|
|
13
|
+
output: string;
|
|
14
|
+
runtime: { provider: string; model: string };
|
|
15
|
+
usage: Record<string, number>;
|
|
16
|
+
};
|
|
17
|
+
export type ClaudeRunsServer = Server & { stopActiveRuns(): Promise<void> };
|
|
18
|
+
export type ClaudeRunsApiOptions = {
|
|
19
|
+
token: string;
|
|
20
|
+
connection: RunsApiConnection;
|
|
21
|
+
execute?: (request: ClaudeRunsRequest) => {
|
|
22
|
+
promise: Promise<ClaudeRunsResult>;
|
|
23
|
+
stop(): void;
|
|
24
|
+
};
|
|
25
|
+
audit?: (entry: Record<string, unknown>) => void;
|
|
26
|
+
maxConcurrency?: number;
|
|
27
|
+
};
|
|
28
|
+
export function validateClaudeRunsRequest(
|
|
29
|
+
body: unknown,
|
|
30
|
+
models: RunsApiConnection["models"]
|
|
31
|
+
): ClaudeRunsRequest;
|
|
32
|
+
export function claudeInvocation(
|
|
33
|
+
request: ClaudeRunsRequest,
|
|
34
|
+
root: string
|
|
35
|
+
): { command: string; args: string[]; options: SpawnOptions };
|
|
36
|
+
export function parseClaudeResult(
|
|
37
|
+
stdout: string,
|
|
38
|
+
request: ClaudeRunsRequest
|
|
39
|
+
): ClaudeRunsResult;
|
|
40
|
+
export function createClaudeRunsApi(
|
|
41
|
+
options: ClaudeRunsApiOptions
|
|
42
|
+
): ClaudeRunsServer;
|
|
43
|
+
export function startConfiguredClaudeRunsApis(options?: {
|
|
44
|
+
env?: Record<string, string | undefined>;
|
|
45
|
+
diagnostic?: (entry: Record<string, unknown>) => void;
|
|
46
|
+
createApi?: typeof createClaudeRunsApi;
|
|
47
|
+
readToken?: (path: string) => string;
|
|
48
|
+
}): Promise<{ stop(): Promise<void> }>;
|
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
3
|
+
import { appendFileSync, readFileSync } from "node:fs";
|
|
4
|
+
import { createServer } from "node:http";
|
|
5
|
+
import { validateOpenerOutputContract } from "./hermes-runs-client.mjs";
|
|
6
|
+
import {
|
|
7
|
+
parseRunsApiConnections,
|
|
8
|
+
RUNS_API_CONNECTIONS_ENV,
|
|
9
|
+
} from "./runs-api-connections.mjs";
|
|
10
|
+
|
|
11
|
+
const MAX_BODY = 320 * 1024;
|
|
12
|
+
const MAX_OUTPUT = 48 * 1024;
|
|
13
|
+
const MAX_CLI_OUTPUT = 512 * 1024;
|
|
14
|
+
const hash = (s) => createHash("sha256").update(s).digest("hex");
|
|
15
|
+
const record = (v) => v && typeof v === "object" && !Array.isArray(v);
|
|
16
|
+
|
|
17
|
+
export function validateClaudeRunsRequest(body, models) {
|
|
18
|
+
const allowed = [
|
|
19
|
+
"input",
|
|
20
|
+
"instructions",
|
|
21
|
+
"conversation_history",
|
|
22
|
+
"session_id",
|
|
23
|
+
"provider",
|
|
24
|
+
"model",
|
|
25
|
+
"model_options",
|
|
26
|
+
];
|
|
27
|
+
if (
|
|
28
|
+
!record(body) ||
|
|
29
|
+
Object.keys(body).some((k) => !allowed.includes(k)) ||
|
|
30
|
+
typeof body.input !== "string" ||
|
|
31
|
+
!body.input.length ||
|
|
32
|
+
Buffer.byteLength(body.input) > 128 * 1024 ||
|
|
33
|
+
typeof body.instructions !== "string" ||
|
|
34
|
+
!body.instructions.length ||
|
|
35
|
+
Buffer.byteLength(body.instructions) > 128 * 1024 ||
|
|
36
|
+
!Array.isArray(body.conversation_history) ||
|
|
37
|
+
body.conversation_history.length > 64 ||
|
|
38
|
+
Buffer.byteLength(JSON.stringify(body.conversation_history)) > 128 * 1024 ||
|
|
39
|
+
body.conversation_history.some(
|
|
40
|
+
(item) =>
|
|
41
|
+
!record(item) ||
|
|
42
|
+
Object.keys(item).sort().join(",") !== "content,role" ||
|
|
43
|
+
!["user", "assistant"].includes(item.role) ||
|
|
44
|
+
typeof item.content !== "string"
|
|
45
|
+
) ||
|
|
46
|
+
!/^sellable_endpoint_[a-f0-9]{32}$/.test(body.session_id ?? "") ||
|
|
47
|
+
body.provider !== "claude-code" ||
|
|
48
|
+
!record(body.model_options) ||
|
|
49
|
+
Object.keys(body.model_options).some(
|
|
50
|
+
(key) =>
|
|
51
|
+
!["reasoning_effort", "sellable_opener_output_contract"].includes(key)
|
|
52
|
+
) ||
|
|
53
|
+
!models.some(
|
|
54
|
+
(model) =>
|
|
55
|
+
model.provider === body.provider &&
|
|
56
|
+
model.model === body.model &&
|
|
57
|
+
model.reasoningEfforts.includes(body.model_options.reasoning_effort)
|
|
58
|
+
)
|
|
59
|
+
)
|
|
60
|
+
throw new Error("claude_runs_request_rejected");
|
|
61
|
+
const contract = body.model_options.sellable_opener_output_contract;
|
|
62
|
+
const schema =
|
|
63
|
+
contract === undefined
|
|
64
|
+
? null
|
|
65
|
+
: validateOpenerOutputContract(contract).schema;
|
|
66
|
+
return {
|
|
67
|
+
input: body.conversation_history.length
|
|
68
|
+
? JSON.stringify({
|
|
69
|
+
conversation_history: body.conversation_history,
|
|
70
|
+
input: body.input,
|
|
71
|
+
})
|
|
72
|
+
: body.input,
|
|
73
|
+
instructions: body.instructions,
|
|
74
|
+
schema,
|
|
75
|
+
model: body.model,
|
|
76
|
+
effort: body.model_options.reasoning_effort,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function claudeInvocation(request, root) {
|
|
81
|
+
return {
|
|
82
|
+
command: `${root}/cli/node_modules/.bin/claude`,
|
|
83
|
+
args: [
|
|
84
|
+
"-p",
|
|
85
|
+
"--model",
|
|
86
|
+
request.model,
|
|
87
|
+
"--effort",
|
|
88
|
+
request.effort,
|
|
89
|
+
"--output-format",
|
|
90
|
+
"json",
|
|
91
|
+
...(request.schema
|
|
92
|
+
? ["--json-schema", JSON.stringify(request.schema)]
|
|
93
|
+
: []),
|
|
94
|
+
"--tools",
|
|
95
|
+
"",
|
|
96
|
+
"--permission-mode",
|
|
97
|
+
"dontAsk",
|
|
98
|
+
"--strict-mcp-config",
|
|
99
|
+
"--mcp-config",
|
|
100
|
+
'{"mcpServers":{}}',
|
|
101
|
+
"--setting-sources",
|
|
102
|
+
"",
|
|
103
|
+
"--settings",
|
|
104
|
+
'{"disableAllHooks":true,"autoMemoryEnabled":false}',
|
|
105
|
+
"--disable-slash-commands",
|
|
106
|
+
"--no-session-persistence",
|
|
107
|
+
"--max-turns",
|
|
108
|
+
"3",
|
|
109
|
+
"--system-prompt",
|
|
110
|
+
request.instructions,
|
|
111
|
+
],
|
|
112
|
+
// Whitelist instead of inheriting the runtime's provider keys, URLs, hooks,
|
|
113
|
+
// plugin paths, NODE_OPTIONS, or workspace access credentials.
|
|
114
|
+
options: {
|
|
115
|
+
cwd: `${root}/work`,
|
|
116
|
+
detached: true,
|
|
117
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
118
|
+
env: {
|
|
119
|
+
PATH: "/usr/local/bin:/usr/bin:/bin",
|
|
120
|
+
HOME: `${root}/claude`,
|
|
121
|
+
CLAUDE_CONFIG_DIR: `${root}/claude`,
|
|
122
|
+
LANG: "C.UTF-8",
|
|
123
|
+
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1",
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function parseClaudeResult(stdout, request) {
|
|
130
|
+
const result = JSON.parse(stdout);
|
|
131
|
+
const models = Object.keys(result.modelUsage ?? {});
|
|
132
|
+
if (
|
|
133
|
+
result.type !== "result" ||
|
|
134
|
+
result.is_error !== false ||
|
|
135
|
+
result.subtype !== "success" ||
|
|
136
|
+
(request.schema
|
|
137
|
+
? !record(result.structured_output)
|
|
138
|
+
: typeof result.result !== "string") ||
|
|
139
|
+
(result.permission_denials ?? []).length ||
|
|
140
|
+
!models.length ||
|
|
141
|
+
models.some(
|
|
142
|
+
(model) =>
|
|
143
|
+
model !== request.model && !model.startsWith(`${request.model}-`)
|
|
144
|
+
)
|
|
145
|
+
)
|
|
146
|
+
throw new Error("claude_runs_output_rejected");
|
|
147
|
+
const output = request.schema
|
|
148
|
+
? JSON.stringify(result.structured_output)
|
|
149
|
+
: result.result;
|
|
150
|
+
if (!output.trim()) throw new Error("claude_runs_output_empty");
|
|
151
|
+
if (Buffer.byteLength(output) > MAX_OUTPUT)
|
|
152
|
+
throw new Error("claude_runs_output_too_large");
|
|
153
|
+
return {
|
|
154
|
+
output,
|
|
155
|
+
runtime: { provider: "claude-code", model: models[0] },
|
|
156
|
+
usage: {
|
|
157
|
+
input_tokens: result.usage?.input_tokens ?? 0,
|
|
158
|
+
output_tokens: result.usage?.output_tokens ?? 0,
|
|
159
|
+
cache_read_tokens: result.usage?.cache_read_input_tokens ?? 0,
|
|
160
|
+
cache_write_tokens: result.usage?.cache_creation_input_tokens ?? 0,
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function startClaudeRun(
|
|
166
|
+
request,
|
|
167
|
+
{ root, spawnImpl = spawn, timeoutMs = 750_000 } = {}
|
|
168
|
+
) {
|
|
169
|
+
const invocation = claudeInvocation(request, root);
|
|
170
|
+
const child = spawnImpl(
|
|
171
|
+
invocation.command,
|
|
172
|
+
invocation.args,
|
|
173
|
+
invocation.options
|
|
174
|
+
);
|
|
175
|
+
let reason = null;
|
|
176
|
+
let killTimer;
|
|
177
|
+
const stop = (code = "claude_runs_cancelled") => {
|
|
178
|
+
reason ??= code;
|
|
179
|
+
try {
|
|
180
|
+
process.kill(-child.pid, "SIGTERM");
|
|
181
|
+
} catch {
|
|
182
|
+
child.kill("SIGTERM");
|
|
183
|
+
}
|
|
184
|
+
killTimer ??= setTimeout(() => {
|
|
185
|
+
try {
|
|
186
|
+
process.kill(-child.pid, "SIGKILL");
|
|
187
|
+
} catch {
|
|
188
|
+
child.kill("SIGKILL");
|
|
189
|
+
}
|
|
190
|
+
}, 5_000);
|
|
191
|
+
killTimer.unref();
|
|
192
|
+
};
|
|
193
|
+
const promise = new Promise((resolve, reject) => {
|
|
194
|
+
const chunks = [];
|
|
195
|
+
let bytes = 0;
|
|
196
|
+
const timer = setTimeout(() => stop("claude_runs_timeout"), timeoutMs);
|
|
197
|
+
child.stdout.on("data", (chunk) => {
|
|
198
|
+
bytes += chunk.length;
|
|
199
|
+
if (bytes > MAX_CLI_OUTPUT) stop("claude_runs_output_too_large");
|
|
200
|
+
else chunks.push(chunk);
|
|
201
|
+
});
|
|
202
|
+
// Drain diagnostics without logging prompts, credentials, or message bodies.
|
|
203
|
+
child.stderr.on("data", () => {});
|
|
204
|
+
child.once("error", () => {
|
|
205
|
+
clearTimeout(timer);
|
|
206
|
+
reject(new Error("claude_runs_cli_unavailable"));
|
|
207
|
+
});
|
|
208
|
+
child.once("close", (code) => {
|
|
209
|
+
clearTimeout(timer);
|
|
210
|
+
clearTimeout(killTimer);
|
|
211
|
+
if (reason || code !== 0)
|
|
212
|
+
return reject(new Error(reason ?? "claude_runs_cli_failed"));
|
|
213
|
+
try {
|
|
214
|
+
resolve(
|
|
215
|
+
parseClaudeResult(Buffer.concat(chunks).toString("utf8"), request)
|
|
216
|
+
);
|
|
217
|
+
} catch {
|
|
218
|
+
reject(new Error("claude_runs_output_rejected"));
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
child.stdin.on("error", () => {});
|
|
222
|
+
child.stdin.end(request.input);
|
|
223
|
+
});
|
|
224
|
+
return { promise, stop };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function createClaudeRunsApi({
|
|
228
|
+
token,
|
|
229
|
+
connection,
|
|
230
|
+
execute = (request) =>
|
|
231
|
+
startClaudeRun(request, { root: connection.localRoot }),
|
|
232
|
+
audit = () => {},
|
|
233
|
+
maxConcurrency = 2,
|
|
234
|
+
} = {}) {
|
|
235
|
+
if (!/^[a-f0-9]{64}$/.test(token ?? ""))
|
|
236
|
+
throw new Error("claude_runs_key_rejected");
|
|
237
|
+
const recordAudit = (entry) => {
|
|
238
|
+
try {
|
|
239
|
+
audit(entry);
|
|
240
|
+
} catch {
|
|
241
|
+
/* Logging cannot crash the Admin runtime. */
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
const runs = new Map();
|
|
245
|
+
let active = 0;
|
|
246
|
+
const json = (res, status, body) => {
|
|
247
|
+
res.writeHead(status, {
|
|
248
|
+
"content-type": "application/json",
|
|
249
|
+
"cache-control": "no-store",
|
|
250
|
+
"retry-after": "2",
|
|
251
|
+
});
|
|
252
|
+
res.end(JSON.stringify(body));
|
|
253
|
+
};
|
|
254
|
+
const terminal = (run, event, fields) => {
|
|
255
|
+
Object.assign(run, fields);
|
|
256
|
+
run.event = {
|
|
257
|
+
event,
|
|
258
|
+
run_id: run.run_id,
|
|
259
|
+
timestamp: new Date().toISOString(),
|
|
260
|
+
...(run.status === "completed"
|
|
261
|
+
? { output: run.output, runtime: run.runtime, usage: run.usage }
|
|
262
|
+
: run.status === "failed"
|
|
263
|
+
? { error: run.error }
|
|
264
|
+
: {}),
|
|
265
|
+
};
|
|
266
|
+
for (const res of run.listeners)
|
|
267
|
+
res.end(`data: ${JSON.stringify(run.event)}\n\n`);
|
|
268
|
+
run.listeners.clear();
|
|
269
|
+
recordAudit({
|
|
270
|
+
runId: run.run_id,
|
|
271
|
+
status: run.status,
|
|
272
|
+
servedRuntime: run.runtime ?? null,
|
|
273
|
+
usage: run.usage ?? null,
|
|
274
|
+
durationMs: Date.now() - run.startedAt,
|
|
275
|
+
outputDigest: run.output ? hash(run.output) : null,
|
|
276
|
+
});
|
|
277
|
+
setTimeout(() => runs.delete(run.run_id), 3_600_000).unref();
|
|
278
|
+
};
|
|
279
|
+
const server = createServer(async (req, res) => {
|
|
280
|
+
const provided = Buffer.from(req.headers.authorization ?? "");
|
|
281
|
+
const expected = Buffer.from(`Bearer ${token}`);
|
|
282
|
+
if (
|
|
283
|
+
provided.length !== expected.length ||
|
|
284
|
+
!timingSafeEqual(provided, expected)
|
|
285
|
+
)
|
|
286
|
+
return json(res, 401, { error: "claude_runs_auth_required" });
|
|
287
|
+
if (
|
|
288
|
+
req.headers["x-sellable-workspace-id"] !== connection.workspaceId ||
|
|
289
|
+
req.headers["x-sellable-agent-id"] !== connection.agentId
|
|
290
|
+
)
|
|
291
|
+
return json(res, 403, { error: "claude_runs_scope_rejected" });
|
|
292
|
+
if (req.method === "GET" && req.url === "/health")
|
|
293
|
+
return json(res, 200, {
|
|
294
|
+
ok: true,
|
|
295
|
+
provider: "claude-code",
|
|
296
|
+
active,
|
|
297
|
+
scope: {
|
|
298
|
+
workspaceId: connection.workspaceId,
|
|
299
|
+
agentId: connection.agentId,
|
|
300
|
+
},
|
|
301
|
+
});
|
|
302
|
+
if (req.method === "POST" && req.url === "/v1/runs") {
|
|
303
|
+
if (active >= maxConcurrency || runs.size >= 200)
|
|
304
|
+
return json(res, 429, { error: "claude_runs_busy" });
|
|
305
|
+
let request;
|
|
306
|
+
try {
|
|
307
|
+
let size = 0;
|
|
308
|
+
const chunks = [];
|
|
309
|
+
for await (const chunk of req) {
|
|
310
|
+
size += chunk.length;
|
|
311
|
+
if (size > MAX_BODY) {
|
|
312
|
+
json(res, 413, { error: "claude_runs_input_too_large" });
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
chunks.push(chunk);
|
|
316
|
+
}
|
|
317
|
+
const body = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
318
|
+
request = validateClaudeRunsRequest(body, connection.models);
|
|
319
|
+
} catch (error) {
|
|
320
|
+
recordAudit({
|
|
321
|
+
status: "request-rejected",
|
|
322
|
+
reason: /^claude_runs_[a-z_]+$/.test(error.message ?? "")
|
|
323
|
+
? error.message
|
|
324
|
+
: "claude_runs_invalid_request",
|
|
325
|
+
});
|
|
326
|
+
return json(res, 400, { error: "claude_runs_request_rejected" });
|
|
327
|
+
}
|
|
328
|
+
// Check again after reading: concurrent uploads cannot over-admit.
|
|
329
|
+
if (active >= maxConcurrency || runs.size >= 200)
|
|
330
|
+
return json(res, 429, { error: "claude_runs_busy" });
|
|
331
|
+
const run = {
|
|
332
|
+
run_id: `run_${randomBytes(16).toString("hex")}`,
|
|
333
|
+
status: "running",
|
|
334
|
+
startedAt: Date.now(),
|
|
335
|
+
listeners: new Set(),
|
|
336
|
+
consumed: false,
|
|
337
|
+
};
|
|
338
|
+
active++;
|
|
339
|
+
runs.set(run.run_id, run);
|
|
340
|
+
try {
|
|
341
|
+
run.execution = execute(request);
|
|
342
|
+
run.execution.promise
|
|
343
|
+
.then(
|
|
344
|
+
(result) =>
|
|
345
|
+
terminal(run, "run.completed", {
|
|
346
|
+
status: "completed",
|
|
347
|
+
...result,
|
|
348
|
+
}),
|
|
349
|
+
(error) =>
|
|
350
|
+
terminal(
|
|
351
|
+
run,
|
|
352
|
+
run.stopping && error.message === "claude_runs_cancelled"
|
|
353
|
+
? "run.cancelled"
|
|
354
|
+
: "run.failed",
|
|
355
|
+
run.stopping && error.message === "claude_runs_cancelled"
|
|
356
|
+
? { status: "cancelled" }
|
|
357
|
+
: { status: "failed", error: "claude_runs_generation_failed" }
|
|
358
|
+
)
|
|
359
|
+
)
|
|
360
|
+
.finally(() => active--);
|
|
361
|
+
} catch {
|
|
362
|
+
active--;
|
|
363
|
+
terminal(run, "run.failed", {
|
|
364
|
+
status: "failed",
|
|
365
|
+
error: "claude_runs_cli_unavailable",
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
return json(res, 202, { run_id: run.run_id, status: "started" });
|
|
369
|
+
}
|
|
370
|
+
const match =
|
|
371
|
+
/^\/v1\/runs\/(run_[a-f0-9]{32})(?:\/(events|stop|approval))?$/.exec(
|
|
372
|
+
req.url ?? ""
|
|
373
|
+
);
|
|
374
|
+
const run = match && runs.get(match[1]);
|
|
375
|
+
if (!run) return json(res, 404, { error: "claude_runs_run_missing" });
|
|
376
|
+
if (req.method === "GET" && !match[2])
|
|
377
|
+
return json(res, 200, {
|
|
378
|
+
run_id: run.run_id,
|
|
379
|
+
status: run.status,
|
|
380
|
+
...(run.output
|
|
381
|
+
? { output: run.output, runtime: run.runtime, usage: run.usage }
|
|
382
|
+
: {}),
|
|
383
|
+
...(run.error ? { error: run.error } : {}),
|
|
384
|
+
});
|
|
385
|
+
if (req.method === "GET" && match[2] === "events") {
|
|
386
|
+
if (run.consumed)
|
|
387
|
+
return json(res, 404, { error: "claude_runs_events_consumed" });
|
|
388
|
+
run.consumed = true;
|
|
389
|
+
res.writeHead(200, {
|
|
390
|
+
"content-type": "text/event-stream",
|
|
391
|
+
"cache-control": "no-store",
|
|
392
|
+
});
|
|
393
|
+
res.flushHeaders();
|
|
394
|
+
if (run.event) return res.end(`data: ${JSON.stringify(run.event)}\n\n`);
|
|
395
|
+
run.listeners.add(res);
|
|
396
|
+
const heartbeat = setInterval(
|
|
397
|
+
() => res.write(": keep-alive\n\n"),
|
|
398
|
+
15_000
|
|
399
|
+
);
|
|
400
|
+
res.once("close", () => {
|
|
401
|
+
clearInterval(heartbeat);
|
|
402
|
+
run.listeners.delete(res);
|
|
403
|
+
});
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
if (req.method === "POST" && match[2] === "stop") {
|
|
407
|
+
if (run.event)
|
|
408
|
+
return json(res, 200, {
|
|
409
|
+
run_id: run.run_id,
|
|
410
|
+
status: run.status,
|
|
411
|
+
...(run.output
|
|
412
|
+
? { output: run.output, runtime: run.runtime, usage: run.usage }
|
|
413
|
+
: {}),
|
|
414
|
+
});
|
|
415
|
+
run.stopping = true;
|
|
416
|
+
run.execution.stop();
|
|
417
|
+
return json(res, 200, { run_id: run.run_id, status: "stopping" });
|
|
418
|
+
}
|
|
419
|
+
return json(res, 405, { error: "claude_runs_method_rejected" });
|
|
420
|
+
});
|
|
421
|
+
server.stopActiveRuns = async () => {
|
|
422
|
+
const activeRuns = [...runs.values()].filter(
|
|
423
|
+
(run) => !run.event && run.execution
|
|
424
|
+
);
|
|
425
|
+
for (const run of activeRuns) {
|
|
426
|
+
run.stopping = true;
|
|
427
|
+
run.execution.stop();
|
|
428
|
+
}
|
|
429
|
+
await Promise.allSettled(activeRuns.map((run) => run.execution.promise));
|
|
430
|
+
};
|
|
431
|
+
return server;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// The Admin runtime owns these listeners for its lifetime. Image-owned startup
|
|
435
|
+
// plus the persistent CLI/auth directory makes normal restarts repeatable.
|
|
436
|
+
export async function startConfiguredClaudeRunsApis({
|
|
437
|
+
env = process.env,
|
|
438
|
+
diagnostic = () => {},
|
|
439
|
+
createApi = createClaudeRunsApi,
|
|
440
|
+
readToken = (path) => readFileSync(path, "utf8").trim(),
|
|
441
|
+
} = {}) {
|
|
442
|
+
const services = [];
|
|
443
|
+
for (const connection of parseRunsApiConnections(
|
|
444
|
+
env[RUNS_API_CONNECTIONS_ENV]
|
|
445
|
+
)) {
|
|
446
|
+
if (
|
|
447
|
+
!connection.localRoot ||
|
|
448
|
+
connection.runtimeId !== env.SELLABLE_RUNTIME_ID
|
|
449
|
+
)
|
|
450
|
+
continue;
|
|
451
|
+
try {
|
|
452
|
+
const token = readToken(connection.apiKeyFile);
|
|
453
|
+
const server = createApi({
|
|
454
|
+
token,
|
|
455
|
+
connection,
|
|
456
|
+
audit: (entry) =>
|
|
457
|
+
appendFileSync(
|
|
458
|
+
`${connection.localRoot}/audit.jsonl`,
|
|
459
|
+
`${JSON.stringify({ at: new Date().toISOString(), ...entry })}\n`,
|
|
460
|
+
{ mode: 0o600 }
|
|
461
|
+
),
|
|
462
|
+
});
|
|
463
|
+
await new Promise((resolve, reject) => {
|
|
464
|
+
server.once("error", reject);
|
|
465
|
+
server.listen(
|
|
466
|
+
Number(new URL(connection.baseUrl).port),
|
|
467
|
+
"127.0.0.1",
|
|
468
|
+
resolve
|
|
469
|
+
);
|
|
470
|
+
});
|
|
471
|
+
services.push(server);
|
|
472
|
+
diagnostic({
|
|
473
|
+
ok: true,
|
|
474
|
+
code: "claude_runs_api_ready",
|
|
475
|
+
baseUrl: connection.baseUrl,
|
|
476
|
+
});
|
|
477
|
+
} catch {
|
|
478
|
+
diagnostic({ ok: false, code: "claude_runs_api_unavailable" });
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
return {
|
|
482
|
+
async stop() {
|
|
483
|
+
for (const server of services) {
|
|
484
|
+
await server.stopActiveRuns();
|
|
485
|
+
await new Promise((resolve) => {
|
|
486
|
+
server.close(resolve);
|
|
487
|
+
server.closeAllConnections();
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
},
|
|
491
|
+
};
|
|
492
|
+
}
|
|
@@ -93,12 +93,12 @@ import {
|
|
|
93
93
|
validateEndpointUatCompletionControl,
|
|
94
94
|
} from "./endpoint-uat-completion-gate.mjs";
|
|
95
95
|
import {
|
|
96
|
-
createHermesRunsClient,
|
|
97
96
|
createHermesSessionRetention,
|
|
98
97
|
HERMES_RUNS_FIXTURE_IDENTITY,
|
|
99
98
|
validateEndpointModelSelection,
|
|
100
99
|
validateOpenerOutputContract,
|
|
101
100
|
} from "./hermes-runs-client.mjs";
|
|
101
|
+
import { createConfiguredRunsClient } from "./runs-api-connections.mjs";
|
|
102
102
|
import {
|
|
103
103
|
createOpenerStageExecution,
|
|
104
104
|
usesOpenerDraftStrategy,
|
|
@@ -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.795-wip.runs-api.20261004.1";
|
|
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;
|
|
@@ -1605,8 +1605,7 @@ export function createCustomerEndpointRelay(options = {}) {
|
|
|
1605
1605
|
runtimeGeneration,
|
|
1606
1606
|
getApiKeyFile = ({ claim }) =>
|
|
1607
1607
|
join(SECRET_ROOT, claim.runtime.profileId, "api-server-key"),
|
|
1608
|
-
createRunsClient =
|
|
1609
|
-
createHermesRunsClient({ apiKeyFile }),
|
|
1608
|
+
createRunsClient = createConfiguredRunsClient,
|
|
1610
1609
|
now = () => new Date(),
|
|
1611
1610
|
sleepImpl = sleep,
|
|
1612
1611
|
diagnostic = () => {},
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export const ENDPOINT_MODEL_SELECTION_ALLOWLIST: Readonly<{
|
|
2
|
+
openrouter: readonly [
|
|
3
|
+
"anthropic/claude-opus-5.5",
|
|
4
|
+
"anthropic/claude-sonnet-5.5",
|
|
5
|
+
];
|
|
6
|
+
}>;
|
|
7
|
+
export const ENDPOINT_MODEL_REASONING_EFFORTS: readonly [
|
|
8
|
+
"low",
|
|
9
|
+
"medium",
|
|
10
|
+
"high",
|
|
11
|
+
"xhigh",
|
|
12
|
+
];
|
|
13
|
+
export type EndpointModelSelection = Readonly<{
|
|
14
|
+
provider: string;
|
|
15
|
+
model: string;
|
|
16
|
+
reasoningEffort: (typeof ENDPOINT_MODEL_REASONING_EFFORTS)[number];
|
|
17
|
+
runsApiBaseUrl?: string;
|
|
18
|
+
}>;
|
|
19
|
+
export function normalizeRunsApiBaseUrl(value: unknown): string | null;
|
|
20
|
+
export function compileEndpointModelSelection(
|
|
21
|
+
value: unknown
|
|
22
|
+
): EndpointModelSelection | null | "invalid";
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Shared by the control plane and the runtime. A custom Runs API is only a
|
|
2
|
+
// requested destination here; the runtime must also match a trusted connection.
|
|
3
|
+
export const ENDPOINT_MODEL_SELECTION_ALLOWLIST = Object.freeze({
|
|
4
|
+
openrouter: Object.freeze([
|
|
5
|
+
"anthropic/claude-opus-5.5",
|
|
6
|
+
"anthropic/claude-sonnet-5.5",
|
|
7
|
+
]),
|
|
8
|
+
});
|
|
9
|
+
export const ENDPOINT_MODEL_REASONING_EFFORTS = Object.freeze([
|
|
10
|
+
"low",
|
|
11
|
+
"medium",
|
|
12
|
+
"high",
|
|
13
|
+
"xhigh",
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
export function normalizeRunsApiBaseUrl(value) {
|
|
17
|
+
if (
|
|
18
|
+
typeof value !== "string" ||
|
|
19
|
+
value.length > 1_024 ||
|
|
20
|
+
value !== value.trim()
|
|
21
|
+
)
|
|
22
|
+
return null;
|
|
23
|
+
try {
|
|
24
|
+
const url = new URL(value);
|
|
25
|
+
if (
|
|
26
|
+
url.username ||
|
|
27
|
+
url.password ||
|
|
28
|
+
url.search ||
|
|
29
|
+
url.hash ||
|
|
30
|
+
/[\\\s]/.test(value) ||
|
|
31
|
+
(url.protocol !== "https:" &&
|
|
32
|
+
!(
|
|
33
|
+
url.protocol === "http:" &&
|
|
34
|
+
["127.0.0.1", "[::1]"].includes(url.hostname)
|
|
35
|
+
))
|
|
36
|
+
)
|
|
37
|
+
return null;
|
|
38
|
+
return `${url.origin}${url.pathname.replace(/\/$/, "")}`;
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function compileEndpointModelSelection(value) {
|
|
45
|
+
if (value === undefined || value === null) return null;
|
|
46
|
+
if (typeof value !== "object" || Array.isArray(value)) return "invalid";
|
|
47
|
+
const custom = Object.hasOwn(value, "runsApiBaseUrl");
|
|
48
|
+
if (
|
|
49
|
+
Object.keys(value).sort().join(",") !==
|
|
50
|
+
(custom
|
|
51
|
+
? "model,provider,reasoningEffort,runsApiBaseUrl"
|
|
52
|
+
: "model,provider,reasoningEffort")
|
|
53
|
+
)
|
|
54
|
+
return "invalid";
|
|
55
|
+
const { provider, model, reasoningEffort } = value;
|
|
56
|
+
if (
|
|
57
|
+
typeof provider !== "string" ||
|
|
58
|
+
typeof model !== "string" ||
|
|
59
|
+
!ENDPOINT_MODEL_REASONING_EFFORTS.includes(reasoningEffort)
|
|
60
|
+
)
|
|
61
|
+
return "invalid";
|
|
62
|
+
if (
|
|
63
|
+
!custom &&
|
|
64
|
+
(!Object.hasOwn(ENDPOINT_MODEL_SELECTION_ALLOWLIST, provider) ||
|
|
65
|
+
!ENDPOINT_MODEL_SELECTION_ALLOWLIST[provider].includes(model))
|
|
66
|
+
)
|
|
67
|
+
return "invalid";
|
|
68
|
+
const runsApiBaseUrl = custom
|
|
69
|
+
? normalizeRunsApiBaseUrl(value.runsApiBaseUrl)
|
|
70
|
+
: null;
|
|
71
|
+
if (
|
|
72
|
+
custom &&
|
|
73
|
+
(!runsApiBaseUrl ||
|
|
74
|
+
!/^[a-z][a-z0-9_-]{0,63}$/.test(provider) ||
|
|
75
|
+
!/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,199}$/.test(model))
|
|
76
|
+
)
|
|
77
|
+
return "invalid";
|
|
78
|
+
return Object.freeze({
|
|
79
|
+
provider,
|
|
80
|
+
model,
|
|
81
|
+
reasoningEffort,
|
|
82
|
+
...(custom ? { runsApiBaseUrl } : {}),
|
|
83
|
+
});
|
|
84
|
+
}
|
|
@@ -7,6 +7,11 @@ import {
|
|
|
7
7
|
writeFileSync,
|
|
8
8
|
} from "node:fs";
|
|
9
9
|
import { dirname, isAbsolute } from "node:path";
|
|
10
|
+
import {
|
|
11
|
+
compileEndpointModelSelection,
|
|
12
|
+
normalizeRunsApiBaseUrl,
|
|
13
|
+
} from "./endpoint-model-selection.mjs";
|
|
14
|
+
export { ENDPOINT_MODEL_SELECTION_ALLOWLIST } from "./endpoint-model-selection.mjs";
|
|
10
15
|
|
|
11
16
|
const LOOPBACK_ORIGIN = "http://127.0.0.1:8642";
|
|
12
17
|
const RUN_ID = /^run_[a-f0-9]{32}$/;
|
|
@@ -206,37 +211,12 @@ function canonicalHistory(value) {
|
|
|
206
211
|
return result;
|
|
207
212
|
}
|
|
208
213
|
|
|
209
|
-
// The models a caller may name for one endpoint run. The server checks the
|
|
210
|
-
// same list before it hands the run over; the runtime checks it again here so
|
|
211
|
-
// a run can never reach Hermes with a model that was not reviewed.
|
|
212
|
-
export const ENDPOINT_MODEL_SELECTION_ALLOWLIST = Object.freeze({
|
|
213
|
-
openrouter: Object.freeze([
|
|
214
|
-
"anthropic/claude-opus-5.5",
|
|
215
|
-
"anthropic/claude-sonnet-5.5",
|
|
216
|
-
]),
|
|
217
|
-
});
|
|
218
|
-
const ENDPOINT_MODEL_REASONING_EFFORTS = Object.freeze([
|
|
219
|
-
"low",
|
|
220
|
-
"medium",
|
|
221
|
-
"high",
|
|
222
|
-
"xhigh",
|
|
223
|
-
]);
|
|
224
|
-
|
|
225
214
|
export function validateEndpointModelSelection(value) {
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
Object.keys(value).sort().join(",") !== "model,provider,reasoningEffort" ||
|
|
229
|
-
!Object.hasOwn(ENDPOINT_MODEL_SELECTION_ALLOWLIST, value.provider) ||
|
|
230
|
-
!ENDPOINT_MODEL_SELECTION_ALLOWLIST[value.provider].includes(value.model) ||
|
|
231
|
-
!ENDPOINT_MODEL_REASONING_EFFORTS.includes(value.reasoningEffort)
|
|
232
|
-
) {
|
|
215
|
+
const compiled = compileEndpointModelSelection(value);
|
|
216
|
+
if (!compiled || compiled === "invalid") {
|
|
233
217
|
throw new Error("hermes_runs_model_selection_rejected");
|
|
234
218
|
}
|
|
235
|
-
return
|
|
236
|
-
provider: value.provider,
|
|
237
|
-
model: value.model,
|
|
238
|
-
reasoningEffort: value.reasoningEffort,
|
|
239
|
-
});
|
|
219
|
+
return compiled;
|
|
240
220
|
}
|
|
241
221
|
|
|
242
222
|
export function validateOpenerOutputContract(value) {
|
|
@@ -606,12 +586,19 @@ function submitNotAdmitted(response, value) {
|
|
|
606
586
|
export function createHermesRunsClient(options = {}) {
|
|
607
587
|
const {
|
|
608
588
|
apiKeyFile,
|
|
589
|
+
baseUrl = LOOPBACK_ORIGIN,
|
|
590
|
+
scopeHeaders = {},
|
|
609
591
|
fixture,
|
|
610
592
|
fetchImpl = fetch,
|
|
611
593
|
timeoutMs = 20_000,
|
|
612
594
|
} = options;
|
|
613
595
|
assertFixture(fixture);
|
|
596
|
+
const origin = normalizeRunsApiBaseUrl(baseUrl);
|
|
614
597
|
if (
|
|
598
|
+
!origin ||
|
|
599
|
+
!exactObject(scopeHeaders) ||
|
|
600
|
+
Object.keys(scopeHeaders).some(key => !["x-sellable-workspace-id", "x-sellable-agent-id"].includes(key)) ||
|
|
601
|
+
Object.values(scopeHeaders).some(value => typeof value !== "string" || !/^[A-Za-z0-9_-]{1,128}$/.test(value)) ||
|
|
615
602
|
typeof fetchImpl !== "function" ||
|
|
616
603
|
!Number.isSafeInteger(timeoutMs) ||
|
|
617
604
|
timeoutMs < 100 ||
|
|
@@ -624,9 +611,11 @@ export function createHermesRunsClient(options = {}) {
|
|
|
624
611
|
if (!path.startsWith("/v1/runs")) {
|
|
625
612
|
throw new Error("hermes_runs_path_rejected");
|
|
626
613
|
}
|
|
627
|
-
return fetchImpl(`${
|
|
614
|
+
return fetchImpl(`${origin}${path}`, {
|
|
628
615
|
...init,
|
|
616
|
+
redirect: "error",
|
|
629
617
|
headers: {
|
|
618
|
+
...scopeHeaders,
|
|
630
619
|
authorization: `Bearer ${apiKey}`,
|
|
631
620
|
...(init.body === undefined
|
|
632
621
|
? {}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export const RUNS_API_CONNECTIONS_ENV: "SELLABLE_AGENT_RUNS_API_CONNECTIONS_JSON";
|
|
2
|
+
export type RunsApiConnection = Readonly<{
|
|
3
|
+
runtimeId: string;
|
|
4
|
+
workspaceId: string;
|
|
5
|
+
agentId: string;
|
|
6
|
+
baseUrl: string;
|
|
7
|
+
apiKeyFile: string;
|
|
8
|
+
endpointIds: string[];
|
|
9
|
+
workKinds: Array<"STANDARD" | "OPENER" | "REPLY" | "FOLLOW_UP">;
|
|
10
|
+
models: Array<{
|
|
11
|
+
provider: string;
|
|
12
|
+
model: string;
|
|
13
|
+
reasoningEfforts: string[];
|
|
14
|
+
}>;
|
|
15
|
+
localRoot?: string;
|
|
16
|
+
}>;
|
|
17
|
+
export function parseRunsApiConnections(raw: unknown): RunsApiConnection[];
|
|
18
|
+
export function runsApiConnectionsForRuntime(
|
|
19
|
+
raw: unknown,
|
|
20
|
+
runtime: {
|
|
21
|
+
runtimeId: string;
|
|
22
|
+
workspaceId: string;
|
|
23
|
+
agentId: string;
|
|
24
|
+
}
|
|
25
|
+
): RunsApiConnection[];
|
|
26
|
+
export function createConfiguredRunsClient(options: any): any;
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
compileEndpointModelSelection,
|
|
4
|
+
normalizeRunsApiBaseUrl,
|
|
5
|
+
} from "./endpoint-model-selection.mjs";
|
|
6
|
+
import { createHermesRunsClient } from "./hermes-runs-client.mjs";
|
|
7
|
+
|
|
8
|
+
export const RUNS_API_CONNECTIONS_ENV =
|
|
9
|
+
"SELLABLE_AGENT_RUNS_API_CONNECTIONS_JSON";
|
|
10
|
+
const id = (value) =>
|
|
11
|
+
typeof value === "string" && /^[A-Za-z0-9_.:-]{1,128}$/.test(value);
|
|
12
|
+
const record = (value) =>
|
|
13
|
+
value && typeof value === "object" && !Array.isArray(value);
|
|
14
|
+
const stable = (value) =>
|
|
15
|
+
Array.isArray(value)
|
|
16
|
+
? `[${value.map(stable).join(",")}]`
|
|
17
|
+
: record(value)
|
|
18
|
+
? `{${Object.keys(value)
|
|
19
|
+
.sort()
|
|
20
|
+
.map((key) => `${JSON.stringify(key)}:${stable(value[key])}`)
|
|
21
|
+
.join(",")}}`
|
|
22
|
+
: JSON.stringify(value);
|
|
23
|
+
const fail = () => {
|
|
24
|
+
throw new Error("runs_api_connection_configuration_rejected");
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// Only the control plane's machine configuration supplies this registry. It
|
|
28
|
+
// contains credential file references, never credentials or request input.
|
|
29
|
+
export function parseRunsApiConnections(raw) {
|
|
30
|
+
if (raw === undefined || raw === "") return [];
|
|
31
|
+
if (typeof raw !== "string" || Buffer.byteLength(raw) > 64 * 1024) fail();
|
|
32
|
+
let entries;
|
|
33
|
+
try {
|
|
34
|
+
entries = JSON.parse(raw);
|
|
35
|
+
} catch {
|
|
36
|
+
fail();
|
|
37
|
+
}
|
|
38
|
+
if (!Array.isArray(entries) || entries.length > 32) fail();
|
|
39
|
+
const seen = new Set();
|
|
40
|
+
return entries.map((entry) => {
|
|
41
|
+
if (
|
|
42
|
+
!record(entry) ||
|
|
43
|
+
Object.keys(entry).some(
|
|
44
|
+
(key) =>
|
|
45
|
+
![
|
|
46
|
+
"runtimeId",
|
|
47
|
+
"workspaceId",
|
|
48
|
+
"agentId",
|
|
49
|
+
"baseUrl",
|
|
50
|
+
"apiKeyFile",
|
|
51
|
+
"endpointIds",
|
|
52
|
+
"workKinds",
|
|
53
|
+
"models",
|
|
54
|
+
"localRoot",
|
|
55
|
+
].includes(key)
|
|
56
|
+
) ||
|
|
57
|
+
![entry.runtimeId, entry.workspaceId, entry.agentId].every(id) ||
|
|
58
|
+
typeof entry.apiKeyFile !== "string" ||
|
|
59
|
+
!/^\/(?:opt\/data|run\/secrets)\/[A-Za-z0-9_./-]{1,250}$/.test(
|
|
60
|
+
entry.apiKeyFile
|
|
61
|
+
) ||
|
|
62
|
+
entry.apiKeyFile
|
|
63
|
+
.split("/")
|
|
64
|
+
.some((part) => part === "." || part === "..") ||
|
|
65
|
+
!Array.isArray(entry.endpointIds) ||
|
|
66
|
+
!entry.endpointIds.length ||
|
|
67
|
+
entry.endpointIds.length > 64 ||
|
|
68
|
+
!entry.endpointIds.every(id) ||
|
|
69
|
+
!Array.isArray(entry.workKinds) ||
|
|
70
|
+
!entry.workKinds.length ||
|
|
71
|
+
!entry.workKinds.every((kind) =>
|
|
72
|
+
["STANDARD", "OPENER", "REPLY", "FOLLOW_UP"].includes(kind)
|
|
73
|
+
) ||
|
|
74
|
+
!Array.isArray(entry.models) ||
|
|
75
|
+
!entry.models.length ||
|
|
76
|
+
entry.models.length > 16
|
|
77
|
+
)
|
|
78
|
+
fail();
|
|
79
|
+
const baseUrl = normalizeRunsApiBaseUrl(entry.baseUrl);
|
|
80
|
+
if (!baseUrl) fail();
|
|
81
|
+
const identity = `${entry.runtimeId}:${baseUrl}`;
|
|
82
|
+
if (seen.has(identity)) fail();
|
|
83
|
+
seen.add(identity);
|
|
84
|
+
const models = entry.models.map((model) => {
|
|
85
|
+
if (
|
|
86
|
+
!record(model) ||
|
|
87
|
+
Object.keys(model).sort().join(",") !==
|
|
88
|
+
"model,provider,reasoningEfforts" ||
|
|
89
|
+
!Array.isArray(model.reasoningEfforts) ||
|
|
90
|
+
!model.reasoningEfforts.length ||
|
|
91
|
+
model.reasoningEfforts.some(
|
|
92
|
+
(reasoningEffort) =>
|
|
93
|
+
compileEndpointModelSelection({
|
|
94
|
+
provider: model.provider,
|
|
95
|
+
model: model.model,
|
|
96
|
+
reasoningEffort,
|
|
97
|
+
runsApiBaseUrl: baseUrl,
|
|
98
|
+
}) === "invalid"
|
|
99
|
+
)
|
|
100
|
+
)
|
|
101
|
+
fail();
|
|
102
|
+
return { ...model, reasoningEfforts: [...model.reasoningEfforts] };
|
|
103
|
+
});
|
|
104
|
+
if (entry.localRoot !== undefined) {
|
|
105
|
+
const url = new URL(baseUrl);
|
|
106
|
+
if (
|
|
107
|
+
typeof entry.localRoot !== "string" ||
|
|
108
|
+
!/^\/opt\/data\/[A-Za-z0-9_-]{1,80}$/.test(entry.localRoot) ||
|
|
109
|
+
entry.apiKeyFile !== `${entry.localRoot}/api-key` ||
|
|
110
|
+
url.hostname !== "127.0.0.1" ||
|
|
111
|
+
url.protocol !== "http:" ||
|
|
112
|
+
url.pathname !== "/" ||
|
|
113
|
+
!url.port ||
|
|
114
|
+
Number(url.port) < 1024 ||
|
|
115
|
+
models.some(
|
|
116
|
+
(model) =>
|
|
117
|
+
model.provider !== "claude-code" ||
|
|
118
|
+
!/^claude-[a-z0-9-]{1,100}$/.test(model.model) ||
|
|
119
|
+
model.reasoningEfforts.some(
|
|
120
|
+
(effort) => !["low", "medium", "high"].includes(effort)
|
|
121
|
+
)
|
|
122
|
+
)
|
|
123
|
+
)
|
|
124
|
+
fail();
|
|
125
|
+
}
|
|
126
|
+
return Object.freeze({
|
|
127
|
+
...entry,
|
|
128
|
+
baseUrl,
|
|
129
|
+
models,
|
|
130
|
+
endpointIds: [...entry.endpointIds],
|
|
131
|
+
workKinds: [...entry.workKinds],
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function runsApiConnectionsForRuntime(raw, runtime) {
|
|
137
|
+
return parseRunsApiConnections(raw).filter(
|
|
138
|
+
(connection) =>
|
|
139
|
+
connection.runtimeId === runtime.runtimeId &&
|
|
140
|
+
connection.workspaceId === runtime.workspaceId &&
|
|
141
|
+
connection.agentId === runtime.agentId
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** @param {any} options */
|
|
146
|
+
export function createConfiguredRunsClient({
|
|
147
|
+
apiKeyFile,
|
|
148
|
+
claim,
|
|
149
|
+
env = process.env,
|
|
150
|
+
clientFactory = createHermesRunsClient,
|
|
151
|
+
}) {
|
|
152
|
+
const selection = claim?.run?.modelSelection;
|
|
153
|
+
if (!selection?.runsApiBaseUrl) return clientFactory({ apiKeyFile });
|
|
154
|
+
const model = compileEndpointModelSelection(selection);
|
|
155
|
+
if (!model || model === "invalid") throw new Error("runs_api_model_rejected");
|
|
156
|
+
const connection = runsApiConnectionsForRuntime(
|
|
157
|
+
env[RUNS_API_CONNECTIONS_ENV],
|
|
158
|
+
{
|
|
159
|
+
runtimeId: claim.runtime?.runtimeId,
|
|
160
|
+
workspaceId: claim.attempt?.workspaceId,
|
|
161
|
+
agentId: claim.attempt?.agentId,
|
|
162
|
+
}
|
|
163
|
+
).find((value) => value.baseUrl === model.runsApiBaseUrl);
|
|
164
|
+
const workKind = claim.run.productExecution?.workKind ?? "STANDARD";
|
|
165
|
+
if (
|
|
166
|
+
!connection ||
|
|
167
|
+
env.SELLABLE_RUNTIME_ID !== connection.runtimeId ||
|
|
168
|
+
!connection.endpointIds.includes(claim.attempt.endpointId) ||
|
|
169
|
+
!connection.workKinds.includes(workKind) ||
|
|
170
|
+
!connection.models.some(
|
|
171
|
+
(allowed) =>
|
|
172
|
+
allowed.provider === model.provider &&
|
|
173
|
+
allowed.model === model.model &&
|
|
174
|
+
allowed.reasoningEfforts.includes(model.reasoningEffort)
|
|
175
|
+
)
|
|
176
|
+
)
|
|
177
|
+
throw new Error("runs_api_connection_not_authorized");
|
|
178
|
+
// This client instance retains one destination and one credential for the
|
|
179
|
+
// entire attempt, including stream/status/stop, even if settings later change.
|
|
180
|
+
const client = clientFactory({
|
|
181
|
+
baseUrl: connection.baseUrl,
|
|
182
|
+
apiKeyFile: connection.apiKeyFile,
|
|
183
|
+
scopeHeaders: {
|
|
184
|
+
"x-sellable-workspace-id": connection.workspaceId,
|
|
185
|
+
"x-sellable-agent-id": connection.agentId,
|
|
186
|
+
},
|
|
187
|
+
});
|
|
188
|
+
return Object.freeze({
|
|
189
|
+
...client,
|
|
190
|
+
submit(compiled, options) {
|
|
191
|
+
// Public semantic endpoints have a published schema but no product tuple.
|
|
192
|
+
// Preserve that existing contract when calling a custom Runs service.
|
|
193
|
+
const schema = claim.revision?.outputSchema;
|
|
194
|
+
if (compiled.outputContract || !schema?.properties?.proposal)
|
|
195
|
+
return client.submit(compiled, options);
|
|
196
|
+
return client.submit(
|
|
197
|
+
{
|
|
198
|
+
...compiled,
|
|
199
|
+
outputContract: {
|
|
200
|
+
schemaVersion: "sellable-opener-output-contract/v1",
|
|
201
|
+
schema,
|
|
202
|
+
nativeProposalSchemaDigest: createHash("sha256")
|
|
203
|
+
.update(stable(schema.properties.proposal))
|
|
204
|
+
.digest("hex"),
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
options
|
|
208
|
+
);
|
|
209
|
+
},
|
|
210
|
+
});
|
|
211
|
+
}
|
|
@@ -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.795-wip.runs-api.20261004.1";
|
|
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.795-wip.runs-api.20261004.1" ||
|
|
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.795-wip.runs-api.20261004.1" ||
|
|
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.795-wip.runs-api.20261004.1";
|
|
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
|