{"version":3,"file":"smoke.d.ts","sourceRoot":"","sources":["../../../../src/core/extensions/plugins/smoke.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAOH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAE9C,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAC;AAoBtD,MAAM,WAAW,YAAY;IAC5B,0EAA0E;IAC1E,IAAI,CAAC,EAAE,OAAO,CAAC;CACf;AAoID,qGAAqG;AACrG,wBAAsB,YAAY,CAAC,MAAM,EAAE,gBAAgB,EAAE,IAAI,GAAE,YAAiB,GAAG,OAAO,CAAC,WAAW,EAAE,CAAC,CAsE5G","sourcesContent":["/**\n * G3 — behavioral smoke for a plugin's executable capabilities.\n *\n * G1 and G2 read files. G3 is the first gate that finds out whether the thing\n * actually runs, which is the most common real failure: a plugin that installs\n * cleanly and breaks the session at the next tool call.\n *\n * **What this is and is not.** Hooks run with `cwd`, `HOME`, `TMPDIR` and the\n * plugin data dir redirected into a throwaway directory, and with a hard\n * timeout. That reduces blast radius; it is *not* containment. Without OS-level\n * sandboxing a shell command can still write wherever it likes, and claiming\n * otherwise in a confirmation prompt would be worse than saying nothing — the\n * whole point of showing gate results to a human is that they are true.\n *\n * **Why it runs before the human confirms.** It does execute not-yet-approved\n * code, which is a real cost. Against it: the code was authored in this session\n * from the user's own request rather than fetched from anywhere, G2 has already\n * screened it for destructive shapes, and absent the smoke test the very same\n * command runs moments later anyway — unscreened, unredirected, and in the real\n * working directory. Running it once under redirection to find out whether it\n * even works is the smaller risk. G3 is therefore authored-only: it is never\n * applied to a marketplace plugin, where the code is someone else's and\n * executing it pre-consent would not be defensible.\n */\n\nimport { spawn } from \"node:child_process\";\nimport { mkdtempSync, rmSync } from \"node:fs\";\nimport { tmpdir } from \"node:os\";\nimport * as path from \"node:path\";\nimport * as readline from \"node:readline\";\nimport type { GateFinding } from \"./gates.js\";\nimport { pluginVariables } from \"./index.js\";\nimport type { NormalizedPlugin } from \"./manifest.js\";\n\n/** Per-capability budget. Long enough for a real server to boot, short enough not to stall a turn. */\nconst SMOKE_TIMEOUT_MS = 10_000;\n\n/** Env override, so tests can exercise the timeout path without waiting for it. */\nfunction smokeTimeoutMs(): number {\n\tconst raw = Number(process.env.HOOCODE_PLUGIN_SMOKE_TIMEOUT_MS);\n\treturn Number.isFinite(raw) && raw > 0 ? raw : SMOKE_TIMEOUT_MS;\n}\n\n/** Synthetic payloads, one per hook event, matching what the bridge really sends. */\nconst SYNTHETIC_PAYLOADS: Record<string, unknown> = {\n\tPreToolUse: { hook_event_name: \"PreToolUse\", tool_name: \"read\", tool_input: { file_path: \"smoke.txt\" } },\n\tPostToolUse: { hook_event_name: \"PostToolUse\", tool_name: \"read\", tool_input: {}, tool_response: \"\" },\n\tUserPromptSubmit: { hook_event_name: \"UserPromptSubmit\", prompt: \"smoke test\" },\n\tSessionStart: { hook_event_name: \"SessionStart\", source: \"startup\" },\n\tStop: { hook_event_name: \"Stop\" },\n};\n\nexport interface SmokeOptions {\n\t/** Skip the whole gate (no UI to report into, or an explicit opt-out). */\n\tskip?: boolean;\n}\n\n/** Environment for a smoke run: the plugin's own variables, redirected at a scratch dir. */\nfunction smokeEnv(plugin: NormalizedPlugin, sandbox: string): NodeJS.ProcessEnv {\n\treturn {\n\t\t...process.env,\n\t\t...pluginVariables(plugin.root, path.join(sandbox, \"data\")),\n\t\tHOME: sandbox,\n\t\tTMPDIR: sandbox,\n\t};\n}\n\nfunction runOnce(\n\tcommand: string,\n\tpayload: unknown,\n\tcwd: string,\n\tenv: NodeJS.ProcessEnv,\n): Promise<{ code: number | null; timedOut: boolean; output: string; spawnError?: string }> {\n\treturn new Promise((resolve) => {\n\t\tlet settled = false;\n\t\tlet output = \"\";\n\t\tconst child = spawn(command, { shell: true, cwd, env });\n\t\tconst timer = setTimeout(() => {\n\t\t\tif (settled) return;\n\t\t\tsettled = true;\n\t\t\tchild.kill(\"SIGKILL\");\n\t\t\tresolve({ code: null, timedOut: true, output });\n\t\t}, smokeTimeoutMs());\n\t\ttimer.unref?.();\n\n\t\tchild.stdout?.on(\"data\", (d) => {\n\t\t\toutput += d.toString().slice(0, 2000);\n\t\t});\n\t\tchild.stderr?.on(\"data\", (d) => {\n\t\t\toutput += d.toString().slice(0, 2000);\n\t\t});\n\t\tchild.on(\"error\", (e) => {\n\t\t\tif (settled) return;\n\t\t\tsettled = true;\n\t\t\tclearTimeout(timer);\n\t\t\tresolve({ code: null, timedOut: false, output, spawnError: String(e) });\n\t\t});\n\t\tchild.on(\"close\", (code) => {\n\t\t\tif (settled) return;\n\t\t\tsettled = true;\n\t\t\tclearTimeout(timer);\n\t\t\tresolve({ code, timedOut: false, output });\n\t\t});\n\n\t\t// A command that ignores stdin (`exit 0`) closes the pipe before we write,\n\t\t// and the resulting EPIPE surfaces asynchronously — a try/catch around the\n\t\t// write does not see it. Swallow it on the stream instead; not reading the\n\t\t// payload is a legitimate thing for a hook to do.\n\t\tchild.stdin?.on(\"error\", () => {});\n\t\tchild.stdin?.end(`${JSON.stringify(payload)}\\n`);\n\t});\n}\n\n/**\n * Complete an MCP handshake against a candidate server, then kill it.\n *\n * Hand-rolled rather than reusing `connectMcpServer`: that registers the\n * connection in a module-level map and terminates any existing entry with the\n * same name, so smoke-testing a draft server would tear down a live one the\n * session is using.\n */\nfunction probeMcpServer(\n\tcommand: string,\n\targs: string[],\n\tenv: NodeJS.ProcessEnv,\n\tcwd: string,\n): Promise<{ ok: boolean; detail: string; startFailed?: boolean }> {\n\treturn new Promise((resolve) => {\n\t\tlet settled = false;\n\t\tlet stderr = \"\";\n\t\tconst child = spawn(command, args, { cwd, env, stdio: [\"pipe\", \"pipe\", \"pipe\"] });\n\t\tconst finish = (ok: boolean, detail: string, startFailed = false) => {\n\t\t\tif (settled) return;\n\t\t\tsettled = true;\n\t\t\tclearTimeout(timer);\n\t\t\tchild.kill(\"SIGKILL\");\n\t\t\tresolve({ ok, detail, startFailed });\n\t\t};\n\t\tconst timer = setTimeout(() => finish(false, \"no response to initialize in time\"), smokeTimeoutMs());\n\t\ttimer.unref?.();\n\n\t\tchild.on(\"error\", (e) => finish(false, `could not start: ${e}`, true));\n\t\tchild.on(\"close\", (code) =>\n\t\t\tfinish(false, `exited (${code}) before completing the handshake${stderr ? `: ${stderr.slice(0, 300)}` : \"\"}`),\n\t\t);\n\t\tchild.stderr?.on(\"data\", (d) => {\n\t\t\tstderr += d.toString().slice(0, 1000);\n\t\t});\n\n\t\tchild.stdin?.on(\"error\", () => {});\n\t\tconst rl = readline.createInterface({ input: child.stdout! });\n\t\trl.on(\"line\", (line) => {\n\t\t\tlet msg: { id?: number; result?: unknown; error?: { message?: string } };\n\t\t\ttry {\n\t\t\t\tmsg = JSON.parse(line);\n\t\t\t} catch {\n\t\t\t\treturn; // servers sometimes log plain text on stdout\n\t\t\t}\n\t\t\tif (msg.error) return finish(false, `server error: ${msg.error.message ?? \"unknown\"}`);\n\t\t\tif (msg.id === 1) {\n\t\t\t\t// Per the spec the client acknowledges initialize before anything else;\n\t\t\t\t// strict servers refuse tools/list without it.\n\t\t\t\tchild.stdin?.write(`${JSON.stringify({ jsonrpc: \"2.0\", method: \"notifications/initialized\" })}\\n`);\n\t\t\t\tchild.stdin?.write(`${JSON.stringify({ jsonrpc: \"2.0\", id: 2, method: \"tools/list\", params: {} })}\\n`);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (msg.id === 2) {\n\t\t\t\tconst tools = (msg.result as { tools?: unknown[] } | undefined)?.tools ?? [];\n\t\t\t\tfinish(true, `handshake ok, ${tools.length} tool(s)`);\n\t\t\t}\n\t\t});\n\n\t\tchild.stdin?.write(\n\t\t\t`${JSON.stringify({\n\t\t\t\tjsonrpc: \"2.0\",\n\t\t\t\tid: 1,\n\t\t\t\tmethod: \"initialize\",\n\t\t\t\tparams: {\n\t\t\t\t\tprotocolVersion: \"2024-11-05\",\n\t\t\t\t\tcapabilities: { tools: {} },\n\t\t\t\t\tclientInfo: { name: \"hoocode-smoke\", version: \"1.0.0\" },\n\t\t\t\t},\n\t\t\t})}\\n`,\n\t\t);\n\t});\n}\n\n/** Run G3 over a plugin's executable capabilities. Returns findings; empty means nothing to test. */\nexport async function runSmokeGate(plugin: NormalizedPlugin, opts: SmokeOptions = {}): Promise<GateFinding[]> {\n\tif (opts.skip) return [];\n\tconst findings: GateFinding[] = [];\n\tconst hasExecutable = !!plugin.hooks || !!plugin.mcpServers;\n\tif (!hasExecutable) return findings;\n\n\tconst sandbox = mkdtempSync(path.join(tmpdir(), \"hoo-smoke-\"));\n\tconst env = smokeEnv(plugin, sandbox);\n\ttry {\n\t\tfor (const [event, groups] of Object.entries(plugin.hooks ?? {})) {\n\t\t\tconst payload = SYNTHETIC_PAYLOADS[event] ?? { hook_event_name: event };\n\t\t\tfor (const group of groups) {\n\t\t\t\tfor (const cmd of group.hooks) {\n\t\t\t\t\tconst res = await runOnce(cmd.command, payload, sandbox, env);\n\t\t\t\t\tconst label = `hook ${event}`;\n\t\t\t\t\tif (res.spawnError) {\n\t\t\t\t\t\t// Consistent with G2: a missing binary is a documented-prerequisite\n\t\t\t\t\t\t// problem, not a broken plugin. Erroring here would contradict the\n\t\t\t\t\t\t// warning G2 already issues for the very same condition.\n\t\t\t\t\t\tfindings.push({\n\t\t\t\t\t\t\tgate: \"G3\",\n\t\t\t\t\t\t\tseverity: \"warning\",\n\t\t\t\t\t\t\tmessage: `${label} could not start: ${res.spawnError}`,\n\t\t\t\t\t\t});\n\t\t\t\t\t} else if (res.timedOut) {\n\t\t\t\t\t\tfindings.push({\n\t\t\t\t\t\t\tgate: \"G3\",\n\t\t\t\t\t\t\tseverity: \"error\",\n\t\t\t\t\t\t\tmessage: `${label} did not finish in time — it would stall every matching tool call.`,\n\t\t\t\t\t\t});\n\t\t\t\t\t} else if (res.code === 0 || (event === \"PreToolUse\" && res.code === 2)) {\n\t\t\t\t\t\t// Exit 2 from PreToolUse is the documented \"block\" decision, not a\n\t\t\t\t\t\t// failure: the hook ran and made a call.\n\t\t\t\t\t\tfindings.push({ gate: \"G3\", severity: \"info\", message: `${label} ran cleanly (exit ${res.code}).` });\n\t\t\t\t\t} else {\n\t\t\t\t\t\t// A non-zero exit may just mean the synthetic payload is not one this\n\t\t\t\t\t\t// hook handles, so it is reported rather than fatal.\n\t\t\t\t\t\tfindings.push({\n\t\t\t\t\t\t\tgate: \"G3\",\n\t\t\t\t\t\t\tseverity: \"warning\",\n\t\t\t\t\t\t\tmessage: `${label} exited ${res.code} on a synthetic ${event} payload${res.output.trim() ? `: ${res.output.trim().slice(0, 200)}` : \"\"}`,\n\t\t\t\t\t\t});\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tfor (const [name, raw] of Object.entries(plugin.mcpServers ?? {})) {\n\t\t\tconst cfg = raw as { command?: unknown; args?: unknown; env?: unknown };\n\t\t\tif (typeof cfg.command !== \"string\") continue; // remote transports are not spawned\n\t\t\tconst res = await probeMcpServer(\n\t\t\t\tcfg.command,\n\t\t\t\tArray.isArray(cfg.args) ? cfg.args.map(String) : [],\n\t\t\t\t{ ...env, ...(cfg.env && typeof cfg.env === \"object\" ? (cfg.env as NodeJS.ProcessEnv) : {}) },\n\t\t\t\tplugin.root,\n\t\t\t);\n\t\t\tfindings.push({\n\t\t\t\tgate: \"G3\",\n\t\t\t\t// A server that never starts is the missing-binary case again — warn.\n\t\t\t\t// One that starts and then fails the handshake is genuinely broken and\n\t\t\t\t// would take the session down at connect time.\n\t\t\t\tseverity: res.ok ? \"info\" : res.startFailed ? \"warning\" : \"error\",\n\t\t\t\tmessage: `mcp server \"${name}\": ${res.detail}`,\n\t\t\t});\n\t\t}\n\t} finally {\n\t\trmSync(sandbox, { recursive: true, force: true });\n\t}\n\n\treturn findings;\n}\n"]}