diff --git a/sdk/plugin-tinyplace/adapters/README.md b/sdk/plugin-tinyplace/adapters/README.md index 61d00fbf..87d2b1e1 100644 --- a/sdk/plugin-tinyplace/adapters/README.md +++ b/sdk/plugin-tinyplace/adapters/README.md @@ -35,7 +35,7 @@ field for your harness. | `projectDir()` | `() => string` | Stable per-project scope key for assignment persistence when there's no session id. `""` = fall back to global scope. | | `serverInstructions` | string | MCP `instructions`. **Must contain the word `UNTRUSTED`** — the prompt-injection guard telling the agent inbound DMs are data, not instructions. | | `inbound` | `{ push, pull, foregroundInject }` | How new DMs reach a live session. `push` is `false` **or** `{ capability, method }` (server→client channel). `pull`/`foregroundInject` are booleans. **At least one delivery path must be truthy**, else DMs vanish. | -| `responder` | `{ command, defaultModel, buildArgs }` | Headless autoresponder. `buildArgs(prompt, model, pluginRoot)` returns the CLI argv and **must thread both `prompt` and `model`**. | +| `responder` | `{ command, defaultModel, buildArgs, prepare?, streamComplete? }` | Headless autoresponder. `buildArgs(prompt, model, pluginRoot, ctx?)` returns the CLI argv and **must thread both `prompt` and `model`**; keep it **side-effect-free** (unit-tested with no env). Optional `prepare(ctx)` runs **once per batch** in `respond-batch.mjs` for setup that can't live in `buildArgs` (e.g. Cursor builds a throwaway send-only `--workspace`); its returned fields are merged into the `ctx` passed to `buildArgs`. Optional `streamComplete: true` makes the spawner pipe stdout and finish on the CLI's terminal `{"type":"result"}` NDJSON event (killing a CLI that hangs after replying) instead of waiting for exit. | | `install` | `{ kind }` | Launcher install strategy tag (e.g. `plugin-dir`, `codex-home`). | | `launch` | `{ displayHarness, binary, notFoundHint?, prepare }` | Launcher recipe. `prepare(ctx)` returns `{ command, args, env }`; see below. | diff --git a/sdk/plugin-tinyplace/adapters/cursor.mjs b/sdk/plugin-tinyplace/adapters/cursor.mjs index a538dc78..29a6bbc3 100644 --- a/sdk/plugin-tinyplace/adapters/cursor.mjs +++ b/sdk/plugin-tinyplace/adapters/cursor.mjs @@ -52,30 +52,36 @@ export const cursorAdapter = { responder: { command: "cursor-agent", defaultModel: "auto", - // Feeds ATTACKER-CONTROLLED DM text into headless `cursor-agent -p`, so it runs - // least-privilege (no `--force`/`--yolo`, which auto-allow writes + shell): - // • `--sandbox enabled` — OS-level sandbox so a prompt-injected DM can't write - // files or run shell through the Cursor agent. - // • `--trust` — grant workspace trust so headless mode can START (cursor-agent - // refuses an untrusted dir) WITHOUT `--force`'s run-everything permissiveness. - // • `--approve-mcps` — auto-approve the tinyplace MCP server; auto_reply (the - // only intended side-effecting path) runs in a SEPARATE process outside the - // sandbox. - // • `--output-format text` — clean reply text. - // • `--` — terminate option parsing before the untrusted DM (no flag smuggling; - // verified: cursor-agent errors on a dash-leading prompt without it). - // - // ⚠️ [VERIFY] Whether cursor-agent can INVOKE MCP tools under `--sandbox enabled` - // headlessly is NOT yet live-confirmed — validation was blocked by cursor-agent - // rate-limiting during testing. If a clean-env retest shows the sandbox blocks - // the MCP tool call, fall back (e.g. drop `--sandbox`, keep the throwaway - // isolated workspace + the spawner's timeout guard as the bound) and reopen the - // security trade-off. The first clean E2E confirmed the adapter itself works. - // NOTE: cursor-agent print-mode can hang after replying (verified) — the shared - // responder spawner (hooks/respond-batch.mjs) bounds every turn with a - // timeout + kill, so a hang fails the message instead of wedging the pool. - buildArgs(prompt, model /* pluginRoot unused: MCP comes from the workspace mcp.json */) { - return ["-p", "--sandbox", "enabled", "--trust", "--approve-mcps", "--output-format", "text", "--model", model, "--", prompt]; + // The reply is delivered by the agent CALLING the tinyplace `auto_reply` MCP + // tool (the spawner ignores stdout — success is turn completion, not parsed + // text). cursor-agent can only invoke MCP tools headlessly under `--yolo`, so + // that flag is REQUIRED here — but it also auto-allows shell + file writes, and + // the prompt carries ATTACKER-CONTROLLED DM text. We bound the blast radius by + // running in a THROWAWAY isolated `--workspace` (below): a prompt-injected DM's + // writes/shell land in that per-wallet scratch dir, never the user's files. + // • `--yolo` — auto-approve MCP tool calls (needed for `auto_reply`); the + // `--workspace` isolation + the spawner's timeout/kill are the guardrails. + // • `--workspace ` — throwaway send-only workspace carrying the tinyplace + // `.cursor/mcp.json` (SEND_ONLY, NO_AUTORESPOND, daemon off). Prepared once + // per batch by `prepare()`. + // • `--output-format stream-json` — emits a terminal `result` event; the + // spawner watches for it and kills the process, so cursor-agent's known + // print-mode HANG-after-reply ends promptly instead of waiting out the + // 180 s timeout (which would falsely fail an already-sent reply). + // • `--` — terminate option parsing before the untrusted DM (no flag + // smuggling; verified: cursor-agent errors on a dash-leading prompt). + streamComplete: true, + // Called ONCE per batch by hooks/respond-batch.mjs (not in buildArgs, which must + // stay side-effect-free for the unit test). Builds the isolated responder + // workspace and returns fields merged into the ctx passed to buildArgs. + prepare(ctx) { + return { workspace: ensureResponderWorkspace(ctx) }; + }, + buildArgs(prompt, model, _pluginRoot, ctx) { + const args = ["-p", "--yolo", "--output-format", "stream-json"]; + if (ctx?.workspace) args.push("--workspace", ctx.workspace); + args.push("--model", model, "--", prompt); + return args; }, }, @@ -90,7 +96,8 @@ export const cursorAdapter = { launch: { displayHarness: "Cursor", binary: "cursor-agent", - notFoundHint: "Is the Cursor Agent CLI installed and on your PATH? (curl https://cursor.com/install | bash)", + notFoundHint: + "Is the Cursor Agent CLI installed and on your PATH? (curl https://cursor.com/install | bash)", // ctx: { pluginDir, dataDir, apiUrl, walletName, forwardedArgs } prepare(ctx) { const iso = ensureIsolatedWorkspace(ctx); @@ -107,33 +114,91 @@ export const cursorAdapter = { }, }; -// Build (idempotently) an isolated Cursor workspace for a wallet and return its -// path. Layout: /cursor-home//.cursor/mcp.json -function ensureIsolatedWorkspace({ pluginDir, dataDir, apiUrl, walletName }) { - const iso = join(dataDir, "cursor-home", encodeURIComponent(walletName)); - const cursorDir = join(iso, ".cursor"); +// Write a `.cursor/mcp.json` under `root` wiring the tinyplace stdio MCP server +// with `env`. cursor-agent SANITIZES the MCP child env, so this `env` block is the +// ONLY channel that reaches the server — it must carry identity + config + the +// TINYPLACE_HARNESS=cursor sentinel that makes detectHarness pick this adapter. +function writeCursorMcpConfig({ root, pluginDir, env }) { + const cursorDir = join(root, ".cursor"); mkdirSync(cursorDir, { recursive: true }); - - const serverScript = join(pluginDir, "mcp", "server.mjs"); - // The MCP `env` block is the ONLY channel that reaches the server (cursor-agent - // sanitizes the child env), so it must carry identity + config + the harness - // sentinel. TINYPLACE_HARNESS=cursor makes detectHarness pick this adapter, and - // the durable daemon is on so inbound survives MCP restarts. const config = { mcpServers: { tinyplace: { command: "node", - args: [serverScript], - env: { - TINYPLACE_HARNESS: "cursor", - TINYPLACE_ACTIVE_WALLET: walletName, - TINYPLACE_CURSOR_HOME: dataDir, - TINYPLACE_API_URL: apiUrl, - TINYPLACE_SESSION_DAEMON: "on", - }, + args: [join(pluginDir, "mcp", "server.mjs")], + env, }, }, }; - writeFileSync(join(cursorDir, "mcp.json"), JSON.stringify(config, null, 2) + "\n", { mode: 0o600 }); + writeFileSync( + join(cursorDir, "mcp.json"), + JSON.stringify(config, null, 2) + "\n", + { mode: 0o600 }, + ); + return root; +} + +// cursor-agent has no `--system-prompt`, so standing guidance can't ride the +// command line. Drop the tiny.place security posture into an always-applied +// Cursor rule (`.cursor/rules/*.mdc` with `alwaysApply: true`) so the interactive +// agent sees the UNTRUSTED-data handling even when the MCP serverInstructions +// aren't surfaced. (multica delivers instructions via `.cursor/skills/`, but those +// are on-demand; a standing security rule belongs in alwaysApply rules.) +// [VERIFY] headless honoring of alwaysApply rules across cursor-agent versions. +function writeCursorInstructionsRule(root, instructions) { + const rulesDir = join(root, ".cursor", "rules"); + mkdirSync(rulesDir, { recursive: true }); + const body = `---\ndescription: tiny.place messaging safety\nalwaysApply: true\n---\n\n${instructions}\n`; + writeFileSync(join(rulesDir, "tinyplace.mdc"), body, { mode: 0o600 }); +} + +// Build (idempotently) an isolated Cursor workspace for a wallet and return its +// path. Layout: /cursor-home//.cursor/{mcp.json,rules/tinyplace.mdc} +// The durable daemon is ON so inbound survives MCP restarts (interactive path). +function ensureIsolatedWorkspace({ pluginDir, dataDir, apiUrl, walletName }) { + const iso = join(dataDir, "cursor-home", encodeURIComponent(walletName)); + writeCursorMcpConfig({ + root: iso, + pluginDir, + env: { + TINYPLACE_HARNESS: "cursor", + TINYPLACE_ACTIVE_WALLET: walletName, + TINYPLACE_CURSOR_HOME: dataDir, + TINYPLACE_API_URL: apiUrl, + TINYPLACE_SESSION_DAEMON: "on", + }, + }); + writeCursorInstructionsRule(iso, cursorAdapter.serverInstructions); return iso; } + +// Build (idempotently) the THROWAWAY send-only workspace the auto-responder runs +// in under `--yolo`. Layout: /responder-home//.cursor/mcp.json. +// Its MCP env pins SEND_ONLY + NO_AUTORESPOND and daemon OFF so the responder can +// only call `auto_reply` — it neither drains the shared mailbox nor recurses into +// the dispatcher. `--yolo`'s file writes/shell are confined to this scratch dir. +// Falls back to dataDirDefault when TINYPLACE_CURSOR_HOME isn't forwarded. +function ensureResponderWorkspace(ctx = {}) { + const dataDir = + ctx.dataDir || + process.env.TINYPLACE_CURSOR_HOME || + cursorAdapter.dataDirDefault; + const walletName = + ctx.wallet || process.env.TINYPLACE_ACTIVE_WALLET || "agent"; + const pluginDir = ctx.pluginDir; + const apiUrl = ctx.apiUrl || process.env.TINYPLACE_API_URL || ""; + const iso = join(dataDir, "responder-home", encodeURIComponent(walletName)); + return writeCursorMcpConfig({ + root: iso, + pluginDir, + env: { + TINYPLACE_HARNESS: "cursor", + TINYPLACE_ACTIVE_WALLET: walletName, + TINYPLACE_CURSOR_HOME: dataDir, + TINYPLACE_API_URL: apiUrl, + TINYPLACE_SEND_ONLY: "1", + TINYPLACE_NO_AUTORESPOND: "1", + TINYPLACE_SESSION_DAEMON: "off", + }, + }); +} diff --git a/sdk/plugin-tinyplace/harness-test.mjs b/sdk/plugin-tinyplace/harness-test.mjs index 079d125f..fa1431ba 100644 --- a/sdk/plugin-tinyplace/harness-test.mjs +++ b/sdk/plugin-tinyplace/harness-test.mjs @@ -1,32 +1,77 @@ #!/usr/bin/env node // Proves the "one plugin, any harness" pivot: from an env bag alone, the package // detects the harness and hands back the correct adapter, fully wired. -import { detectHarness, resolveAdapter, harnessDataDir } from "./mcp/harness.mjs"; +import { + detectHarness, + resolveAdapter, + harnessDataDir, +} from "./mcp/harness.mjs"; let failed = false; -const check = (n, c, x) => { console.log(`${c ? "PASS" : "FAIL"} ${n}${x ? ` — ${x}` : ""}`); if (!c) failed = true; }; +const check = (n, c, x) => { + console.log(`${c ? "PASS" : "FAIL"} ${n}${x ? ` — ${x}` : ""}`); + if (!c) failed = true; +}; // ── detection from env signals ──────────────────────────────────────────────── check("codex via CODEX_HOME", detectHarness({ CODEX_HOME: "/x" }) === "codex"); -check("codex via CODEX_SESSION_ID", detectHarness({ CODEX_SESSION_ID: "s" }) === "codex"); -check("codex via CODEX_THREAD_ID", detectHarness({ CODEX_THREAD_ID: "t" }) === "codex"); -check("claude via CLAUDE_PLUGIN_ROOT", detectHarness({ CLAUDE_PLUGIN_ROOT: "/p" }) === "claude"); -check("claude via CLAUDE_CODE_SESSION_ID", detectHarness({ CLAUDE_CODE_SESSION_ID: "s" }) === "claude"); +check( + "codex via CODEX_SESSION_ID", + detectHarness({ CODEX_SESSION_ID: "s" }) === "codex", +); +check( + "codex via CODEX_THREAD_ID", + detectHarness({ CODEX_THREAD_ID: "t" }) === "codex", +); +check( + "claude via CLAUDE_PLUGIN_ROOT", + detectHarness({ CLAUDE_PLUGIN_ROOT: "/p" }) === "claude", +); +check( + "claude via CLAUDE_CODE_SESSION_ID", + detectHarness({ CLAUDE_CODE_SESSION_ID: "s" }) === "claude", +); // Cursor exposes no ambient signal to the MCP subprocess (verified), so its // detection rides the self-provisioned TINYPLACE_CURSOR_HOME the install sets. -check("cursor via TINYPLACE_CURSOR_HOME", detectHarness({ TINYPLACE_CURSOR_HOME: "/c" }) === "cursor"); +check( + "cursor via TINYPLACE_CURSOR_HOME", + detectHarness({ TINYPLACE_CURSOR_HOME: "/c" }) === "cursor", +); // Windsurf (now Devin Desktop) likewise leaks no MCP-subprocess signal; detection // rides the self-provisioned TINYPLACE_WINDSURF_HOME the install sets. -check("windsurf via TINYPLACE_WINDSURF_HOME", detectHarness({ TINYPLACE_WINDSURF_HOME: "/w" }) === "windsurf"); +check( + "windsurf via TINYPLACE_WINDSURF_HOME", + detectHarness({ TINYPLACE_WINDSURF_HOME: "/w" }) === "windsurf", +); check("default = claude (no signals)", detectHarness({}) === "claude"); // ── explicit override wins over signals ─────────────────────────────────────── -check("override forces codex", detectHarness({ TINYPLACE_HARNESS: "codex", CLAUDE_PLUGIN_ROOT: "/p" }) === "codex"); -check("override forces claude", detectHarness({ TINYPLACE_HARNESS: "claude", CODEX_HOME: "/x" }) === "claude"); -check("override forces cursor", detectHarness({ TINYPLACE_HARNESS: "cursor", CODEX_HOME: "/x" }) === "cursor"); -check("override forces windsurf", detectHarness({ TINYPLACE_HARNESS: "windsurf", CODEX_HOME: "/x" }) === "windsurf"); -check("bad override ignored → signal", detectHarness({ TINYPLACE_HARNESS: "nope", CODEX_HOME: "/x" }) === "codex"); -check("override case-insensitive", detectHarness({ TINYPLACE_HARNESS: "CODEX" }) === "codex"); +check( + "override forces codex", + detectHarness({ TINYPLACE_HARNESS: "codex", CLAUDE_PLUGIN_ROOT: "/p" }) === + "codex", +); +check( + "override forces claude", + detectHarness({ TINYPLACE_HARNESS: "claude", CODEX_HOME: "/x" }) === "claude", +); +check( + "override forces cursor", + detectHarness({ TINYPLACE_HARNESS: "cursor", CODEX_HOME: "/x" }) === "cursor", +); +check( + "override forces windsurf", + detectHarness({ TINYPLACE_HARNESS: "windsurf", CODEX_HOME: "/x" }) === + "windsurf", +); +check( + "bad override ignored → signal", + detectHarness({ TINYPLACE_HARNESS: "nope", CODEX_HOME: "/x" }) === "codex", +); +check( + "override case-insensitive", + detectHarness({ TINYPLACE_HARNESS: "CODEX" }) === "codex", +); // ── adapter wiring: codex ───────────────────────────────────────────────────── { @@ -34,9 +79,19 @@ check("override case-insensitive", detectHarness({ TINYPLACE_HARNESS: "CODEX" }) check("codex adapter provider", a.provider === "codex"); check("codex label prefix", a.sessionLabelPrefix === "codex"); check("codex dataDirEnv", a.dataDirEnv === "TINYPLACE_CODEX_HOME"); - check("codex pull-only (no push)", a.inbound.push === false && a.inbound.pull === true); - check("codex has foreground inject slot", a.inbound.foregroundInject === true); - check("codex responder = codex exec", a.responder.command === "codex" && a.responder.buildArgs("P", "M").includes("exec")); + check( + "codex pull-only (no push)", + a.inbound.push === false && a.inbound.pull === true, + ); + check( + "codex has foreground inject slot", + a.inbound.foregroundInject === true, + ); + check( + "codex responder = codex exec", + a.responder.command === "codex" && + a.responder.buildArgs("P", "M").includes("exec"), + ); check("codex install kind", a.install.kind === "codex-home"); } @@ -45,10 +100,20 @@ check("override case-insensitive", detectHarness({ TINYPLACE_HARNESS: "CODEX" }) const a = resolveAdapter({ CLAUDE_PLUGIN_ROOT: "/p" }); check("claude adapter provider", a.provider === "claude"); check("claude label prefix", a.sessionLabelPrefix === "claude"); - check("claude has push capability", a.inbound.push && a.inbound.push.capability === "claude/channel"); + check( + "claude has push capability", + a.inbound.push && a.inbound.push.capability === "claude/channel", + ); check("claude not pull", a.inbound.pull === false); - check("claude has foreground inject slot", a.inbound.foregroundInject === true); - check("claude responder = claude -p", a.responder.command === "claude" && a.responder.buildArgs("P", "M", "/root").includes("-p")); + check( + "claude has foreground inject slot", + a.inbound.foregroundInject === true, + ); + check( + "claude responder = claude -p", + a.responder.command === "claude" && + a.responder.buildArgs("P", "M", "/root").includes("-p"), + ); check("claude install kind", a.install.kind === "plugin-dir"); } @@ -58,8 +123,44 @@ check("override case-insensitive", detectHarness({ TINYPLACE_HARNESS: "CODEX" }) check("cursor adapter provider", a.provider === "cursor"); check("cursor label prefix", a.sessionLabelPrefix === "cursor"); check("cursor dataDirEnv", a.dataDirEnv === "TINYPLACE_CURSOR_HOME"); - check("cursor pull-only (no push, no inject)", a.inbound.push === false && a.inbound.pull === true && a.inbound.foregroundInject === false); - check("cursor responder = cursor-agent -p", a.responder.command === "cursor-agent" && a.responder.buildArgs("P", "M").includes("-p")); + check( + "cursor pull-only (no push, no inject)", + a.inbound.push === false && + a.inbound.pull === true && + a.inbound.foregroundInject === false, + ); + check( + "cursor responder = cursor-agent -p", + a.responder.command === "cursor-agent" && + a.responder.buildArgs("P", "M").includes("-p"), + ); + // Responder needs --yolo (only way cursor-agent invokes MCP tools headlessly) and + // stream-json (terminal `result` event lets the spawner kill the known hang). + { + const args = a.responder.buildArgs("P", "M"); + check("cursor responder uses --yolo", args.includes("--yolo")); + check( + "cursor responder streams json", + args.includes("stream-json") && a.responder.streamComplete === true, + ); + check( + "cursor responder terminates opts with --", + args.at(-2) === "--" && args.at(-1) === "P", + ); + // buildArgs stays side-effect-free (no --workspace) until prepare() supplies a ctx. + check( + "cursor responder buildArgs is pure", + !args.includes("--workspace") && + typeof a.responder.prepare === "function", + ); + const withWs = a.responder.buildArgs("P", "M", "/root", { + workspace: "/iso/ws", + }); + check( + "cursor responder honors prepared --workspace", + withWs.includes("--workspace") && withWs.includes("/iso/ws"), + ); + } check("cursor install kind", a.install.kind === "mcp-json"); } @@ -69,8 +170,17 @@ check("override case-insensitive", detectHarness({ TINYPLACE_HARNESS: "CODEX" }) check("windsurf adapter provider", a.provider === "windsurf"); check("windsurf label prefix", a.sessionLabelPrefix === "windsurf"); check("windsurf dataDirEnv", a.dataDirEnv === "TINYPLACE_WINDSURF_HOME"); - check("windsurf pull-only (no push, no inject)", a.inbound.push === false && a.inbound.pull === true && a.inbound.foregroundInject === false); - check("windsurf responder = devin -p", a.responder.command === "devin" && a.responder.buildArgs("P", "M").includes("-p")); + check( + "windsurf pull-only (no push, no inject)", + a.inbound.push === false && + a.inbound.pull === true && + a.inbound.foregroundInject === false, + ); + check( + "windsurf responder = devin -p", + a.responder.command === "devin" && + a.responder.buildArgs("P", "M").includes("-p"), + ); check("windsurf install kind", a.install.kind === "mcp-json"); } @@ -80,8 +190,14 @@ check("override case-insensitive", detectHarness({ TINYPLACE_HARNESS: "CODEX" }) const claude = resolveAdapter({ CLAUDE_PLUGIN_ROOT: "/p" }); process.env.CODEX_SESSION_ID = "cx-123"; process.env.CLAUDE_CODE_SESSION_ID = "cl-456"; - check("codex resolves CODEX_SESSION_ID", codex.resolveHarnessSessionId() === "cx-123"); - check("claude resolves CLAUDE_CODE_SESSION_ID", claude.resolveHarnessSessionId() === "cl-456"); + check( + "codex resolves CODEX_SESSION_ID", + codex.resolveHarnessSessionId() === "cx-123", + ); + check( + "claude resolves CLAUDE_CODE_SESSION_ID", + claude.resolveHarnessSessionId() === "cl-456", + ); delete process.env.CODEX_SESSION_ID; delete process.env.CLAUDE_CODE_SESSION_ID; } @@ -90,9 +206,15 @@ check("override case-insensitive", detectHarness({ TINYPLACE_HARNESS: "CODEX" }) { const a = resolveAdapter({ CODEX_HOME: "/x" }); process.env.TINYPLACE_CODEX_HOME = "/tmp/custom-codex"; - check("dataDir honors env override", harnessDataDir(a) === "/tmp/custom-codex"); + check( + "dataDir honors env override", + harnessDataDir(a) === "/tmp/custom-codex", + ); delete process.env.TINYPLACE_CODEX_HOME; - check("dataDir falls back to default", harnessDataDir(a) === a.dataDirDefault); + check( + "dataDir falls back to default", + harnessDataDir(a) === a.dataDirDefault, + ); } console.log(failed ? "\nHARNESS TEST FAILED ❌" : "\nHARNESS TEST PASSED ✅"); diff --git a/sdk/plugin-tinyplace/hooks/respond-batch.mjs b/sdk/plugin-tinyplace/hooks/respond-batch.mjs index 44f99a87..8892e6d4 100644 --- a/sdk/plugin-tinyplace/hooks/respond-batch.mjs +++ b/sdk/plugin-tinyplace/hooks/respond-batch.mjs @@ -8,17 +8,29 @@ // so they neither contend on the shared inbox nor recurse into the dispatcher. // // The command + args are read from the active adapter (ADAPTER.responder), so the -// SAME runner works for every harness. Both responders run SANDBOXED because the -// message text is attacker-controlled — the only side-effecting path is the -// tinyplace `auto_reply` MCP tool (never the shell or filesystem): +// SAME runner works for every harness. The message text is attacker-controlled and +// the only intended side-effecting path is the tinyplace `auto_reply` MCP tool, so +// each responder is confined by the tightest mechanism its CLI offers: // - Codex → `codex exec --sandbox read-only … `; the tinyplace MCP // server is reached via the isolated CODEX_HOME the launcher wrote (forwarded // through process.env). // - Claude → `claude -p --plugin-dir --permission-mode dontAsk // --tools "" --allowedTools mcp__tinyplace__auto_reply …`. +// - Cursor → `cursor-agent -p --yolo --workspace …`; cursor-agent can only +// call MCP tools headlessly under `--yolo`, so the guardrail is a THROWAWAY +// send-only `--workspace` (prepared by ADAPTER.responder.prepare) that confines +// any `--yolo` writes/shell to a scratch dir, plus the timeout below. // TINYPLACE_ACTIVE_WALLET pins the responder's identity either way. import { spawn } from "node:child_process"; -import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmdirSync, rmSync } from "node:fs"; +import { + existsSync, + mkdirSync, + readdirSync, + readFileSync, + renameSync, + rmdirSync, + rmSync, +} from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -28,13 +40,20 @@ const HERE = dirname(fileURLToPath(import.meta.url)); const PLUGIN_DIR = dirname(HERE); // hooks/ -> plugin root (passed to the responder for --plugin-dir on Claude) const ADAPTER = activeAdapter(); const rawPool = Number(process.env.TINYPLACE_AUTORESPOND_POOL); -const POOL = Number.isFinite(rawPool) && rawPool > 0 ? Math.min(Math.floor(rawPool), 16) : 4; -const MODEL = process.env.TINYPLACE_AUTORESPOND_MODEL ?? ADAPTER.responder.defaultModel; +const POOL = + Number.isFinite(rawPool) && rawPool > 0 + ? Math.min(Math.floor(rawPool), 16) + : 4; +const MODEL = + process.env.TINYPLACE_AUTORESPOND_MODEL ?? ADAPTER.responder.defaultModel; // Hard cap on a single responder turn. Some headless CLIs finish their reply but // don't release the process (verified: cursor-agent print-mode can hang), which // would wedge a worker forever. Kill + fail the message past this bound. const rawTimeout = Number(process.env.TINYPLACE_RESPONDER_TIMEOUT_MS); -const RESPONDER_TIMEOUT_MS = Number.isFinite(rawTimeout) && rawTimeout > 0 ? Math.floor(rawTimeout) : 180_000; +const RESPONDER_TIMEOUT_MS = + Number.isFinite(rawTimeout) && rawTimeout > 0 + ? Math.floor(rawTimeout) + : 180_000; const { wallet, batchDir } = JSON.parse(process.argv[2] ?? "{}"); if (!wallet || !batchDir || !existsSync(batchDir)) process.exit(0); @@ -53,6 +72,41 @@ function moveToFailed(file) { } } +// Some responders need per-batch setup (Cursor builds an isolated send-only +// --workspace because cursor-agent sanitizes the MCP child env and runs --yolo). +// prepare() runs ONCE; its result is merged into the ctx handed to buildArgs. +const RESPONDER_CTX = { + wallet, + pluginDir: PLUGIN_DIR, + dataDir: process.env[ADAPTER.dataDirEnv], + apiUrl: process.env.TINYPLACE_API_URL, +}; +if (typeof ADAPTER.responder.prepare === "function") { + try { + Object.assign( + RESPONDER_CTX, + ADAPTER.responder.prepare(RESPONDER_CTX) || {}, + ); + } catch (e) { + // FAIL CLOSED. prepare() builds the responder's security guardrail (e.g. the + // isolated --workspace that confines cursor-agent's --yolo to a scratch dir). + // Spawning with a degraded ctx would run --yolo unconfined against the real + // cwd on attacker-controlled DM text — worse than not replying. Abort the + // batch, preserving the claimed messages in failed/ for a later retry. + console.error( + `[respond-batch] responder.prepare failed for wallet=${wallet} ` + + `(${files.length} claimed) — aborting batch, no responder spawned: ${e?.message ?? e}`, + ); + for (const f of files) moveToFailed(f); + try { + rmdirSync(batchDir); + } catch { + /* not empty / already gone */ + } + process.exit(0); + } +} + // A session label is attacker-controlled free text (from the DM envelope), and // here it is interpolated into a quoted tool-call argument in the LLM prompt — // so validate its shape before use to prevent argument-injection. decodeEnvelope @@ -68,11 +122,17 @@ const SAFE_SESSION_RE = /^[\w:-]{1,32}$/; // of the allowed chars can terminate a double-quoted arg (only " / newline can, and // both stay excluded), so the injection guard is preserved. const UNSAFE_ARG_RE = /[^\w:.+/=@-]+/g; -const safeArg = (v) => String(v ?? "").replace(UNSAFE_ARG_RE, "").slice(0, 128); +const safeArg = (v) => + String(v ?? "") + .replace(UNSAFE_ARG_RE, "") + .slice(0, 128); function buildPrompt(msg) { // If the sender addressed us from a specific session, reply back to that same // session so a multi-session peer correlates it (to_session in the envelope). - const safeSession = typeof msg.fromSession === "string" && SAFE_SESSION_RE.test(msg.fromSession) ? msg.fromSession : null; + const safeSession = + typeof msg.fromSession === "string" && SAFE_SESSION_RE.test(msg.fromSession) + ? msg.fromSession + : null; const toSessionArg = safeSession ? `, to_session="${safeSession}"` : ""; const fromNote = safeSession ? ` (from session ${safeSession})` : ""; const from = safeArg(msg.from); @@ -100,11 +160,20 @@ function respond(file) { resolve(); return; } + // `streamComplete` responders (Cursor) can HANG after emitting their reply, so + // we watch stdout for the terminal `result` event and finish on it instead of + // waiting for a clean exit. Everyone else ignores stdout and settles on exit. + const streamComplete = ADAPTER.responder.streamComplete === true; const child = spawn( ADAPTER.responder.command, - ADAPTER.responder.buildArgs(buildPrompt(msg), MODEL, PLUGIN_DIR), + ADAPTER.responder.buildArgs( + buildPrompt(msg), + MODEL, + PLUGIN_DIR, + RESPONDER_CTX, + ), { - stdio: "ignore", + stdio: streamComplete ? ["ignore", "pipe", "ignore"] : "ignore", env: { ...process.env, TINYPLACE_HARNESS: ADAPTER.provider, // pin the responder's own MCP to this harness @@ -114,7 +183,7 @@ function respond(file) { }, }, ); - // Settle exactly once: whichever of exit / error / timeout fires first wins. + // Settle exactly once: whichever of result / exit / error / timeout fires first. let settled = false; const finish = (cleanup) => { if (settled) return; @@ -127,6 +196,51 @@ function respond(file) { } resolve(); }; + // A completed turn is a success (matches the exit-0 semantics below): the agent + // was told to call auto_reply exactly once, then stop. On the terminal `result` + // event, delete the message and tear the (possibly-hung) process down. + // [VERIFY] cursor-agent's result-event schema across versions. + if (streamComplete && child.stdout) { + let buf = ""; + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + buf += chunk; + let nl; + while ((nl = buf.indexOf("\n")) >= 0) { + const line = buf + .slice(0, nl) + .replace(/^\s*(?:stdout|stderr):\s*/, "") + .trim(); + buf = buf.slice(nl + 1); + if (!line || line[0] !== "{") continue; + let ev; + try { + ev = JSON.parse(line); + } catch { + continue; + } + if (ev && ev.type === "result") { + finish(() => rmSync(join(batchDir, file))); + try { + child.kill("SIGTERM"); + } catch { + /* already gone */ + } + setTimeout(() => { + try { + child.kill("SIGKILL"); + } catch { + /* already gone */ + } + }, 3000).unref(); + return; + } + } + }); + child.stdout.on("error", () => { + /* pipe torn down on kill — ignore */ + }); + } const timer = setTimeout(() => { // Hung responder (e.g. cursor-agent print-mode): SIGTERM, then SIGKILL, and // fail the message so the worker isn't wedged and the batch can drain. @@ -144,7 +258,11 @@ function respond(file) { }, 3000).unref(); finish(() => moveToFailed(file)); }, RESPONDER_TIMEOUT_MS); - child.on("exit", (code) => finish(() => (code === 0 ? rmSync(join(batchDir, file)) : moveToFailed(file)))); + child.on("exit", (code) => + finish(() => + code === 0 ? rmSync(join(batchDir, file)) : moveToFailed(file), + ), + ); child.on("error", () => finish(() => moveToFailed(file))); }); } @@ -156,7 +274,9 @@ async function worker() { await respond(files[index++]); } } -await Promise.all(Array.from({ length: Math.min(POOL, files.length || 1) }, worker)); +await Promise.all( + Array.from({ length: Math.min(POOL, files.length || 1) }, worker), +); // Remove the batch dir only if it is EMPTY — every claimed file was either // answered (deleted) or moved to failed/, so nothing is dropped.