UNPKG

@sellable/install

Version:

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

351 lines (342 loc) • 12.7 kB
import { createHash } from "node:crypto"; import { compileEndpointModelSelection, normalizeRunsApiBaseUrl, } from "./endpoint-model-selection.mjs"; import { createHermesRunsClient } from "./hermes-runs-client.mjs"; import { createOpenAiRunsClient } from "./openai-runs-client.mjs"; import { createPinnedFetch } from "./public-endpoint-guard.mjs"; import { createWorkspaceConnectionRunsClient } from "./workspace-connection-client.mjs"; export const RUNS_API_CONNECTIONS_ENV = "SELLABLE_AGENT_RUNS_API_CONNECTIONS_JSON"; const id = (value) => typeof value === "string" && /^[A-Za-z0-9_.:-]{1,128}$/.test(value); const record = (value) => value && typeof value === "object" && !Array.isArray(value); const stable = (value) => Array.isArray(value) ? `[${value.map(stable).join(",")}]` : record(value) ? `{${Object.keys(value) .sort() .map((key) => `${JSON.stringify(key)}:${stable(value[key])}`) .join(",")}}` : JSON.stringify(value); const fail = () => { throw new Error("runs_api_connection_configuration_rejected"); }; // Only the control plane's machine configuration supplies this registry. It // contains credential file references, never credentials or request input. export function parseRunsApiConnections(raw) { if (raw === undefined || raw === "") return []; if (typeof raw !== "string" || Buffer.byteLength(raw) > 64 * 1024) fail(); let entries; try { entries = JSON.parse(raw); } catch { fail(); } if (!Array.isArray(entries) || entries.length > 32) fail(); const seen = new Set(); return entries.map((entry) => { if ( !record(entry) || Object.keys(entry).some( (key) => ![ "runtimeId", "workspaceId", "agentId", "baseUrl", "apiKeyFile", "endpointIds", "workKinds", "models", "localRoot", "toolEndpointIds", "name", "protocol", "structuredOutputs", ].includes(key) ) || ![entry.runtimeId, entry.workspaceId, entry.agentId].every(id) || (entry.protocol !== undefined && !["runs-api", "openai-chat", "openai-responses"].includes( entry.protocol )) || (entry.structuredOutputs !== undefined && typeof entry.structuredOutputs !== "boolean") || (entry.name !== undefined && (typeof entry.name !== "string" || !/^[\p{L}\p{N} .()_-]{1,80}$/u.test(entry.name))) || typeof entry.apiKeyFile !== "string" || !/^\/(?:opt\/data|run\/secrets)\/[A-Za-z0-9_./-]{1,250}$/.test( entry.apiKeyFile ) || entry.apiKeyFile .split("/") .some((part) => part === "." || part === "..") || // Absent: the connection serves every endpoint of its agent. (entry.endpointIds !== undefined && (!Array.isArray(entry.endpointIds) || !entry.endpointIds.length || entry.endpointIds.length > 64 || !entry.endpointIds.every(id))) || !Array.isArray(entry.workKinds) || !entry.workKinds.length || !entry.workKinds.every((kind) => ["STANDARD", "OPENER", "REPLY", "FOLLOW_UP"].includes(kind) ) || !Array.isArray(entry.models) || !entry.models.length || entry.models.length > 16 ) fail(); const baseUrl = normalizeRunsApiBaseUrl(entry.baseUrl); if (!baseUrl) fail(); const identity = `${entry.runtimeId}:${baseUrl}`; if (seen.has(identity)) fail(); seen.add(identity); const models = entry.models.map((model) => { if ( !record(model) || Object.keys(model).sort().join(",") !== "model,provider,reasoningEfforts" || !Array.isArray(model.reasoningEfforts) || !model.reasoningEfforts.length || model.reasoningEfforts.some( (reasoningEffort) => compileEndpointModelSelection({ provider: model.provider, model: model.model, reasoningEffort, runsApiBaseUrl: baseUrl, }) === "invalid" ) ) fail(); return { ...model, reasoningEfforts: [...model.reasoningEfforts] }; }); if (entry.localRoot !== undefined) { const url = new URL(baseUrl); if ( (entry.protocol !== undefined && entry.protocol !== "runs-api") || typeof entry.localRoot !== "string" || !/^\/opt\/data\/[A-Za-z0-9_-]{1,80}$/.test(entry.localRoot) || entry.apiKeyFile !== `${entry.localRoot}/api-key` || url.hostname !== "127.0.0.1" || url.protocol !== "http:" || url.pathname !== "/" || !url.port || Number(url.port) < 1024 || models.some( (model) => model.provider !== "claude-code" || !/^claude-[a-z0-9-]{1,100}$/.test(model.model) || model.reasoningEfforts.some( (effort) => !["low", "medium", "high"].includes(effort) ) ) ) fail(); } // When present, only runs on these endpoints get their own allowed tools // from the image-owned service. When absent, a run gets the tools its own // signed turn descriptor allows, and a run allowed none stays tool-free. if ( entry.toolEndpointIds !== undefined && (entry.localRoot === undefined || !Array.isArray(entry.toolEndpointIds) || !entry.toolEndpointIds.length || !entry.toolEndpointIds.every(id) || new Set(entry.toolEndpointIds).size !== entry.toolEndpointIds.length || (entry.endpointIds !== undefined && entry.toolEndpointIds.some( (value) => !entry.endpointIds.includes(value) ))) ) fail(); return Object.freeze({ ...entry, baseUrl, models, ...(entry.endpointIds === undefined ? {} : { endpointIds: [...entry.endpointIds] }), workKinds: [...entry.workKinds], ...(entry.toolEndpointIds === undefined ? {} : { toolEndpointIds: [...entry.toolEndpointIds] }), }); }); } /** Whether a trusted connection may serve runs of this endpoint. */ export function runsApiConnectionServesEndpoint(connection, endpointId) { return ( connection.endpointIds === undefined || connection.endpointIds.includes(endpointId) ); } export function runsApiConnectionId(connection) { return `runs_${createHash("sha256") .update( JSON.stringify([ connection.runtimeId, connection.workspaceId, connection.agentId, connection.baseUrl, ]) ) .digest("hex") .slice(0, 24)}`; } export function runsApiConnectionsForRuntime(raw, runtime) { return parseRunsApiConnections(raw).filter( (connection) => connection.runtimeId === runtime.runtimeId && connection.workspaceId === runtime.workspaceId && connection.agentId === runtime.agentId ); } // Exactly the members the endpoint-connection-key verb returns. The key follows // the saved key's rule (16-1024 characters), never the key-file rule; the // remote tool's values are checked where the create body is built. const connectionRelease = (value) => record(value) && Object.keys(value).sort().join(",") === "apiKey,keyGeneration,protocol,structuredOutputs,tools" && typeof value.apiKey === "string" && value.apiKey.length >= 16 && value.apiKey.length <= 1024 && !/[\s\x00-\x1f\x7f]/.test(value.apiKey) && Number.isSafeInteger(value.keyGeneration) && ["runs-api", "openai-chat", "openai-responses"].includes(value.protocol) && typeof value.structuredOutputs === "boolean" && (value.tools === null || (value.protocol === "runs-api" && record(value.tools) && Object.keys(value.tools).sort().join(",") === "allowedTools,authorization,expiresAt,serverUrl" && typeof value.tools.serverUrl === "string" && typeof value.tools.authorization === "string" && Array.isArray(value.tools.allowedTools) && typeof value.tools.expiresAt === "string")); // Adds the opener output contract (and a granted remote tool) to every submit. function customRunsClient(client, claim, remoteTools) { return Object.freeze({ ...client, submit(compiled, options) { const request = remoteTools ? { ...compiled, remoteTools } : compiled; // Public semantic endpoints have a published schema but no product tuple. // Preserve that existing contract when calling a custom Runs service. const schema = claim.revision?.outputSchema; if (compiled.outputContract || !schema?.properties?.proposal) return client.submit(request, options); return client.submit( { ...request, outputContract: { schemaVersion: "sellable-opener-output-contract/v1", schema, nativeProposalSchemaDigest: createHash("sha256") .update(stable(schema.properties.proposal)) .digest("hex"), }, }, options ); }, }); } /** @param {any} options */ export async function createConfiguredRunsClient({ apiKeyFile, claim, env = process.env, clientFactory = createHermesRunsClient, fetchConnectionKey, chainStep = 0, createFetch = createPinnedFetch, }) { const selection = claim?.run?.modelSelection; // A workspace connection never uses the registry, even on a matching URL: // its key is fetched for this attempt and lives only in these closures, and // every request goes through the address guard. URL and model come from // the signed claim, never from the key response. if (selection?.modelConnectionId) { const step = compileEndpointModelSelection(selection); if (!step || step === "invalid") throw new Error("runs_api_model_rejected"); let release = null; try { release = await fetchConnectionKey(step.modelConnectionId, chainStep); } catch { // Every refusal is one fixed code: no server reason, no response field. } if (!connectionRelease(release)) throw new Error("runs_api_connection_unavailable"); const { apiKey, protocol, structuredOutputs, tools } = release; const client = createWorkspaceConnectionRunsClient({ connection: { baseUrl: step.runsApiBaseUrl, protocol, structuredOutputs }, apiKey, // An OpenAI-protocol provider sends its headers only when it is done, so // the adapter's 750 s run signal bounds that call, not the guard. fetchImpl: protocol === "runs-api" ? createFetch() : createFetch({ deadlineMs: 760_000 }), modelSelection: step, responseSchema: claim.revision?.outputSchema, }); return customRunsClient( client, claim, tools && { serverUrl: tools.serverUrl, authorization: tools.authorization, allowedTools: tools.allowedTools, } ); } if (!selection?.runsApiBaseUrl) return clientFactory({ apiKeyFile }); const model = compileEndpointModelSelection(selection); if (!model || model === "invalid") throw new Error("runs_api_model_rejected"); const connection = runsApiConnectionsForRuntime( env[RUNS_API_CONNECTIONS_ENV], { runtimeId: claim.runtime?.runtimeId, workspaceId: claim.attempt?.workspaceId, agentId: claim.attempt?.agentId, } ).find((value) => value.baseUrl === model.runsApiBaseUrl); const workKind = claim.run.productExecution?.workKind ?? "STANDARD"; if ( !connection || env.SELLABLE_RUNTIME_ID !== connection.runtimeId || !runsApiConnectionServesEndpoint(connection, claim.attempt.endpointId) || !connection.workKinds.includes(workKind) || !connection.models.some( (allowed) => allowed.provider === model.provider && allowed.model === model.model && allowed.reasoningEfforts.includes(model.reasoningEffort) ) ) throw new Error("runs_api_connection_not_authorized"); // This client instance retains one destination and one credential for the // entire attempt, including stream/status/stop, even if settings later change. const client = connection.protocol?.startsWith("openai-") ? createOpenAiRunsClient({ connection, responseSchema: claim.revision?.outputSchema, modelSelection: model, }) : clientFactory({ baseUrl: connection.baseUrl, apiKeyFile: connection.apiKeyFile, scopeHeaders: { "x-sellable-workspace-id": connection.workspaceId, "x-sellable-agent-id": connection.agentId, }, }); return customRunsClient(client, claim); }