@sellable/install
Version:
One-command installer for Sellable MCP in Claude Code, Codex, and Hermes
1,149 lines (1,113 loc) • 36.8 kB
JavaScript
import { createHash } from "node:crypto";
import {
lstatSync,
mkdirSync,
readFileSync,
renameSync,
writeFileSync,
} from "node:fs";
import { dirname, isAbsolute } from "node:path";
import {
compileEndpointModelSelection,
normalizeRunsApiBaseUrl,
} from "./endpoint-model-selection.mjs";
export { ENDPOINT_MODEL_SELECTION_ALLOWLIST } from "./endpoint-model-selection.mjs";
const LOOPBACK_ORIGIN = "http://127.0.0.1:8642";
const RUN_ID = /^run_[a-f0-9]{32}$/;
const SAFE_TOOL_NAME = /^[a-z][a-z0-9_]{0,127}$/;
const MAX_RESPONSE_BYTES = 512 * 1024;
const MAX_INPUT_BYTES = 128 * 1024;
const MAX_INSTRUCTIONS_BYTES = 128 * 1024;
const MAX_HISTORY_BYTES = 128 * 1024;
const MAX_HISTORY_ITEM_BYTES = 48 * 1024;
const MAX_HISTORY_ITEMS = 64;
const MAX_EVENT_BYTES = 128 * 1024;
const MAX_CANONICAL_OUTPUT_BYTES = 48 * 1024;
const EXACT_FIXTURE_DIGEST =
"dc91130958d2d0181843d12b5073aa0df3b36c7d96718e149750aa5f692640ef";
export const HERMES_RUNS_FIXTURE_IDENTITY = Object.freeze({
schemaVersion: "sellable-hermes-api-capabilities/v1",
image:
"nousresearch/hermes-agent@sha256:fca358f12efd65bfaaca05884166f15c0e2788375ca30d77061ac1ebc96452b7",
hermesVersion: "0.21.5",
fixtureDigest: EXACT_FIXTURE_DIGEST,
origin: LOOPBACK_ORIGIN,
auth: "bearer",
eventsAreConsuming: true,
nativeEventIds: false,
unconsumedEventTtlSeconds: 300,
terminalStatusTtlSeconds: 3_600,
});
const KNOWN_EVENT_KEYS = Object.freeze({
"approval.request": ["choices", "event", "run_id", "timestamp"],
"approval.responded": ["choice", "event", "resolved", "run_id", "timestamp"],
"run.cancelled": ["event", "run_id", "timestamp"],
"run.completed": ["event", "output", "run_id", "timestamp", "usage"],
"run.failed": ["error", "event", "run_id", "timestamp"],
"tool.completed": [
"duration",
"error",
"event",
"run_id",
"timestamp",
"tool",
],
"tool.started": ["event", "preview", "run_id", "timestamp", "tool"],
});
const sha256 = (value) => createHash("sha256").update(value).digest("hex");
function stableJson(value) {
if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
if (value && typeof value === "object") {
return `{${Object.keys(value)
.sort()
.map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`)
.join(",")}}`;
}
return JSON.stringify(value);
}
function bytes(value) {
return Buffer.byteLength(value, "utf8");
}
function exactObject(value) {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function approvalResolved(value) {
return value === true || (Number.isSafeInteger(value) && value > 0);
}
function normalizedTimestamp(value) {
if (
typeof value === "string" &&
value.length <= 64 &&
Number.isFinite(Date.parse(value))
) {
return value;
}
// Hermes 0.21.5's live /v1/runs implementation emits time.time() seconds,
// even though the captured fixture examples use ISO strings. Normalize that
// observed wire shape so known tool events do not collapse to UNKNOWN.
if (
typeof value === "number" &&
Number.isFinite(value) &&
value >= 946_684_800 &&
value <= 4_102_444_800
) {
return new Date(value * 1_000).toISOString();
}
return null;
}
function evidence(code, value, extra = {}) {
let encoded;
try {
encoded = stableJson(value);
} catch {
encoded = "[unserializable]";
}
return Object.freeze({
ok: false,
classification: code,
certainty: "UNCERTAIN",
evidence: Object.freeze({
digest: sha256(encoded),
bytes: bytes(encoded),
...extra,
}),
});
}
function assertFixture(fixture) {
if (fixture === undefined) return;
const { fixtureDigest: _fixtureDigest, ...fixtureBody } = fixture ?? {};
if (
!exactObject(fixture) ||
fixture.schemaVersion !== HERMES_RUNS_FIXTURE_IDENTITY.schemaVersion ||
fixture.image !== HERMES_RUNS_FIXTURE_IDENTITY.image ||
fixture.hermesVersion !== HERMES_RUNS_FIXTURE_IDENTITY.hermesVersion ||
fixture.fixtureDigest !== HERMES_RUNS_FIXTURE_IDENTITY.fixtureDigest ||
sha256(stableJson(fixtureBody)) !== fixture.fixtureDigest ||
fixture.network?.host !== "127.0.0.1" ||
fixture.network?.port !== 8642 ||
fixture.network?.cors !== false ||
fixture.network?.publicListener !== false ||
fixture.auth?.required !== true ||
fixture.auth?.type !== "bearer" ||
fixture.runs?.events?.consuming !== true ||
fixture.runs?.events?.secondSubscriptionStatus !== 404 ||
fixture.runs?.restart?.statusAfterRestart !== 404 ||
stableJson(
fixture.runs?.nativeContracts?.createRequest?.phase166ServerAuthoredKeys
) !==
stableJson([
"conversation_history",
"input",
"instructions",
"session_id",
]) ||
stableJson(fixture.runs?.approval?.phase166AllowedChoices) !==
stableJson(["once", "deny"])
) {
throw new Error("hermes_runs_fixture_rejected");
}
const schemas = fixture.runs?.nativeContracts?.events?.schemas;
for (const [name, requiredKeys] of Object.entries(KNOWN_EVENT_KEYS)) {
if (
stableJson(schemas?.[name]?.requiredKeys) !== stableJson(requiredKeys)
) {
throw new Error("hermes_runs_fixture_rejected");
}
}
}
function readApiKey(path) {
if (!isAbsolute(path ?? "")) {
throw new Error("hermes_runs_api_key_file_rejected");
}
const stat = lstatSync(path);
if (stat.isSymbolicLink() || !stat.isFile() || (stat.mode & 0o077) !== 0) {
throw new Error("hermes_runs_api_key_file_rejected");
}
const key = readFileSync(path, "utf8");
if (key.length < 24 || key.length > 512 || /[\r\n\0]/.test(key)) {
throw new Error("hermes_runs_api_key_file_rejected");
}
return key;
}
function boundedString(value, maximum, code) {
if (typeof value !== "string" || value.length < 1 || bytes(value) > maximum) {
throw new Error(code);
}
return value;
}
function canonicalHistory(value) {
if (!Array.isArray(value) || value.length > MAX_HISTORY_ITEMS) {
throw new Error("hermes_runs_history_rejected");
}
const result = value.map((item) => {
if (
!exactObject(item) ||
!["user", "assistant"].includes(item.role) ||
typeof item.content !== "string" ||
bytes(item.content) > MAX_HISTORY_ITEM_BYTES ||
stableJson(Object.keys(item).sort()) !== stableJson(["content", "role"])
) {
throw new Error("hermes_runs_history_rejected");
}
return Object.freeze({ role: item.role, content: item.content });
});
if (bytes(stableJson(result)) > MAX_HISTORY_BYTES) {
throw new Error("hermes_runs_history_rejected");
}
return result;
}
export function validateEndpointModelSelection(value) {
const compiled = compileEndpointModelSelection(value);
if (!compiled || compiled === "invalid") {
throw new Error("hermes_runs_model_selection_rejected");
}
return compiled;
}
export function validateOpenerOutputContract(value) {
const schema = value?.schema;
if (
!exactObject(value) ||
Object.keys(value).sort().join(",") !==
"nativeProposalSchemaDigest,schema,schemaVersion" ||
value.schemaVersion !== "sellable-opener-output-contract/v1" ||
!/^[a-f0-9]{64}$/.test(value.nativeProposalSchemaDigest ?? "") ||
!exactObject(schema) ||
Object.keys(schema).sort().join(",") !==
"additionalProperties,properties,required,type" ||
schema.type !== "object" ||
schema.additionalProperties !== false ||
stableJson(schema.required) !== '["proposal"]' ||
!exactObject(schema.properties) ||
Object.keys(schema.properties).join(",") !== "proposal" ||
!exactObject(schema.properties.proposal) ||
bytes(stableJson(schema)) > 32 * 1024 ||
sha256(stableJson(schema.properties.proposal)) !==
value.nativeProposalSchemaDigest
)
throw new Error("hermes_opener_output_contract_rejected");
return JSON.parse(stableJson(value));
}
// One remote MCP server for the run, in the OpenAI Responses tool shape.
function compileRemoteTools(value) {
const serverUrl = normalizeRunsApiBaseUrl(value?.serverUrl);
const names = value?.allowedTools;
if (
!exactObject(value) ||
Object.keys(value).sort().join(",") !==
"allowedTools,authorization,serverUrl" ||
!serverUrl?.startsWith("https://") ||
typeof value.authorization !== "string" ||
!/^srt_[A-Za-z0-9-]{1,128}_[A-Za-z0-9_-]{43}$/.test(value.authorization) ||
!Array.isArray(names) ||
names.length < 1 ||
names.length > 32 ||
new Set(names).size !== names.length ||
names.some(
(name) => typeof name !== "string" || !/^[a-z][a-z0-9_]{0,63}$/.test(name)
)
)
throw new Error("hermes_runs_submit_rejected");
return Object.freeze({
type: "mcp",
server_label: "sellable",
server_url: serverUrl,
authorization: value.authorization,
allowed_tools: [...names],
require_approval: "never",
});
}
function compileCreateBody(input, openerStage = false, verification = false) {
if (!exactObject(input)) throw new Error("hermes_runs_submit_rejected");
const allowed = [
"conversationHistory",
"input",
"instructions",
"correlation",
"outputContract",
...(openerStage && !verification ? [] : ["modelSelection"]),
...(openerStage ? [] : ["remoteTools"]),
];
if (Object.keys(input).some((key) => !allowed.includes(key))) {
throw new Error("hermes_runs_submit_rejected");
}
const compiledInput =
typeof input.input === "string" ? input.input : stableJson(input.input);
boundedString(compiledInput, MAX_INPUT_BYTES, "hermes_runs_input_rejected");
const instructions = boundedString(
input.instructions,
MAX_INSTRUCTIONS_BYTES,
"hermes_runs_instructions_rejected"
);
const correlation = boundedString(
input.correlation,
1_024,
"hermes_runs_correlation_rejected"
);
const history = canonicalHistory(input.conversationHistory ?? []);
if (openerStage && history.length !== 0) {
throw new Error("hermes_opener_stage_history_rejected");
}
const modelSelection =
input.modelSelection === undefined
? null
: validateEndpointModelSelection(input.modelSelection);
// A per-run model's opener is held to the strict output contract by its
// provider (the bridge sends it as OpenRouter json_schema). Outside opener
// stages the contract only travels with an allowlisted model selection.
if (!openerStage && input.outputContract !== undefined && !modelSelection) {
throw new Error("hermes_runs_submit_rejected");
}
const outputContract =
input.outputContract === undefined
? null
: validateOpenerOutputContract(input.outputContract);
const remoteTools =
input.remoteTools === undefined
? null
: compileRemoteTools(input.remoteTools);
const modelOptions = {
...(modelSelection === null
? {}
: { reasoning_effort: modelSelection.reasoningEffort }),
...(outputContract === null
? {}
: { sellable_opener_output_contract: outputContract }),
};
return Object.freeze({
input: compiledInput,
instructions,
conversation_history: history,
session_id: `${openerStage ? "sellable_opener_stage" : "sellable_endpoint"}_${sha256(correlation).slice(0, 32)}`,
// Hermes 0.21.5 honours a per-run provider and model on /v1/runs and
// resolves the provider's credential from the machine's own pool.
...(modelSelection === null
? {}
: { provider: modelSelection.provider, model: modelSelection.model }),
...(Object.keys(modelOptions).length === 0
? {}
: { model_options: modelOptions }),
...(remoteTools === null ? {} : { tools: [remoteTools] }),
});
}
async function readBounded(response, maximum = MAX_RESPONSE_BYTES) {
const declared = Number(response.headers?.get?.("content-length") ?? "0");
if (Number.isFinite(declared) && declared > maximum) {
return evidence("NATIVE_RESPONSE_OVERSIZED", { declared });
}
const text = await response.text();
if (bytes(text) > maximum) {
return evidence("NATIVE_RESPONSE_OVERSIZED", { bytes: bytes(text) });
}
try {
return JSON.parse(text);
} catch {
return evidence("NATIVE_RESPONSE_UNKNOWN", text);
}
}
const SERVED_RUNTIME_ID = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/;
// The provider and model that actually served a run (a fallback can differ
// from the request) and its token counts, so cost and model are auditable.
function servedRuntime(value) {
return exactObject(value) &&
SERVED_RUNTIME_ID.test(value.provider ?? "") &&
SERVED_RUNTIME_ID.test(value.model ?? "")
? Object.freeze({ provider: value.provider, model: value.model })
: null;
}
function runUsage(value) {
if (!exactObject(value)) return null;
const count = (key) =>
Number.isSafeInteger(value[key]) && value[key] >= 0 ? value[key] : null;
return Object.freeze({
inputTokens: count("input_tokens"),
outputTokens: count("output_tokens"),
cacheReadTokens: count("cache_read_tokens"),
cacheWriteTokens: count("cache_write_tokens"),
});
}
function ensureRunId(value) {
if (!RUN_ID.test(value ?? "")) throw new Error("hermes_runs_run_id_rejected");
return value;
}
function nativeStatus(value, expectedRunId) {
if (!exactObject(value) || value.run_id !== expectedRunId) {
return evidence("NATIVE_STATUS_UNKNOWN", value);
}
const status =
value.status === "waiting_for_approval"
? "awaiting_approval"
: value.status;
if (
![
"started",
"queued",
"running",
"awaiting_approval",
"completed",
"failed",
"cancelled",
"stopping",
].includes(status)
) {
return evidence("NATIVE_STATUS_UNKNOWN", value, {
statusDigest: sha256(String(status)),
});
}
if (status === "stopping") {
return Object.freeze({
ok: true,
runId: expectedRunId,
status,
classification: "STOPPING_UNCERTAIN",
certainty: "UNCERTAIN",
positiveCancellation: false,
});
}
if (status === "completed") {
const output = value.output;
const outputBytes = bytes(stableJson(output));
if (!("output" in value) || outputBytes > MAX_CANONICAL_OUTPUT_BYTES) {
return evidence("NATIVE_COMPLETION_UNKNOWN", value);
}
return Object.freeze({
ok: true,
runId: expectedRunId,
status,
terminal: true,
classification: "COMPLETED",
output,
outputDigest: sha256(stableJson(output)),
servedRuntime: servedRuntime(value.runtime),
usage: runUsage(value.usage),
});
}
if (status === "failed") {
return Object.freeze({
ok: true,
runId: expectedRunId,
status,
terminal: true,
classification: "FAILED",
errorDigest: sha256(stableJson(value.error ?? null)),
});
}
if (status === "cancelled") {
return Object.freeze({
ok: true,
runId: expectedRunId,
status,
terminal: true,
classification: "CANCELLED_POSITIVE",
positiveCancellation: true,
});
}
return Object.freeze({
ok: true,
runId: expectedRunId,
status,
terminal: false,
classification:
status === "awaiting_approval" ? "AWAITING_APPROVAL" : "ACTIVE",
});
}
/** @param {any} value @param {any} options */
export function sanitizeHermesRunEvent(value, options = {}) {
const { expectedRunId, ordinal = 0 } = options;
const encoded = (() => {
try {
return stableJson(value);
} catch {
return "[unserializable]";
}
})();
if (bytes(encoded) > MAX_EVENT_BYTES || !exactObject(value)) {
return evidence("NATIVE_EVENT_UNKNOWN", value, { ordinal });
}
const timestamp = normalizedTimestamp(value.timestamp);
const required = KNOWN_EVENT_KEYS[value.event];
if (
!required ||
required.some((key) => !(key in value)) ||
value.run_id !== expectedRunId ||
!RUN_ID.test(value.run_id ?? "") ||
!timestamp
) {
return evidence("NATIVE_EVENT_UNKNOWN", value, { ordinal });
}
const base = {
ok: true,
event: value.event,
runId: expectedRunId,
timestamp,
nativeEventIdentity: sha256(`${ordinal}\n${encoded}`),
};
if (value.event === "approval.request") {
const choices = Array.isArray(value.choices)
? value.choices.filter((choice) => choice === "once" || choice === "deny")
: [];
return Object.freeze({
...base,
kind: "APPROVAL_REQUESTED",
allowedChoices: Object.freeze([...new Set(choices)].sort()),
requestDigest: sha256(encoded),
});
}
if (value.event === "approval.responded") {
if (
!["once", "deny"].includes(value.choice) ||
!approvalResolved(value.resolved)
) {
return evidence("NATIVE_EVENT_UNKNOWN", value, { ordinal });
}
return Object.freeze({
...base,
kind: "APPROVAL_RESPONDED",
choice: value.choice,
resolved: true,
});
}
if (value.event === "run.completed") {
if (bytes(stableJson(value.output)) > MAX_CANONICAL_OUTPUT_BYTES) {
return evidence("NATIVE_EVENT_UNKNOWN", value, { ordinal });
}
return Object.freeze({
...base,
kind: "RUN_COMPLETED",
terminal: true,
output: value.output,
outputDigest: sha256(stableJson(value.output)),
usageDigest: sha256(stableJson(value.usage)),
servedRuntime: servedRuntime(value.runtime),
usage: runUsage(value.usage),
});
}
if (value.event === "run.failed") {
return Object.freeze({
...base,
kind: "RUN_FAILED",
terminal: true,
errorDigest: sha256(stableJson(value.error)),
});
}
if (value.event === "run.cancelled") {
return Object.freeze({
...base,
kind: "RUN_CANCELLED",
terminal: true,
positiveCancellation: true,
});
}
if (!SAFE_TOOL_NAME.test(value.tool ?? "")) {
return evidence("NATIVE_EVENT_UNKNOWN", value, { ordinal });
}
if (value.event === "tool.started") {
return Object.freeze({
...base,
kind: "TOOL_STARTED",
tool: value.tool,
previewDigest: sha256(stableJson(value.preview)),
});
}
return Object.freeze({
...base,
kind: "TOOL_COMPLETED",
tool: value.tool,
duration:
typeof value.duration === "number" && Number.isFinite(value.duration)
? Math.max(0, Math.min(value.duration, 86_400))
: null,
errorDigest:
value.error === null || value.error === false
? null
: sha256(stableJson(value.error)),
});
}
function transportFailure(code, error) {
const result = new Error(code);
result.code = code;
result.classification = code;
result.certainty = "UNCERTAIN";
result.evidence = Object.freeze({
digest: sha256(error instanceof Error ? error.name : typeof error),
});
return result;
}
function submitNotAdmitted(response, value) {
const retryAfter = response.headers?.get?.("retry-after") ?? "";
const retryAfterSeconds =
retryAfter.trim().length > 0 ? Number(retryAfter) : Number.NaN;
const retryAfterMs = Number.isFinite(retryAfterSeconds)
? Math.max(100, Math.min(5_000, retryAfterSeconds * 1_000))
: 1_000;
const result = new Error("SUBMIT_NOT_ADMITTED");
result.code = "SUBMIT_NOT_ADMITTED";
result.classification = "SUBMIT_NOT_ADMITTED";
result.certainty = "NOT_ACCEPTED";
result.nativeStatus = response.status;
result.retryAfterMs = retryAfterMs;
result.evidence = Object.freeze({
digest: sha256(stableJson(value)),
});
return result;
}
/** @param {any} options */
export function createHermesRunsClient(options = {}) {
const {
apiKey: inMemoryKey,
apiKeyFile,
baseUrl = LOOPBACK_ORIGIN,
scopeHeaders = {},
fixture,
fetchImpl = fetch,
timeoutMs = 20_000,
} = options;
assertFixture(fixture);
const origin = normalizeRunsApiBaseUrl(baseUrl);
if (
!origin ||
// Exactly one key source. An in-memory key (a workspace model connection)
// follows that key's own rule, never readApiKey's key-file rule.
(inMemoryKey === undefined) === (apiKeyFile === undefined) ||
(inMemoryKey !== undefined &&
(typeof inMemoryKey !== "string" ||
inMemoryKey.length < 16 ||
inMemoryKey.length > 1024 ||
/[\s\x00-\x1f\x7f]/.test(inMemoryKey))) ||
!exactObject(scopeHeaders) ||
Object.keys(scopeHeaders).some(
(key) => !["x-sellable-workspace-id", "x-sellable-agent-id"].includes(key)
) ||
Object.values(scopeHeaders).some(
(value) =>
typeof value !== "string" || !/^[A-Za-z0-9_-]{1,128}$/.test(value)
) ||
typeof fetchImpl !== "function" ||
!Number.isSafeInteger(timeoutMs) ||
timeoutMs < 100 ||
timeoutMs > 120_000
) {
throw new Error("hermes_runs_client_rejected");
}
const apiKey = inMemoryKey ?? readApiKey(apiKeyFile);
const request = async (path, init = {}) => {
if (!path.startsWith("/v1/runs")) {
throw new Error("hermes_runs_path_rejected");
}
return fetchImpl(`${origin}${path}`, {
...init,
redirect: "error",
headers: {
...scopeHeaders,
authorization: `Bearer ${apiKey}`,
...(init.body === undefined
? {}
: { "content-type": "application/json" }),
},
signal: init.signal ?? AbortSignal.timeout(timeoutMs),
});
};
const submit = async (
compiled,
{ signal } = {},
openerStage = false,
verification = false
) => {
const body = compileCreateBody(compiled, openerStage, verification);
let response;
try {
response = await request("/v1/runs", {
method: "POST",
body: JSON.stringify(body),
signal,
});
} catch (error) {
throw transportFailure("SUBMIT_AMBIGUOUS", error);
}
const value = await readBounded(response);
// Hermes 0.21.5 emits 429 for its concurrency limiter and 503 while the
// gateway is draining. Both responses occur before it allocates a run,
// so retrying the identical request cannot duplicate a native attempt.
if (response.status === 429 || response.status === 503) {
throw submitNotAdmitted(response, value);
}
if (
response.status !== 202 ||
!exactObject(value) ||
stableJson(
Object.keys(value)
.filter((key) => key !== "replayed")
.sort()
) !== stableJson(["run_id", "status"]) ||
(value.replayed !== undefined && value.replayed !== false) ||
value.status !== "started" ||
!RUN_ID.test(value.run_id ?? "")
) {
throw transportFailure("SUBMIT_AMBIGUOUS", value);
}
return Object.freeze({
ok: true,
runId: value.run_id,
status: "started",
requestDigest: sha256(stableJson(body)),
});
};
return Object.freeze({
fixtureIdentity: HERMES_RUNS_FIXTURE_IDENTITY,
submit: (compiled, options) => submit(compiled, options),
// Fixed operator verification uses the existing tool-free session policy.
submitVerification: (compiled, options) =>
submit(compiled, options, true, true),
// Only this reserved session prefix receives the pinned tool-free bridge policy.
submitOpenerStage: (compiled, options) => submit(compiled, options, true),
async status(runId, { signal } = {}) {
ensureRunId(runId);
let response;
try {
response = await request(`/v1/runs/${runId}`, { signal });
} catch (error) {
return evidence("NATIVE_DISCONNECTED", error);
}
if (response.status === 404) {
return Object.freeze({
ok: false,
classification: "NATIVE_HANDLE_LOST",
certainty: "UNCERTAIN",
nativeStatus: 404,
});
}
const value = await readBounded(response);
if (response.status !== 200) {
return evidence("NATIVE_STATUS_UNKNOWN", value, {
nativeStatus: response.status,
});
}
return nativeStatus(value, runId);
},
async streamEvents(runId, { signal, onEvent } = {}) {
ensureRunId(runId);
if (typeof onEvent !== "function") {
throw new Error("hermes_runs_event_handler_rejected");
}
let response;
try {
response = await request(`/v1/runs/${runId}/events`, { signal });
} catch (error) {
return evidence("NATIVE_DISCONNECTED", error);
}
if (response.status === 404) {
return Object.freeze({
ok: false,
classification: "NATIVE_EVENTS_LOST",
certainty: "UNCERTAIN",
nativeStatus: 404,
});
}
if (
response.status !== 200 ||
!String(response.headers?.get?.("content-type") ?? "")
.toLowerCase()
.startsWith("text/event-stream")
) {
return evidence("NATIVE_EVENTS_UNKNOWN", {
status: response.status,
contentType: response.headers?.get?.("content-type") ?? null,
});
}
let ordinal = 0;
let totalBytes = 0;
let buffered = "";
const handleFrame = async (frame) => {
const data = frame
.split(/\r?\n/)
.filter((line) => line.startsWith("data:"))
.map((line) => line.slice(5).trimStart())
.join("\n");
if (!data) return;
let raw;
try {
raw = JSON.parse(data);
} catch {
raw = data;
}
const sanitized = sanitizeHermesRunEvent(raw, {
expectedRunId: runId,
ordinal,
});
// The callback is intentionally awaited. The runtime binds it to the
// signed durable event append; no local/public cursor advances first.
await onEvent(sanitized, ordinal);
ordinal += 1;
};
const drainFrames = async (final = false) => {
for (;;) {
const boundary = buffered.match(/\r?\n\r?\n/);
if (!boundary || boundary.index === undefined) break;
const frame = buffered.slice(0, boundary.index);
buffered = buffered.slice(boundary.index + boundary[0].length);
await handleFrame(frame);
}
if (final && buffered.trim()) {
await handleFrame(buffered);
buffered = "";
}
};
if (!response.body) {
return evidence("NATIVE_EVENTS_UNKNOWN", { body: "missing" });
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
for (;;) {
const { done, value } = await reader.read();
if (done) break;
totalBytes += value.byteLength;
if (totalBytes > MAX_RESPONSE_BYTES) {
await reader.cancel();
return evidence("NATIVE_EVENTS_OVERSIZED", { bytes: totalBytes });
}
buffered += decoder.decode(value, { stream: true });
await drainFrames();
}
buffered += decoder.decode();
await drainFrames(true);
return Object.freeze({ ok: true, consumed: true, eventCount: ordinal });
},
async approve(runId, choice, { signal } = {}) {
ensureRunId(runId);
if (choice !== "once" && choice !== "deny") {
throw new Error("hermes_runs_approval_choice_rejected");
}
let response;
try {
response = await request(`/v1/runs/${runId}/approval`, {
method: "POST",
body: JSON.stringify({ choice }),
signal,
});
} catch (error) {
return evidence("NATIVE_APPROVAL_UNCERTAIN", error);
}
const value = await readBounded(response);
if (
response.status !== 200 ||
!exactObject(value) ||
stableJson(Object.keys(value).sort()) !==
stableJson(["choice", "object", "resolved", "run_id"]) ||
value.run_id !== runId ||
value.choice !== choice ||
!approvalResolved(value.resolved)
) {
return evidence("NATIVE_APPROVAL_UNCERTAIN", value, {
nativeStatus: response.status,
});
}
return Object.freeze({ ok: true, runId, choice, resolved: true });
},
async stop(runId, { signal } = {}) {
ensureRunId(runId);
let response;
try {
response = await request(`/v1/runs/${runId}/stop`, {
method: "POST",
signal,
});
} catch (error) {
return evidence("STOP_UNCERTAIN", error);
}
if (response.status === 404) {
return Object.freeze({
ok: false,
classification: "STOP_RACE_UNCERTAIN",
certainty: "UNCERTAIN",
nativeStatus: 404,
});
}
const value = await readBounded(response);
// Hermes 0.21 returns the existing terminal status when stop races
// completion. Preserve that result; a failed/completed run is not cancelled.
if (
response.status === 200 &&
exactObject(value) &&
value.run_id === runId &&
["completed", "failed", "cancelled"].includes(value.status)
) {
return nativeStatus(value, runId);
}
if (
response.status !== 200 ||
!exactObject(value) ||
stableJson(Object.keys(value).sort()) !==
stableJson(["run_id", "status"]) ||
value.run_id !== runId ||
value.status !== "stopping"
) {
return evidence("STOP_UNCERTAIN", value, {
nativeStatus: response.status,
});
}
// Hermes only accepted the request. `stopping` is explicitly not proof
// of terminal cancellation in the frozen 0.20.0 fixture.
return Object.freeze({
ok: true,
runId,
status: "stopping",
classification: "STOPPING_UNCERTAIN",
certainty: "UNCERTAIN",
positiveCancellation: false,
});
},
readiness({ keyGeneration, keyFingerprint, runtimeGeneration } = {}) {
if (
!Number.isSafeInteger(keyGeneration) ||
keyGeneration < 1 ||
!/^[a-f0-9]{16}$/.test(keyFingerprint ?? "") ||
!Number.isSafeInteger(runtimeGeneration) ||
runtimeGeneration < 1
) {
throw new Error("hermes_runs_readiness_rejected");
}
return Object.freeze({
host: "127.0.0.1",
port: 8642,
cors: false,
publicListener: false,
authRequired: true,
authType: "bearer",
keyGeneration,
keyFingerprint,
runtimeGeneration,
fixtureDigest: EXACT_FIXTURE_DIGEST,
capability: "fixture_bound_runs_relay_v1",
});
},
});
}
// Endpoint runs (`api_server`) and cron jobs each leave a Hermes session that
// nothing reads again: an endpoint attempt gets a fresh session and its result
// lives in Sellable. Hermes only closes these after 90 idle days and deletes
// them after another 90, so state.db grows with every run, and so do the
// backup and the volume. Retention deletes them after a week. It goes through
// the running gateway's session API because Hermes refuses bulk deletes from a
// second writer, and it never touches conversations (Slack, Desktop, TUI).
export const HERMES_AUTOMATION_SESSION_SOURCES = Object.freeze([
"api_server",
"cron",
]);
export const HERMES_AUTOMATION_SESSION_RETENTION_DAYS = 7;
const SESSION_PAGE_SIZE = 200;
const SESSION_RETENTION_MAX_DELETES = 1_000;
const SESSION_RETENTION_INTERVAL_MS = 24 * 60 * 60 * 1000;
const SESSION_RETENTION_RETRY_MS = 60 * 60 * 1000;
// A runtime that just started is still bringing its gateway up; the first pass
// waits so it does not fail against an API that is not serving yet.
const SESSION_RETENTION_START_DELAY_MS = 10 * 60 * 1000;
// A bounded reason for the operator log; never a raw error message.
function sessionRetentionFailureReason(error) {
const message = String(error?.message ?? "");
const http =
/^hermes_session_retention_(list|delete)_http_([1-5][0-9]{2})$/.exec(
message
);
if (http) return `${http[1]}_http_${http[2]}`;
if (
message === "hermes_runs_api_key_file_rejected" ||
error?.code === "ENOENT"
) {
return "key_rejected";
}
if (error?.name === "TimeoutError" || error?.name === "AbortError") {
return "timeout";
}
return "request_failed";
}
function sessionLastActiveSeconds(session) {
return Math.max(
...[session.last_active, session.ended_at, session.started_at].filter(
(value) => typeof value === "number" && Number.isFinite(value)
),
0
);
}
/** @param {any} options */
export async function pruneHermesAutomationSessions({
apiKeyFile,
fetchImpl = fetch,
now = Date.now(),
retentionDays = HERMES_AUTOMATION_SESSION_RETENTION_DAYS,
maxDeletes = SESSION_RETENTION_MAX_DELETES,
}) {
const apiKey = readApiKey(apiKeyFile);
const call = (method, path) =>
fetchImpl(`${LOOPBACK_ORIGIN}${path}`, {
method,
headers: { authorization: `Bearer ${apiKey}` },
signal: AbortSignal.timeout(20_000),
});
const cutoff = now / 1000 - retentionDays * 86_400;
const stale = new Set();
for (const source of HERMES_AUTOMATION_SESSION_SOURCES) {
// Newest activity first, so every page is read; listing stops once one
// pass worth of stale sessions is known.
for (
let offset = 0;
stale.size <= maxDeletes;
offset += SESSION_PAGE_SIZE
) {
const response = await call(
"GET",
`/api/sessions?source=${source}&limit=${SESSION_PAGE_SIZE}&offset=${offset}`
);
if (!response.ok) {
throw new Error(
`hermes_session_retention_list_http_${response.status}`
);
}
const page = await response.json();
const rows = Array.isArray(page?.data) ? page.data : [];
for (const session of rows) {
if (
session?.source === source &&
typeof session.id === "string" &&
session.pinned !== true &&
sessionLastActiveSeconds(session) < cutoff
) {
stale.add(session.id);
}
}
if (page?.has_more !== true || rows.length === 0) break;
}
}
let deleted = 0;
for (const id of [...stale].slice(0, maxDeletes)) {
const response = await call(
"DELETE",
`/api/sessions/${encodeURIComponent(id)}`
);
if (response.ok) deleted += 1;
else if (response.status !== 404) {
throw new Error(
`hermes_session_retention_delete_http_${response.status}`
);
}
}
return {
ok: true,
deleted,
remaining: Math.max(0, stale.size - maxDeletes),
};
}
function readRetentionNextRunAt(statePath) {
try {
const value = JSON.parse(readFileSync(statePath, "utf8"))?.nextRunAt;
return Number.isFinite(value) ? value : 0;
} catch {
return 0;
}
}
function writeRetentionNextRunAt(statePath, nextRunAt) {
mkdirSync(dirname(statePath), { recursive: true, mode: 0o700 });
const temporary = `${statePath}.${process.pid}.tmp`;
writeFileSync(temporary, `${JSON.stringify({ nextRunAt })}\n`, {
mode: 0o600,
});
renameSync(temporary, statePath);
}
/**
* Daily, single-flight session retention for a runtime's idle loop. `tick`
* returns the running pass (or null when not due) and never rejects, so the
* loop can fire it without waiting.
* @param {any} options
*/
export function createHermesSessionRetention({
fetchImpl = fetch,
now = () => Date.now(),
maxDeletes = SESSION_RETENTION_MAX_DELETES,
startDelayMs = SESSION_RETENTION_START_DELAY_MS,
log = (entry) => process.stderr.write(`${JSON.stringify(entry)}\n`),
} = {}) {
let running = null;
const startedAt = now();
return {
tick({ apiKeyFile, statePath }) {
if (running) return running;
if (now() < startedAt + startDelayMs) return null;
if (now() < readRetentionNextRunAt(statePath)) return null;
running = (async () => {
let nextRunAt;
try {
const result = await pruneHermesAutomationSessions({
apiKeyFile,
fetchImpl,
now: now(),
maxDeletes,
});
log({
ok: true,
code: "hermes_session_retention_pruned",
deleted: result.deleted,
remaining: result.remaining,
});
nextRunAt =
now() +
(result.remaining > 0
? SESSION_RETENTION_RETRY_MS
: SESSION_RETENTION_INTERVAL_MS);
} catch (error) {
log({
ok: false,
code: "hermes_session_retention_failed",
reason: sessionRetentionFailureReason(error),
});
nextRunAt = now() + SESSION_RETENTION_RETRY_MS;
}
try {
writeRetentionNextRunAt(statePath, nextRunAt);
} catch {
// The next tick simply runs again.
}
})().finally(() => {
running = null;
});
return running;
},
};
}