@sellable/install
Version:
One-command installer for Sellable MCP in Claude Code, Codex, and Hermes
351 lines (342 loc) • 12.7 kB
JavaScript
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);
}