UNPKG

@sellable/install

Version:

One-command installer for Sellable MCP in Claude Code, Codex, and Hermes

1,149 lines (1,113 loc) • 36.8 kB
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; }, }; }