@sellable/install 0.1.794 → 0.1.795-wip.runtime-ux.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 +57 -0
- package/lib/sellable-agent/fly-customer-image/claude-code-runs-api.mjs +563 -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 +26 -0
- package/lib/sellable-agent/fly-customer-image/endpoint-model-selection.mjs +98 -0
- package/lib/sellable-agent/fly-customer-image/hermes-runs-client.mjs +24 -33
- package/lib/sellable-agent/fly-customer-image/openai-runs-client.d.mts +7 -0
- package/lib/sellable-agent/fly-customer-image/openai-runs-client.mjs +271 -0
- package/lib/sellable-agent/fly-customer-image/runs-api-connections.d.mts +30 -0
- package/lib/sellable-agent/fly-customer-image/runs-api-connections.mjs +245 -0
- package/lib/sellable-agent/fly-customer-image/runs-api-health-check.d.mts +9 -0
- package/lib/sellable-agent/fly-customer-image/runs-api-health-check.mjs +261 -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
|
@@ -0,0 +1,563 @@
|
|
|
1
|
+
import { execFile, 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
|
+
// Uses the same isolated environment as generation. Only a typed status leaves
|
|
130
|
+
// this boundary; CLI stdout can contain account identity and is never forwarded.
|
|
131
|
+
export function readClaudeAuthentication({
|
|
132
|
+
root,
|
|
133
|
+
execFileImpl = execFile,
|
|
134
|
+
timeoutMs = 8_000,
|
|
135
|
+
} = {}) {
|
|
136
|
+
const invocation = claudeInvocation(
|
|
137
|
+
{ instructions: "", input: "", model: "claude-opus", effort: "high" },
|
|
138
|
+
root
|
|
139
|
+
);
|
|
140
|
+
return new Promise((resolve) => {
|
|
141
|
+
execFileImpl(
|
|
142
|
+
invocation.command,
|
|
143
|
+
["auth", "status"],
|
|
144
|
+
{
|
|
145
|
+
cwd: invocation.options.cwd,
|
|
146
|
+
env: invocation.options.env,
|
|
147
|
+
timeout: timeoutMs,
|
|
148
|
+
maxBuffer: 16 * 1024,
|
|
149
|
+
encoding: "utf8",
|
|
150
|
+
},
|
|
151
|
+
(error, stdout) => {
|
|
152
|
+
try {
|
|
153
|
+
const value = JSON.parse(stdout);
|
|
154
|
+
if (value.loggedIn === false) return resolve("SIGN_IN_REQUIRED");
|
|
155
|
+
if (!error && value.loggedIn === true) return resolve("READY");
|
|
156
|
+
} catch {
|
|
157
|
+
/* A missing or incompatible CLI is unavailable, never ready. */
|
|
158
|
+
}
|
|
159
|
+
resolve("UNAVAILABLE");
|
|
160
|
+
}
|
|
161
|
+
);
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function parseClaudeResult(stdout, request) {
|
|
166
|
+
const result = JSON.parse(stdout);
|
|
167
|
+
const models = Object.keys(result.modelUsage ?? {});
|
|
168
|
+
if (
|
|
169
|
+
result.type !== "result" ||
|
|
170
|
+
result.is_error !== false ||
|
|
171
|
+
result.subtype !== "success" ||
|
|
172
|
+
(request.schema
|
|
173
|
+
? !record(result.structured_output)
|
|
174
|
+
: typeof result.result !== "string") ||
|
|
175
|
+
(result.permission_denials ?? []).length ||
|
|
176
|
+
!models.length ||
|
|
177
|
+
models.some(
|
|
178
|
+
(model) =>
|
|
179
|
+
model !== request.model && !model.startsWith(`${request.model}-`)
|
|
180
|
+
)
|
|
181
|
+
)
|
|
182
|
+
throw new Error("claude_runs_output_rejected");
|
|
183
|
+
const output = request.schema
|
|
184
|
+
? JSON.stringify(result.structured_output)
|
|
185
|
+
: result.result;
|
|
186
|
+
if (!output.trim()) throw new Error("claude_runs_output_empty");
|
|
187
|
+
if (Buffer.byteLength(output) > MAX_OUTPUT)
|
|
188
|
+
throw new Error("claude_runs_output_too_large");
|
|
189
|
+
return {
|
|
190
|
+
output,
|
|
191
|
+
runtime: { provider: "claude-code", model: models[0] },
|
|
192
|
+
usage: {
|
|
193
|
+
input_tokens: result.usage?.input_tokens ?? 0,
|
|
194
|
+
output_tokens: result.usage?.output_tokens ?? 0,
|
|
195
|
+
cache_read_tokens: result.usage?.cache_read_input_tokens ?? 0,
|
|
196
|
+
cache_write_tokens: result.usage?.cache_creation_input_tokens ?? 0,
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function startClaudeRun(
|
|
202
|
+
request,
|
|
203
|
+
{ root, spawnImpl = spawn, timeoutMs = 750_000 } = {}
|
|
204
|
+
) {
|
|
205
|
+
const invocation = claudeInvocation(request, root);
|
|
206
|
+
const child = spawnImpl(
|
|
207
|
+
invocation.command,
|
|
208
|
+
invocation.args,
|
|
209
|
+
invocation.options
|
|
210
|
+
);
|
|
211
|
+
let reason = null;
|
|
212
|
+
let killTimer;
|
|
213
|
+
const stop = (code = "claude_runs_cancelled") => {
|
|
214
|
+
reason ??= code;
|
|
215
|
+
try {
|
|
216
|
+
process.kill(-child.pid, "SIGTERM");
|
|
217
|
+
} catch {
|
|
218
|
+
child.kill("SIGTERM");
|
|
219
|
+
}
|
|
220
|
+
killTimer ??= setTimeout(() => {
|
|
221
|
+
try {
|
|
222
|
+
process.kill(-child.pid, "SIGKILL");
|
|
223
|
+
} catch {
|
|
224
|
+
child.kill("SIGKILL");
|
|
225
|
+
}
|
|
226
|
+
}, 5_000);
|
|
227
|
+
killTimer.unref();
|
|
228
|
+
};
|
|
229
|
+
const promise = new Promise((resolve, reject) => {
|
|
230
|
+
const chunks = [];
|
|
231
|
+
let bytes = 0;
|
|
232
|
+
const timer = setTimeout(() => stop("claude_runs_timeout"), timeoutMs);
|
|
233
|
+
child.stdout.on("data", (chunk) => {
|
|
234
|
+
bytes += chunk.length;
|
|
235
|
+
if (bytes > MAX_CLI_OUTPUT) stop("claude_runs_output_too_large");
|
|
236
|
+
else chunks.push(chunk);
|
|
237
|
+
});
|
|
238
|
+
// Drain diagnostics without logging prompts, credentials, or message bodies.
|
|
239
|
+
child.stderr.on("data", () => {});
|
|
240
|
+
child.once("error", () => {
|
|
241
|
+
clearTimeout(timer);
|
|
242
|
+
reject(new Error("claude_runs_cli_unavailable"));
|
|
243
|
+
});
|
|
244
|
+
child.once("close", (code) => {
|
|
245
|
+
clearTimeout(timer);
|
|
246
|
+
clearTimeout(killTimer);
|
|
247
|
+
if (reason || code !== 0)
|
|
248
|
+
return reject(new Error(reason ?? "claude_runs_cli_failed"));
|
|
249
|
+
try {
|
|
250
|
+
resolve(
|
|
251
|
+
parseClaudeResult(Buffer.concat(chunks).toString("utf8"), request)
|
|
252
|
+
);
|
|
253
|
+
} catch {
|
|
254
|
+
reject(new Error("claude_runs_output_rejected"));
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
child.stdin.on("error", () => {});
|
|
258
|
+
child.stdin.end(request.input);
|
|
259
|
+
});
|
|
260
|
+
return { promise, stop };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export function createClaudeRunsApi({
|
|
264
|
+
token,
|
|
265
|
+
connection,
|
|
266
|
+
execute = (request) => {
|
|
267
|
+
let activeRun;
|
|
268
|
+
let stopped = false;
|
|
269
|
+
return {
|
|
270
|
+
promise: (async () => {
|
|
271
|
+
const state = await checkAuthentication();
|
|
272
|
+
if (stopped) throw new Error("claude_runs_cancelled");
|
|
273
|
+
if (state !== "READY")
|
|
274
|
+
throw new Error(
|
|
275
|
+
state === "SIGN_IN_REQUIRED"
|
|
276
|
+
? "claude_runs_sign_in_required"
|
|
277
|
+
: "claude_runs_auth_check_unavailable"
|
|
278
|
+
);
|
|
279
|
+
activeRun = startClaudeRun(request, { root: connection.localRoot });
|
|
280
|
+
return activeRun.promise;
|
|
281
|
+
})(),
|
|
282
|
+
stop() {
|
|
283
|
+
stopped = true;
|
|
284
|
+
activeRun?.stop();
|
|
285
|
+
},
|
|
286
|
+
};
|
|
287
|
+
},
|
|
288
|
+
audit = () => {},
|
|
289
|
+
maxConcurrency = 2,
|
|
290
|
+
checkAuthentication = () =>
|
|
291
|
+
readClaudeAuthentication({ root: connection.localRoot }),
|
|
292
|
+
} = {}) {
|
|
293
|
+
if (!/^[a-f0-9]{64}$/.test(token ?? ""))
|
|
294
|
+
throw new Error("claude_runs_key_rejected");
|
|
295
|
+
const recordAudit = (entry) => {
|
|
296
|
+
try {
|
|
297
|
+
audit(entry);
|
|
298
|
+
} catch {
|
|
299
|
+
/* Logging cannot crash the Admin runtime. */
|
|
300
|
+
}
|
|
301
|
+
};
|
|
302
|
+
const runs = new Map();
|
|
303
|
+
let active = 0;
|
|
304
|
+
const json = (res, status, body) => {
|
|
305
|
+
res.writeHead(status, {
|
|
306
|
+
"content-type": "application/json",
|
|
307
|
+
"cache-control": "no-store",
|
|
308
|
+
"retry-after": "2",
|
|
309
|
+
});
|
|
310
|
+
res.end(JSON.stringify(body));
|
|
311
|
+
};
|
|
312
|
+
const terminal = (run, event, fields) => {
|
|
313
|
+
Object.assign(run, fields);
|
|
314
|
+
run.event = {
|
|
315
|
+
event,
|
|
316
|
+
run_id: run.run_id,
|
|
317
|
+
timestamp: new Date().toISOString(),
|
|
318
|
+
...(run.status === "completed"
|
|
319
|
+
? { output: run.output, runtime: run.runtime, usage: run.usage }
|
|
320
|
+
: run.status === "failed"
|
|
321
|
+
? { error: run.error }
|
|
322
|
+
: {}),
|
|
323
|
+
};
|
|
324
|
+
for (const res of run.listeners)
|
|
325
|
+
res.end(`data: ${JSON.stringify(run.event)}\n\n`);
|
|
326
|
+
run.listeners.clear();
|
|
327
|
+
recordAudit({
|
|
328
|
+
runId: run.run_id,
|
|
329
|
+
status: run.status,
|
|
330
|
+
servedRuntime: run.runtime ?? null,
|
|
331
|
+
usage: run.usage ?? null,
|
|
332
|
+
durationMs: Date.now() - run.startedAt,
|
|
333
|
+
outputDigest: run.output ? hash(run.output) : null,
|
|
334
|
+
});
|
|
335
|
+
setTimeout(() => runs.delete(run.run_id), 3_600_000).unref();
|
|
336
|
+
};
|
|
337
|
+
const server = createServer(async (req, res) => {
|
|
338
|
+
const provided = Buffer.from(req.headers.authorization ?? "");
|
|
339
|
+
const expected = Buffer.from(`Bearer ${token}`);
|
|
340
|
+
if (
|
|
341
|
+
provided.length !== expected.length ||
|
|
342
|
+
!timingSafeEqual(provided, expected)
|
|
343
|
+
)
|
|
344
|
+
return json(res, 401, { error: "claude_runs_auth_required" });
|
|
345
|
+
if (
|
|
346
|
+
req.headers["x-sellable-workspace-id"] !== connection.workspaceId ||
|
|
347
|
+
req.headers["x-sellable-agent-id"] !== connection.agentId
|
|
348
|
+
)
|
|
349
|
+
return json(res, 403, { error: "claude_runs_scope_rejected" });
|
|
350
|
+
if (req.method === "GET" && req.url === "/health") {
|
|
351
|
+
const authentication = await checkAuthentication().catch(
|
|
352
|
+
() => "UNAVAILABLE"
|
|
353
|
+
);
|
|
354
|
+
return json(res, 200, {
|
|
355
|
+
ok: authentication === "READY",
|
|
356
|
+
authentication,
|
|
357
|
+
provider: "claude-code",
|
|
358
|
+
active,
|
|
359
|
+
scope: {
|
|
360
|
+
workspaceId: connection.workspaceId,
|
|
361
|
+
agentId: connection.agentId,
|
|
362
|
+
},
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
if (req.method === "POST" && req.url === "/v1/runs") {
|
|
366
|
+
if (active >= maxConcurrency || runs.size >= 200)
|
|
367
|
+
return json(res, 429, { error: "claude_runs_busy" });
|
|
368
|
+
let request;
|
|
369
|
+
try {
|
|
370
|
+
let size = 0;
|
|
371
|
+
const chunks = [];
|
|
372
|
+
for await (const chunk of req) {
|
|
373
|
+
size += chunk.length;
|
|
374
|
+
if (size > MAX_BODY) {
|
|
375
|
+
json(res, 413, { error: "claude_runs_input_too_large" });
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
chunks.push(chunk);
|
|
379
|
+
}
|
|
380
|
+
const body = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
381
|
+
request = validateClaudeRunsRequest(body, connection.models);
|
|
382
|
+
} catch (error) {
|
|
383
|
+
recordAudit({
|
|
384
|
+
status: "request-rejected",
|
|
385
|
+
reason: /^claude_runs_[a-z_]+$/.test(error.message ?? "")
|
|
386
|
+
? error.message
|
|
387
|
+
: "claude_runs_invalid_request",
|
|
388
|
+
});
|
|
389
|
+
return json(res, 400, { error: "claude_runs_request_rejected" });
|
|
390
|
+
}
|
|
391
|
+
// Check again after reading: concurrent uploads cannot over-admit.
|
|
392
|
+
if (active >= maxConcurrency || runs.size >= 200)
|
|
393
|
+
return json(res, 429, { error: "claude_runs_busy" });
|
|
394
|
+
const run = {
|
|
395
|
+
run_id: `run_${randomBytes(16).toString("hex")}`,
|
|
396
|
+
status: "running",
|
|
397
|
+
startedAt: Date.now(),
|
|
398
|
+
listeners: new Set(),
|
|
399
|
+
consumed: false,
|
|
400
|
+
};
|
|
401
|
+
active++;
|
|
402
|
+
runs.set(run.run_id, run);
|
|
403
|
+
try {
|
|
404
|
+
run.execution = execute(request);
|
|
405
|
+
run.execution.promise
|
|
406
|
+
.then(
|
|
407
|
+
(result) =>
|
|
408
|
+
terminal(run, "run.completed", {
|
|
409
|
+
status: "completed",
|
|
410
|
+
...result,
|
|
411
|
+
}),
|
|
412
|
+
(error) =>
|
|
413
|
+
terminal(
|
|
414
|
+
run,
|
|
415
|
+
run.stopping && error.message === "claude_runs_cancelled"
|
|
416
|
+
? "run.cancelled"
|
|
417
|
+
: "run.failed",
|
|
418
|
+
run.stopping && error.message === "claude_runs_cancelled"
|
|
419
|
+
? { status: "cancelled" }
|
|
420
|
+
: {
|
|
421
|
+
status: "failed",
|
|
422
|
+
error: [
|
|
423
|
+
"claude_runs_sign_in_required",
|
|
424
|
+
"claude_runs_auth_check_unavailable",
|
|
425
|
+
].includes(error.message)
|
|
426
|
+
? error.message
|
|
427
|
+
: "claude_runs_generation_failed",
|
|
428
|
+
}
|
|
429
|
+
)
|
|
430
|
+
)
|
|
431
|
+
.finally(() => active--);
|
|
432
|
+
} catch {
|
|
433
|
+
active--;
|
|
434
|
+
terminal(run, "run.failed", {
|
|
435
|
+
status: "failed",
|
|
436
|
+
error: "claude_runs_cli_unavailable",
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
return json(res, 202, { run_id: run.run_id, status: "started" });
|
|
440
|
+
}
|
|
441
|
+
const match =
|
|
442
|
+
/^\/v1\/runs\/(run_[a-f0-9]{32})(?:\/(events|stop|approval))?$/.exec(
|
|
443
|
+
req.url ?? ""
|
|
444
|
+
);
|
|
445
|
+
const run = match && runs.get(match[1]);
|
|
446
|
+
if (!run) return json(res, 404, { error: "claude_runs_run_missing" });
|
|
447
|
+
if (req.method === "GET" && !match[2])
|
|
448
|
+
return json(res, 200, {
|
|
449
|
+
run_id: run.run_id,
|
|
450
|
+
status: run.status,
|
|
451
|
+
...(run.output
|
|
452
|
+
? { output: run.output, runtime: run.runtime, usage: run.usage }
|
|
453
|
+
: {}),
|
|
454
|
+
...(run.error ? { error: run.error } : {}),
|
|
455
|
+
});
|
|
456
|
+
if (req.method === "GET" && match[2] === "events") {
|
|
457
|
+
if (run.consumed)
|
|
458
|
+
return json(res, 404, { error: "claude_runs_events_consumed" });
|
|
459
|
+
run.consumed = true;
|
|
460
|
+
res.writeHead(200, {
|
|
461
|
+
"content-type": "text/event-stream",
|
|
462
|
+
"cache-control": "no-store",
|
|
463
|
+
});
|
|
464
|
+
res.flushHeaders();
|
|
465
|
+
if (run.event) return res.end(`data: ${JSON.stringify(run.event)}\n\n`);
|
|
466
|
+
run.listeners.add(res);
|
|
467
|
+
const heartbeat = setInterval(
|
|
468
|
+
() => res.write(": keep-alive\n\n"),
|
|
469
|
+
15_000
|
|
470
|
+
);
|
|
471
|
+
res.once("close", () => {
|
|
472
|
+
clearInterval(heartbeat);
|
|
473
|
+
run.listeners.delete(res);
|
|
474
|
+
});
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
if (req.method === "POST" && match[2] === "stop") {
|
|
478
|
+
if (run.event)
|
|
479
|
+
return json(res, 200, {
|
|
480
|
+
run_id: run.run_id,
|
|
481
|
+
status: run.status,
|
|
482
|
+
...(run.output
|
|
483
|
+
? { output: run.output, runtime: run.runtime, usage: run.usage }
|
|
484
|
+
: {}),
|
|
485
|
+
});
|
|
486
|
+
run.stopping = true;
|
|
487
|
+
run.execution.stop();
|
|
488
|
+
return json(res, 200, { run_id: run.run_id, status: "stopping" });
|
|
489
|
+
}
|
|
490
|
+
return json(res, 405, { error: "claude_runs_method_rejected" });
|
|
491
|
+
});
|
|
492
|
+
server.stopActiveRuns = async () => {
|
|
493
|
+
const activeRuns = [...runs.values()].filter(
|
|
494
|
+
(run) => !run.event && run.execution
|
|
495
|
+
);
|
|
496
|
+
for (const run of activeRuns) {
|
|
497
|
+
run.stopping = true;
|
|
498
|
+
run.execution.stop();
|
|
499
|
+
}
|
|
500
|
+
await Promise.allSettled(activeRuns.map((run) => run.execution.promise));
|
|
501
|
+
};
|
|
502
|
+
return server;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// The Admin runtime owns these listeners for its lifetime. Image-owned startup
|
|
506
|
+
// plus the persistent CLI/auth directory makes normal restarts repeatable.
|
|
507
|
+
export async function startConfiguredClaudeRunsApis({
|
|
508
|
+
env = process.env,
|
|
509
|
+
diagnostic = () => {},
|
|
510
|
+
createApi = createClaudeRunsApi,
|
|
511
|
+
readToken = (path) => readFileSync(path, "utf8").trim(),
|
|
512
|
+
} = {}) {
|
|
513
|
+
const services = [];
|
|
514
|
+
for (const connection of parseRunsApiConnections(
|
|
515
|
+
env[RUNS_API_CONNECTIONS_ENV]
|
|
516
|
+
)) {
|
|
517
|
+
if (
|
|
518
|
+
!connection.localRoot ||
|
|
519
|
+
connection.runtimeId !== env.SELLABLE_RUNTIME_ID
|
|
520
|
+
)
|
|
521
|
+
continue;
|
|
522
|
+
try {
|
|
523
|
+
const token = readToken(connection.apiKeyFile);
|
|
524
|
+
const server = createApi({
|
|
525
|
+
token,
|
|
526
|
+
connection,
|
|
527
|
+
audit: (entry) =>
|
|
528
|
+
appendFileSync(
|
|
529
|
+
`${connection.localRoot}/audit.jsonl`,
|
|
530
|
+
`${JSON.stringify({ at: new Date().toISOString(), ...entry })}\n`,
|
|
531
|
+
{ mode: 0o600 }
|
|
532
|
+
),
|
|
533
|
+
});
|
|
534
|
+
await new Promise((resolve, reject) => {
|
|
535
|
+
server.once("error", reject);
|
|
536
|
+
server.listen(
|
|
537
|
+
Number(new URL(connection.baseUrl).port),
|
|
538
|
+
"127.0.0.1",
|
|
539
|
+
resolve
|
|
540
|
+
);
|
|
541
|
+
});
|
|
542
|
+
services.push(server);
|
|
543
|
+
diagnostic({
|
|
544
|
+
ok: true,
|
|
545
|
+
code: "claude_runs_api_ready",
|
|
546
|
+
baseUrl: connection.baseUrl,
|
|
547
|
+
});
|
|
548
|
+
} catch {
|
|
549
|
+
diagnostic({ ok: false, code: "claude_runs_api_unavailable" });
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
return {
|
|
553
|
+
async stop() {
|
|
554
|
+
for (const server of services) {
|
|
555
|
+
await server.stopActiveRuns();
|
|
556
|
+
await new Promise((resolve) => {
|
|
557
|
+
server.close(resolve);
|
|
558
|
+
server.closeAllConnections();
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
},
|
|
562
|
+
};
|
|
563
|
+
}
|
|
@@ -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.runtime-ux.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,26 @@
|
|
|
1
|
+
export const ENDPOINT_MODEL_SELECTION_ALLOWLIST: Readonly<{
|
|
2
|
+
openrouter: readonly [
|
|
3
|
+
"anthropic/claude-opus-5.5",
|
|
4
|
+
"deepseek/deepseek-v4-flash-0731",
|
|
5
|
+
"openai/gpt-6-luna",
|
|
6
|
+
"anthropic/claude-sonnet-5.5",
|
|
7
|
+
];
|
|
8
|
+
}>;
|
|
9
|
+
export const ENDPOINT_MODEL_REASONING_EFFORTS: readonly [
|
|
10
|
+
"none",
|
|
11
|
+
"low",
|
|
12
|
+
"medium",
|
|
13
|
+
"high",
|
|
14
|
+
"xhigh",
|
|
15
|
+
];
|
|
16
|
+
export type EndpointModelSelection = Readonly<{
|
|
17
|
+
provider: string;
|
|
18
|
+
model: string;
|
|
19
|
+
reasoningEffort: (typeof ENDPOINT_MODEL_REASONING_EFFORTS)[number];
|
|
20
|
+
runsApiBaseUrl?: string;
|
|
21
|
+
fallbackPolicy?: "STOP" | "PRODUCT_DEFAULT";
|
|
22
|
+
}>;
|
|
23
|
+
export function normalizeRunsApiBaseUrl(value: unknown): string | null;
|
|
24
|
+
export function compileEndpointModelSelection(
|
|
25
|
+
value: unknown
|
|
26
|
+
): EndpointModelSelection | null | "invalid";
|