UNPKG

@sellable/install

Version:

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

346 lines (339 loc) • 11.5 kB
// The machine-local Slack Socket Mode stand-in for a shared-app runtime. import { timingSafeEqual } from "node:crypto"; import { once } from "node:events"; import { createServer } from "node:http"; // The shared app declares /sellable; Hermes registers /hermes. Every other // command reaches Hermes under its own name. export const SHARED_SLACK_COMMAND_ALIASES = Object.freeze({ "/sellable": "/hermes", }); const record = (value) => value !== null && typeof value === "object" && !Array.isArray(value); const malformed = () => new Error("slack_envelope_malformed"); // One stored Slack inbox row becomes one Socket Mode frame // (slack_sdk/socket_mode/request.py). Events keep Slack's exact body bytes; // interactive and slash bodies are Slack's form, decoded as Bolt decodes it. export function toSocketModeFrame({ id, kind, body, slackRetryNum, slackRetryReason, }) { if ( typeof id !== "string" || !id || typeof body !== "string" || !Number.isInteger(slackRetryNum) || slackRetryNum < 0 || typeof slackRetryReason !== "string" ) throw malformed(); if (kind === "EVENTS_API") { let parsed; try { parsed = JSON.parse(body); } catch { throw malformed(); } if (!record(parsed)) throw malformed(); return ( `{"envelope_id":${JSON.stringify(id)},"type":"events_api",` + `"accepts_response_payload":false,"retry_attempt":${slackRetryNum},` + `"retry_reason":${JSON.stringify(slackRetryReason)},"payload":${body}}` ); } const frame = (type, payload) => JSON.stringify({ envelope_id: id, type, accepts_response_payload: true, retry_attempt: slackRetryNum, retry_reason: slackRetryReason, payload, }); if (kind === "INTERACTIVE") { let payload; try { payload = JSON.parse(new URLSearchParams(body).get("payload")); } catch { throw malformed(); } if (!record(payload)) throw malformed(); return frame("interactive", payload); } if (kind === "SLASH_COMMANDS") { const fields = Object.fromEntries(new URLSearchParams(body)); if (typeof fields.command !== "string" || !fields.command) throw malformed(); fields.command = SHARED_SLACK_COMMAND_ALIASES[fields.command] ?? fields.command; return frame("slash_commands", fields); } throw malformed(); } const json = (res, status, body) => { res.writeHead(status, { "content-type": "application/json" }); res.end(JSON.stringify(body)); }; // Length-checked first: timingSafeEqual throws on unequal lengths. const same = (provided, expected) => { const a = Buffer.from(provided ?? ""); const b = Buffer.from(expected); return a.length === b.length && timingSafeEqual(a, b); }; const OPEN = 1; // WebSocket.OPEN // Node's parser admits request targets URL rejects (for example "http://["). // A throw in either handler would take the customer runtime down. const targetOf = (raw) => { try { return new URL(raw, "http://127.0.0.1"); } catch { return null; } }; const API_METHOD = /^\/api\/([A-Za-z0-9_.]+)$/; const HOP_BY_HOP = new Set([ "host", "content-length", "connection", "transfer-encoding", "keep-alive", "upgrade", ]); const SLACK_CHANNEL_ID = /^[CDG][A-Z0-9]+$/; // slack_sdk sends some arguments in the query string, and the status call // names the channel channel_id. const channelOf = (body, contentType, searchParams) => { let fields = {}; try { fields = /json/.test(contentType ?? "") ? JSON.parse(body) : Object.fromEntries(new URLSearchParams(body)); } catch {} const channel = fields?.channel ?? fields?.channel_id ?? searchParams.get("channel") ?? searchParams.get("channel_id"); return typeof channel === "string" && SLACK_CHANNEL_ID.test(channel) ? channel : null; }; // Hermes connects here with the per-boot local app token as if this were // Slack (docs.slack.dev/apis/events-api/using-socket-mode). The token and // ticket never appear in a diagnostic. export async function startSlackStandIn(options = {}) { const { localAppToken, ticket, appId, upstreamOrigin, fetchImpl = fetch, ackTimeoutMs = 10_000, upstreamTimeoutMs = 30_000, warmGraceMs = 8_000, now = Date.now, diagnostic = () => {}, } = options; if ( typeof localAppToken !== "string" || !localAppToken || typeof ticket !== "string" || !ticket || typeof appId !== "string" || typeof upstreamOrigin !== "string" || typeof fetchImpl !== "function" || typeof now !== "function" || typeof diagnostic !== "function" ) throw new Error("slack_stand_in_configuration_rejected"); // Imported here so the Admin image, which loads customer-runtime.mjs but // never starts the stand-in, does not need ws. const { WebSocketServer } = await import("ws"); const wss = new WebSocketServer({ noServer: true }); let current = null; let connectedAt = 0; let port = 0; // One in-flight envelope per deliver() call (the relay is sequential). Each // waiter knows the socket it was sent on, so a replaced socket releases its // own envelope for redelivery instead of letting it time out as no_ack. const waiters = new Map(); const server = createServer(async (req, res) => { const target = targetOf(req.url); if (target?.pathname === "/api/apps.connections.open") { const ok = same(req.headers.authorization, `Bearer ${localAppToken}`); diagnostic({ kind: "sellable_slack_stand_in_api", method: "apps.connections.open", status: 200, ok, error: ok ? null : "invalid_auth", channel: null, }); // The SDK reconnects with its cached URL, so the URL is reusable. return json( res, 200, ok ? { ok, url: `ws://127.0.0.1:${port}/link/?ticket=${ticket}` } : { ok, error: "invalid_auth" } ); } const method = target && API_METHOD.exec(target.pathname)?.[1]; if (!method) return json(res, 404, { ok: false, error: "unknown_method" }); if (req.method !== "GET" && req.method !== "POST") return json(res, 405, { ok: false, error: "method_not_allowed" }); // Every other call goes to Slack with Hermes' own token, and Slack's // answer comes back as is. If Slack resets or hangs, Hermes' connection // is dropped so its client retries as it would against Slack. This // handler never rejects: the runtime has no unhandledRejection handler. const line = { kind: "sellable_slack_stand_in_api", method, status: null, ok: null, error: null, channel: null, }; try { const parts = []; for await (const part of req) parts.push(part); const body = Buffer.concat(parts); line.channel = channelOf( String(body), req.headers["content-type"], target.searchParams ); const headers = {}; for (const [name, value] of Object.entries(req.headers)) if (!HOP_BY_HOP.has(name)) headers[name] = value; const upstream = await fetchImpl( `${upstreamOrigin}/api/${method}${target.search}`, { method: req.method, headers, body: req.method === "POST" ? body : undefined, signal: AbortSignal.timeout(upstreamTimeoutMs), } ); const answer = Buffer.from(await upstream.arrayBuffer()); line.status = upstream.status; try { const parsed = JSON.parse(String(answer)); if (typeof parsed?.ok === "boolean") line.ok = parsed.ok; if (typeof parsed?.error === "string") line.error = parsed.error; } catch {} // fetch has already decoded the body, so content-encoding is not // copied; only the headers Hermes' client reads come back. const out = {}; for (const name of ["content-type", "retry-after"]) { const value = upstream.headers.get(name); if (value !== null) out[name] = value; } res.writeHead(upstream.status, out); res.end(answer); } catch (error) { line.error = error?.name === "TimeoutError" ? "upstream_timeout" : "upstream_unavailable"; req.socket.destroy(); } diagnostic(line); }); server.on("upgrade", (req, socket, head) => { socket.on("error", () => socket.destroy()); const target = targetOf(req.url); if ( target?.pathname !== "/link/" || !same(target.searchParams.get("ticket"), ticket) ) { socket.once("finish", () => socket.destroy()); socket.end("HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\n\r\n"); return; } wss.handleUpgrade(req, socket, head, (ws) => { // Newest connection wins; Hermes closes its old socket first anyway. current?.close(1000); current = ws; connectedAt = now(); diagnostic({ kind: "sellable_slack_stand_in_socket", event: "open" }); ws.on("error", () => {}); // Acks resolve their waiter; ack payloads are dropped (the inbox already // answered Slack). Nothing here may throw. ws.on("message", (data, isBinary) => { if (isBinary) return; let frame; try { frame = JSON.parse(String(data)); } catch { return; } waiters.get(frame?.envelope_id)?.settle("delivered"); }); ws.on("close", () => { if (current === ws) current = null; for (const waiter of [...waiters.values()]) if (waiter.ws === ws) waiter.settle("released"); diagnostic({ kind: "sellable_slack_stand_in_socket", event: "close" }); }); ws.send( JSON.stringify({ type: "hello", num_connections: 1, debug_info: { host: "sellable-local-stand-in" }, connection_info: { app_id: appId }, }) ); }); }); server.listen(0, "127.0.0.1"); await once(server, "listening"); port = server.address().port; return { origin: `http://127.0.0.1:${port}`, port, // delivered on Hermes' ack, no_ack past the deadline (Bolt never acks an // envelope no listener matches), released when no socket takes it. async deliver(envelope) { const frame = toSocketModeFrame(envelope); const ws = current; const outcome = await new Promise((resolve) => { if (ws?.readyState !== OPEN) return resolve("released"); const waiter = { ws, settle(result) { clearTimeout(timer); if (waiters.get(envelope.id) === waiter) waiters.delete(envelope.id); resolve(result); }, }; const timer = setTimeout(() => waiter.settle("no_ack"), ackTimeoutMs); waiters.set(envelope.id, waiter); ws.send(frame); }); if (outcome !== "delivered") diagnostic({ kind: "sellable_slack_stand_in_delivery", envelopeId: envelope.id, outcome, }); return outcome; }, status() { const hermesConnected = current?.readyState === OPEN; return { hermesConnected, warm: hermesConnected && now() - connectedAt >= warmGraceMs, }; }, close: () => new Promise((resolve) => { for (const ws of wss.clients) ws.terminate(); server.close(resolve); server.closeAllConnections(); }), }; }