From 1d1241244e98424a582fd279b40e75bd596fca58 Mon Sep 17 00:00:00 2001 From: Ghost Scripter Date: Mon, 13 Jul 2026 13:59:51 +0530 Subject: [PATCH 1/2] =?UTF-8?q?docs(plugin-tinyplace):=20cursor=E2=87=84op?= =?UTF-8?q?enhuman=20bidirectional=20bridge=20spike?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Capture the throwaway prototype that proves a Cursor IDE agent can be driven into a live two-way tiny.place conversation with OpenHuman over the Signal relay, plus the findings that inform the real adapter: - forward (Cursor→OpenHuman) via beforeSubmitPrompt/afterAgentResponse hooks → SessionEnvelopeV1 DMs rendered as a `cursor` runtime. - reverse (OpenHuman→Cursor) via a daemon that pastes inbox DMs into the live GUI (clipboard + System Events), with echo-suppression and focus-restore. - findings: `stop → followup_message` is the only in-conversation injection channel; CGEventPostToPid can't reach a backgrounded Electron window; AX value-set doesn't register in React; concurrent FileSessionStore access corrupts the ratchet (→ HTTP 400), fixed with a cross-process lock; SDK ≥2.0.2 required for base58 bundle routing. Prototype only (README flags the security caveats + auto-approve tradeoff); nothing here ships. Complements the cursor adapter hardening (#251). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../prototype/cursor-bridge/README.md | 133 +++++++++++++ .../prototype/cursor-bridge/common.mjs | 188 ++++++++++++++++++ .../prototype/cursor-bridge/daemon.mjs | 102 ++++++++++ .../prototype/cursor-bridge/hook.mjs | 117 +++++++++++ .../prototype/cursor-bridge/postkeys.swift | 32 +++ .../prototype/cursor-bridge/setup.mjs | 32 +++ 6 files changed, 604 insertions(+) create mode 100644 sdk/plugin-tinyplace/prototype/cursor-bridge/README.md create mode 100644 sdk/plugin-tinyplace/prototype/cursor-bridge/common.mjs create mode 100644 sdk/plugin-tinyplace/prototype/cursor-bridge/daemon.mjs create mode 100644 sdk/plugin-tinyplace/prototype/cursor-bridge/hook.mjs create mode 100644 sdk/plugin-tinyplace/prototype/cursor-bridge/postkeys.swift create mode 100644 sdk/plugin-tinyplace/prototype/cursor-bridge/setup.mjs diff --git a/sdk/plugin-tinyplace/prototype/cursor-bridge/README.md b/sdk/plugin-tinyplace/prototype/cursor-bridge/README.md new file mode 100644 index 00000000..aed83632 --- /dev/null +++ b/sdk/plugin-tinyplace/prototype/cursor-bridge/README.md @@ -0,0 +1,133 @@ +# Cursor ⇄ OpenHuman bidirectional bridge (prototype) + +> **Status: throwaway spike, not production.** This proves an IDE agent (Cursor) +> can be "hijacked" into a live, two-way tiny.place conversation with OpenHuman +> over the Signal-encrypted relay — and documents exactly what does and doesn't +> work, so the real adapter (`adapters/cursor.mjs`) can build on it. It is driven +> entirely by Cursor **hooks** + a small background daemon; it does not modify +> Cursor or OpenHuman. + +## What it does + +- **Forward (Cursor → OpenHuman):** Cursor's `beforeSubmitPrompt` and + `afterAgentResponse` hooks observe each turn and send it to OpenHuman as a + tiny.place `SessionEnvelopeV1` DM. OpenHuman's orchestration ingest classifies + it as a `cursor` runtime (`harness_type_for`) and renders it as a live session. +- **Reverse (OpenHuman → Cursor):** a background daemon polls the bridge's + encrypted inbox and, on a new OpenHuman DM, pastes it into Cursor's chat and + submits — so OpenHuman's messages appear in the **live Cursor GUI** and get + answered, and the answer flows back to OpenHuman. Full loop. + +## Architecture + +``` +Cursor GUI ──beforeSubmitPrompt/afterAgentResponse hooks──▶ hook.mjs ──Signal DM──▶ OpenHuman +Cursor GUI ◀──paste+Enter (daemon, macOS automation)────── daemon.mjs ◀──Signal DM── OpenHuman +``` + +| File | Role | +| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `common.mjs` | Shared SDK wiring (client/signer/FileSessionStore), the `SessionEnvelopeV1` builder, the cross-process lock, and echo-suppression records. | +| `hook.mjs` | The Cursor hook handler. Dispatches on `hook_event_name`: forwards user/assistant turns, auto-approves shell/MCP/read gates, no-ops on `stop` (reverse is the daemon's job). | +| `daemon.mjs` | Long-running reverse-push loop: poll inbox → paste each OpenHuman message into Cursor. | +| `postkeys.swift` | Experiment: `CGEventPostToPid` background key injection. **Does not work** for Cursor (see Findings) — kept as evidence. | +| `setup.mjs` | One-time: mint the bridge identity, publish Signal keys, send OpenHuman a contact request. | + +## Setup + +1. **Build the SDK** (needs ≥ 2.0.2 for base58 bundle routing): + ```bash + pnpm --filter @tinyhumansai/tinyplace build + ``` +2. **Find OpenHuman's tiny.place address** (its base58 cryptoId — shown in the app, + or in its logs as `[tinyplace] … agent_id=`). +3. **Provision the bridge** (publishes keys + sends a contact request): + ```bash + OPENHUMAN_ADDR= node setup.mjs + ``` + Then **accept the contact request** in the OpenHuman app (the relay is + contact-gated). +4. **Wire the Cursor hooks.** Create a wrapper that pins env + an absolute node + path (Cursor gives hooks a sanitized env and an arbitrary cwd): + ```bash + # bridge.sh + #!/bin/bash + export TINYPLACE_API_URL="https://staging-api.tiny.place" + export OPENHUMAN_ADDR="" + export BRIDGE_HOME="$HOME/.tinyplace-cursorbridge" + export BRIDGE_LOG="/tmp/cursor-bridge.log" + exec /abs/path/to/node /abs/path/to/hook.mjs + ``` + `~/.cursor/hooks.json`: + ```json + { + "version": 1, + "hooks": { + "beforeSubmitPrompt": [{ "command": "/abs/bridge.sh", "timeout": 30 }], + "afterAgentResponse": [{ "command": "/abs/bridge.sh", "timeout": 30 }], + "stop": [{ "command": "/abs/bridge.sh", "timeout": 30 }], + "beforeShellExecution": [{ "command": "/abs/bridge.sh", "timeout": 15 }], + "beforeMCPExecution": [{ "command": "/abs/bridge.sh", "timeout": 15 }], + "beforeReadFile": [{ "command": "/abs/bridge.sh", "timeout": 15 }] + } + } + ``` + **Reload the Cursor window** so it picks up `hooks.json`. +5. **Start the reverse daemon** (same env as the wrapper): + ```bash + node daemon.mjs # polls the inbox; pastes OpenHuman messages into Cursor + ``` + +Now chat in Cursor (mirrors to OpenHuman) and message the bridge from OpenHuman +(appears in Cursor, gets answered, answer returns). + +## Findings (the point of the spike) + +1. **Reverse injection IS possible via the `stop` hook.** Cursor hooks aren't + observe-only: `stop` / `subagentStop` accept a `{ "followup_message": "…" }` + result that Cursor **auto-submits** into the live chat. That's the only channel + that injects text into an ongoing conversation. This prototype ended up using a + daemon-driven GUI paste instead (see below), but `stop → followup_message` + works and is the zero-dependency fallback (turn-triggered, not push). + +2. **You cannot push into an idle Cursor from outside.** `stop` only fires when a + turn ends, so pure hook injection needs the user to take a turn. To get instant + push we drive the GUI (clipboard + `System Events` Cmd+V/Return). + +3. **`CGEventPostToPid` does NOT reach a backgrounded Electron window.** We tried + posting key events straight to Cursor's PID (`postkeys.swift`) to avoid + foregrounding — Chromium drops synthetic key events unless it's the key window. + So **there is no zero-focus-steal instant push**: instant delivery requires + briefly foregrounding Cursor (a ~0.5s flash; we capture and restore the prior + app so OpenHuman isn't left behind), OR you inject only while Cursor is already + frontmost (no flash, but not "instant while you're in OpenHuman"). + +4. **Setting the input's AX value doesn't register in React.** Even when found in + the Chromium AX tree, `set value of ` doesn't fire the input events + Cursor's React app needs, so a subsequent submit sends nothing. Real key events + (foreground) are required. + +5. **Concurrency corrupts the Signal session → HTTP 400.** The daemon (reads the + inbox, advancing the receive ratchet) and the hook processes (send, advancing + the send ratchet) share one `FileSessionStore`. Concurrent read+write clobbers + the Double Ratchet state and the relay rejects the next send with `400`. Fixed + with a **cross-process mkdir lock** (`withLock` in `common.mjs`) around every + SDK op that touches the store. + +6. **SDK ≥ 2.0.2 is mandatory.** Older builds fetch a peer's pre-key bundle by the + base64 identity key; a `/` in it becomes `%2F` and 404s on `/keys/:cryptoId/*`. + 2.0.2+ fetches by base58 cryptoId. (This is why the OpenHuman-side "slash-free + identity" idea was the wrong fix — the routing belongs at the client.) + +7. **First-contact ratchet desync** can drop the very first DM; recover with a + session reset + re-handshake, then resend. + +## ⚠️ Security caveats (demo only) + +- The Cursor hook config **auto-approves** shell/MCP/file gates so turns don't + stall on "Waiting for Approval" — meaning an (untrusted) injected OpenHuman + message can run shell in your workspace. Remove the three `before*Execution` / + `beforeReadFile` hooks to restore manual approval. +- Messages from OpenHuman are pasted and submitted as prompts; treat them as + untrusted data. Don't point this at an OpenHuman/peer you don't control. +- This provisions a throwaway wallet under `~/.tinyplace-cursorbridge`. diff --git a/sdk/plugin-tinyplace/prototype/cursor-bridge/common.mjs b/sdk/plugin-tinyplace/prototype/cursor-bridge/common.mjs new file mode 100644 index 00000000..4f6fc716 --- /dev/null +++ b/sdk/plugin-tinyplace/prototype/cursor-bridge/common.mjs @@ -0,0 +1,188 @@ +// Shared wiring for the Cursor⇄OpenHuman bidirectional bridge prototype. +// Uses the built TypeScript SDK (sdk/typescript/dist) directly — it must be +// >= 2.0.2, which fetches peer bundles by base58 cryptoId; older builds (e.g. +// the 1.0.1 in a stale node_modules) fetch by the base64 key and 404 on any key +// containing "/". Build it first: `pnpm --filter @tinyhumansai/tinyplace build`. +// The dist path is resolved relative to THIS file (repo-portable, cwd-independent, +// since Cursor runs hooks from arbitrary dirs); override with TINYPLACE_SDK_DIST. +import { + readFileSync, + writeFileSync, + mkdirSync, + rmdirSync, + existsSync, + statSync, + appendFileSync, +} from "node:fs"; +import { join, dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { homedir } from "node:os"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +// prototype/cursor-bridge/ -> ../../../typescript/dist +const DIST = + process.env.TINYPLACE_SDK_DIST ?? resolve(HERE, "../../../typescript/dist"); +const { TinyPlaceClient, LocalSigner } = await import(`${DIST}/index.js`); +const { FileSessionStore } = await import(`${DIST}/node/index.js`); +const agent = await import(`${DIST}/agent/index.js`); + +export const API = + process.env.TINYPLACE_API_URL ?? "https://staging-api.tiny.place"; +// The OpenHuman app's tiny.place address (its base58 cryptoId). REQUIRED — set it +// in the wrapper/env; there is no default (it's per-install). See README. +export const OPENHUMAN = process.env.OPENHUMAN_ADDR ?? ""; +export const HOME = + process.env.BRIDGE_HOME ?? join(homedir(), ".tinyplace-cursorbridge"); +export const LOG = process.env.BRIDGE_LOG ?? "/tmp/cursor-bridge.log"; + +export const log = (m) => { + try { + appendFileSync(LOG, `${new Date().toISOString()} ${m}\n`); + } catch {} +}; + +// ── cross-process mutex ────────────────────────────────────────────────────── +// The daemon (reverse: reads inbox) and the hook processes (forward: send) both +// touch the SAME FileSessionStore. Concurrent read+write corrupts the Double +// Ratchet state → the relay rejects the next send with HTTP 400. Serialize every +// SDK op that hits the store behind an atomic mkdir lock (works across processes). +const LOCKDIR = join(HOME, ".session.lock"); +const LOCK_STALE_MS = 15000; + +export async function withLock(fn, { retries = 200, delayMs = 40 } = {}) { + mkdirSync(HOME, { recursive: true }); + for (let i = 0; i < retries; i++) { + try { + mkdirSync(LOCKDIR); // atomic: throws EEXIST if held + try { + return await fn(); + } finally { + try { + rmdirSync(LOCKDIR); + } catch {} + } + } catch (e) { + if (e.code !== "EEXIST") throw e; + // Break a stale lock left by a crashed process. + try { + if (Date.now() - statSync(LOCKDIR).mtimeMs > LOCK_STALE_MS) { + rmdirSync(LOCKDIR); + continue; + } + } catch {} + await new Promise((r) => setTimeout(r, delayMs)); + } + } + // Give up waiting — proceed unlocked rather than drop the message. + return await fn(); +} + +const bytesToHex = (b) => + Array.from(b, (x) => x.toString(16).padStart(2, "0")).join(""); +const hexToBytes = (h) => { + const o = new Uint8Array(h.length / 2); + for (let i = 0; i < o.length; i++) + o[i] = parseInt(h.slice(i * 2, i * 2 + 2), 16); + return o; +}; + +// Load or mint a stable 32-byte seed for the bridge identity. +export function loadOrCreateSeed() { + mkdirSync(HOME, { recursive: true }); + const p = join(HOME, "wallet.json"); + if (existsSync(p)) return JSON.parse(readFileSync(p, "utf8")).seedHex; + const seed = new Uint8Array(32); + globalThis.crypto.getRandomValues(seed); + const seedHex = bytesToHex(seed); + writeFileSync(p, JSON.stringify({ seedHex }, null, 2), { mode: 0o600 }); + return seedHex; +} + +// Build a client + signer with the Signal session store the bridge shares across +// all hook invocations (each hook is a fresh process; the store is on disk). +export async function build() { + const seedHex = loadOrCreateSeed(); + const signer = await LocalSigner.fromSeed(hexToBytes(seedHex)); + const storePath = FileSessionStore.defaultPath( + signer.publicKeyBase64, + join(HOME, "signal"), + ); + const store = new FileSessionStore( + storePath, + await signer.getX25519KeyPair(), + ); + const client = new TinyPlaceClient({ + baseUrl: API, + signer, + encryption: { store }, + }); + return { signer, client, agent }; +} + +// ── echo suppression ────────────────────────────────────────────────────────── +// The daemon pastes OpenHuman messages into Cursor as PLAIN text (no visible tag), +// so beforeSubmitPrompt can't tell a pushed message from a user-typed one by +// content alone. Instead the daemon records each push here; the hook consumes the +// record to skip forwarding that one submission back to OpenHuman (which already +// has it). Match is by trimmed text within a short TTL, consumed once. +const PUSHED = join(HOME, "pushed.json"); +const PUSH_TTL_MS = 30000; + +function readPushed() { + try { + return JSON.parse(readFileSync(PUSHED, "utf8")); + } catch { + return []; + } +} + +export function recordPush(text) { + const arr = readPushed() + .filter((e) => Date.now() - e.ts < PUSH_TTL_MS) + .concat([{ text: String(text).trim(), ts: Date.now() }]) + .slice(-20); + try { + writeFileSync(PUSHED, JSON.stringify(arr), { mode: 0o600 }); + } catch {} +} + +// Returns true (and removes the record) if `text` matches a recent push. +export function consumePush(text) { + const arr = readPushed(); + const t = String(text).trim(); + const i = arr.findIndex( + (e) => e.text === t && Date.now() - e.ts < PUSH_TTL_MS, + ); + if (i === -1) return false; + arr.splice(i, 1); + try { + writeFileSync(PUSHED, JSON.stringify(arr), { mode: 0o600 }); + } catch {} + return true; +} + +// SessionEnvelopeV1 body so OpenHuman's orchestration ingest classifies this as a +// `cursor` runtime and renders it under a Cursor session (harness_type_for). +export function envelope({ role, text, convId, cwd }) { + const sid = convId || "cursor-session"; + return JSON.stringify({ + envelope_version: "tinyplace.harness.session.v1", + version: 1, + scope: { + type: "session", + key: "cursor", + cwd: cwd || "", + wrapper_session_id: sid, + harness_session_id: sid, + }, + harness: { provider: "cursor", command: "cursor", argv: [] }, + message: { + id: `${sid}-${Date.now()}`, + line: Date.now(), + role, + text, + timestamp: new Date().toISOString(), + }, + source: { path: "cursor", record_type: role }, + }); +} diff --git a/sdk/plugin-tinyplace/prototype/cursor-bridge/daemon.mjs b/sdk/plugin-tinyplace/prototype/cursor-bridge/daemon.mjs new file mode 100644 index 00000000..66e6eb7e --- /dev/null +++ b/sdk/plugin-tinyplace/prototype/cursor-bridge/daemon.mjs @@ -0,0 +1,102 @@ +// Instant reverse auto-push daemon. +// +// Cursor exposes no way to push text into an idle agent, so we do it at the OS +// level: poll the bridge inbox and, when OpenHuman sends a DM, paste it into +// Cursor's chat composer via the clipboard + System Events (Cmd+V, Enter). The +// message is prefixed with the INJECT sentinel so beforeSubmitPrompt skips echoing +// it back to OpenHuman. +// +// Requires macOS Accessibility permission for the process running osascript +// (System Preferences → Privacy & Security → Accessibility → enable your terminal +// / node). First push fails loudly in the log if it's not granted. +import { spawnSync } from "node:child_process"; +import { build, OPENHUMAN, log, recordPush, withLock } from "./common.mjs"; + +const POLL_MS = Number(process.env.BRIDGE_POLL_MS) || 3000; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +function extractText(raw) { + try { + const o = JSON.parse(raw); + if ( + o && + o.envelope_version && + o.message && + typeof o.message.text === "string" + ) { + return o.message.text; + } + } catch { + /* plain DM */ + } + return raw; +} + +// Put text on the clipboard, focus Cursor, paste, and submit. Clipboard paste +// (vs keystroke) sidesteps all escaping and handles any characters. Single-line +// so the paste doesn't submit early — our explicit Return submits. +function pushToCursor(text) { + const oneLine = String(text) + .replace(/\s*\n\s*/g, " ") + .trim(); + const copy = spawnSync("pbcopy", [], { input: oneLine }); + if (copy.status !== 0) { + log(`pbcopy failed: ${copy.stderr}`); + return false; + } + // Remember whatever app the user is in (e.g. OpenHuman), briefly bring Cursor to + // front to paste+submit, then RESTORE the previous app so OpenHuman isn't left in + // the background. Cursor keeps running its turn once submitted. + const lines = [ + 'tell application "System Events" to set prevApp to name of first application process whose frontmost is true', + 'tell application "Cursor" to activate', + "delay 0.25", + 'tell application "System Events" to keystroke "v" using command down', + "delay 0.12", + 'tell application "System Events" to key code 36', + "delay 0.12", + 'tell application "System Events" to set frontmost of process prevApp to true', + ]; + const args = lines.flatMap((l) => ["-e", l]); + const r = spawnSync("osascript", args, { encoding: "utf8" }); + if (r.status !== 0) { + log( + `osascript FAILED (Accessibility permission?): ${String(r.stderr).trim()}`, + ); + return false; + } + return true; +} + +const { signer, client, agent } = await build(); +log( + `daemon START polling=${POLL_MS}ms as=${signer.agentId.slice(0, 10)}… from=${OPENHUMAN.slice(0, 10)}…`, +); + +for (;;) { + try { + // Serialize session-store access with the hooks (which SEND on the same store) + // so the Double Ratchet state isn't corrupted by concurrent read/write (→ 400). + const msgs = await withLock(() => + agent.readMessages(client, signer, { limit: 20 }), + ); + const texts = msgs + .filter((m) => m.from === OPENHUMAN) + .map((m) => extractText(m.text)) + .filter((t) => String(t).trim()); + for (const t of texts) { + const oneLine = String(t) + .replace(/\s*\n\s*/g, " ") + .trim(); + log(`PUSH -> Cursor (${oneLine.length} chars): ${oneLine.slice(0, 60)}`); + // Record BEFORE pasting so beforeSubmitPrompt (which fires on submit) can + // find the marker and skip echoing this message back to OpenHuman. + recordPush(oneLine); + pushToCursor(oneLine); + await sleep(1500); // let Cursor settle + start its turn before the next push + } + } catch (e) { + log(`daemon poll error: ${e.message}`); + } + await sleep(POLL_MS); +} diff --git a/sdk/plugin-tinyplace/prototype/cursor-bridge/hook.mjs b/sdk/plugin-tinyplace/prototype/cursor-bridge/hook.mjs new file mode 100644 index 00000000..5b9b3344 --- /dev/null +++ b/sdk/plugin-tinyplace/prototype/cursor-bridge/hook.mjs @@ -0,0 +1,117 @@ +// Cursor hook handler — the bidirectional bridge. +// +// FORWARD (Cursor → OpenHuman): +// beforeSubmitPrompt → send the user turn as a cursor SessionEnvelopeV1 DM +// afterAgentResponse → send the assistant turn ditto +// REVERSE (OpenHuman → Cursor GUI): +// stop → drain the bridge inbox; any DM from OpenHuman is +// returned as {"followup_message": ...}, which Cursor +// auto-submits into the LIVE chat (the agent then +// answers it, and afterAgentResponse mirrors that answer +// back to OpenHuman — a full loop). +// +// Cursor pipes the hook payload as JSON on stdin and reads our JSON from stdout. +import { readFileSync } from "node:fs"; +import { + build, + envelope, + log, + OPENHUMAN, + consumePush, + withLock, +} from "./common.mjs"; + +function readStdin() { + try { + return readFileSync(0, "utf8"); + } catch { + return ""; + } +} + +function emit(obj) { + // Cursor consumes hook decisions as a single JSON object on stdout. + if (obj) process.stdout.write(JSON.stringify(obj)); +} + +async function main() { + let payload; + try { + payload = JSON.parse(readStdin() || "{}"); + } catch (e) { + log(`bad payload: ${e.message}`); + return; + } + const ev = payload.hook_event_name ?? payload.hookEventName ?? ""; + + // Auto-approve gates FIRST (no SDK/network needed): a Cursor turn that stalls on + // "Waiting for Approval" never fires `stop`, so the reverse pull never runs. For + // a hands-off hijack we allow shell/MCP/read so turns always complete. + // ⚠️ DEMO CONVENIENCE: this lets an (untrusted) injected OpenHuman message run + // shell in your workspace. Remove these three hooks from ~/.cursor/hooks.json to + // go back to manual approval. + if ( + ev === "beforeShellExecution" || + ev === "beforeMCPExecution" || + ev === "beforeReadFile" + ) { + emit({ permission: "allow" }); + log(`auto-allow ${ev}`); + return; + } + + const convId = + payload.conversation_id || payload.conversationId || "cursor-session"; + const cwd = + (payload.workspace_roots && payload.workspace_roots[0]) || + payload.cwd || + process.env.CURSOR_PROJECT_DIR || + ""; + + const { signer, client, agent } = await build(); + + if (ev === "beforeSubmitPrompt" || ev === "afterAgentResponse") { + const role = ev === "beforeSubmitPrompt" ? "user" : "assistant"; + const text = + (ev === "beforeSubmitPrompt" ? payload.prompt : payload.text) ?? ""; + if (!String(text).trim()) { + log(`skip ${ev} (empty)`); + return; + } + // Don't echo a daemon-pushed OpenHuman message back to OpenHuman (it already + // has it). The daemon recorded this exact text just before pasting it. + if (ev === "beforeSubmitPrompt" && consumePush(text)) { + log(`skip echo of pushed message (conv=${convId})`); + return; + } + try { + await withLock(() => + agent.sendMessage( + client, + signer, + OPENHUMAN, + envelope({ role, text, convId, cwd }), + ), + ); + log(`FWD ${role} (${String(text).length} chars) conv=${convId} -> OH`); + } catch (e) { + log(`FWD ${role} FAILED: ${e.message}`); + } + return; + } + + if (ev === "stop" || ev === "subagentStop") { + // Reverse delivery is now owned by the background auto-push daemon (daemon.mjs), + // which polls the inbox and pastes OpenHuman messages into Cursor's GUI. The stop + // hook must NOT also drain the inbox — two readers race on the same session store + // and would double-ack/clobber. So this is a no-op. + log(`stop: reverse handled by daemon (no-op)`); + return; + } + + log(`ignore event=${ev}`); +} + +main() + .catch((e) => log(`fatal: ${e.stack || e.message}`)) + .finally(() => process.exit(0)); diff --git a/sdk/plugin-tinyplace/prototype/cursor-bridge/postkeys.swift b/sdk/plugin-tinyplace/prototype/cursor-bridge/postkeys.swift new file mode 100644 index 00000000..96c45cf7 --- /dev/null +++ b/sdk/plugin-tinyplace/prototype/cursor-bridge/postkeys.swift @@ -0,0 +1,32 @@ +// Post Cmd+V then Return to a specific PID WITHOUT foregrounding it. +// Usage: postkeys (clipboard is set by the caller via pbcopy) +// If Chromium accepts these background events into its focused chat field, this +// gives zero-focus-steal injection. +import CoreGraphics +import Foundation + +let args = CommandLine.arguments +guard args.count >= 2, let pid = Int32(args[1]) else { + FileHandle.standardError.write("usage: postkeys \n".data(using: .utf8)!) + exit(2) +} + +let src = CGEventSource(stateID: .combinedSessionState) + +func post(_ key: CGKeyCode, _ flags: CGEventFlags) { + if let d = CGEvent(keyboardEventSource: src, virtualKey: key, keyDown: true) { + d.flags = flags + d.postToPid(pid) + } + if let u = CGEvent(keyboardEventSource: src, virtualKey: key, keyDown: false) { + u.flags = flags + u.postToPid(pid) + } +} + +post(9, .maskCommand) // Cmd+V (paste) +// Pass "paste" as arg 2 to skip the submit (leaves text in the box for testing). +if args.count < 3 || args[2] != "paste" { + usleep(120_000) + post(36, []) // Return (submit) +} diff --git a/sdk/plugin-tinyplace/prototype/cursor-bridge/setup.mjs b/sdk/plugin-tinyplace/prototype/cursor-bridge/setup.mjs new file mode 100644 index 00000000..cf63f221 --- /dev/null +++ b/sdk/plugin-tinyplace/prototype/cursor-bridge/setup.mjs @@ -0,0 +1,32 @@ +// One-time: provision the bridge identity (publish Signal keys) and send a +// contact request to OpenHuman so DMs can flow both ways (relay is contact-gated). +import { build, OPENHUMAN, API, HOME } from "./common.mjs"; + +if (!OPENHUMAN) { + console.error( + "Set OPENHUMAN_ADDR to the OpenHuman app's tiny.place cryptoId. See README.", + ); + process.exit(1); +} + +const { signer, client } = await build(); +console.log("bridge address :", signer.agentId); +console.log("bridge home :", HOME); +console.log("api :", API); +console.log("openhuman :", OPENHUMAN); + +// Publish our pre-key bundle so peers can open a Signal session with us. +await client.enableEncryption(); +console.log("keys published ✅"); + +// Contact request (idempotent; auto-accepts if OpenHuman already requested us). +try { + const c = await client.contacts.request(OPENHUMAN); + console.log("contact request ->", JSON.stringify(c).slice(0, 160)); +} catch (e) { + console.log("contact request note:", e?.message ?? String(e)); +} + +const st = await client.contacts.status(OPENHUMAN); +console.log("contact status :", JSON.stringify(st)); +process.exit(0); From 091763dc2d4dcd28a7dd39a1082ae8d4ada188c9 Mon Sep 17 00:00:00 2001 From: Ghost Scripter Date: Tue, 14 Jul 2026 01:14:04 +0530 Subject: [PATCH 2/2] feat(plugin-tinyplace): bridge approval routing + session self-heal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the cursor⇄openhuman bridge prototype with the tool-approval feature and transport hardening validated against staging: - hook.mjs: route beforeShellExecution/beforeMCPExecution to OpenHuman as a v2 approval_request event and block for the allow/deny decision (falls back to Cursor's own prompt on timeout); auto-allow file reads. - common.mjs: v2 approvalEnvelope builder, extractText, an AWAITING flag (daemon pauses inbox draining while an approval is pending), and sendWithRetry which self-heals a desynced session (reset + retry on a 400/encrypt error). - daemon.mjs: pause while an approval is pending so the hook owns the decision DM. - README: approval-routing section + findings on the two-store ratchet fragility (receiving side can't retry a silent drop) and deriving the resolved-card state. Prototype only; kept repo-portable (relative SDK dist, OPENHUMAN_ADDR from env). Pairs with the OpenHuman Allow/Deny card PR (tinyhumansai/openhuman#4837). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../prototype/cursor-bridge/README.md | 40 ++++-- .../prototype/cursor-bridge/common.mjs | 103 ++++++++++++++- .../prototype/cursor-bridge/daemon.mjs | 29 ++++- .../prototype/cursor-bridge/hook.mjs | 120 ++++++++++++++---- 4 files changed, 254 insertions(+), 38 deletions(-) diff --git a/sdk/plugin-tinyplace/prototype/cursor-bridge/README.md b/sdk/plugin-tinyplace/prototype/cursor-bridge/README.md index aed83632..54e75914 100644 --- a/sdk/plugin-tinyplace/prototype/cursor-bridge/README.md +++ b/sdk/plugin-tinyplace/prototype/cursor-bridge/README.md @@ -17,6 +17,12 @@ encrypted inbox and, on a new OpenHuman DM, pastes it into Cursor's chat and submits — so OpenHuman's messages appear in the **live Cursor GUI** and get answered, and the answer flows back to OpenHuman. Full loop. +- **Tool-approval routing (OpenHuman decides):** Cursor's `beforeShellExecution` / + `beforeMCPExecution` hooks route the approval to OpenHuman — the hook posts a v2 + `approval_request` event (rendered as a native **Allow/Deny card** by OpenHuman) + and **blocks** until the user replies `allow`/`deny` there, then returns that as + the hook's permission. So you approve a Cursor tool call from OpenHuman without + switching to Cursor. On timeout it falls back to Cursor's own prompt (`ask`). ## Architecture @@ -25,13 +31,13 @@ Cursor GUI ──beforeSubmitPrompt/afterAgentResponse hooks──▶ hook.mjs Cursor GUI ◀──paste+Enter (daemon, macOS automation)────── daemon.mjs ◀──Signal DM── OpenHuman ``` -| File | Role | -| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `common.mjs` | Shared SDK wiring (client/signer/FileSessionStore), the `SessionEnvelopeV1` builder, the cross-process lock, and echo-suppression records. | -| `hook.mjs` | The Cursor hook handler. Dispatches on `hook_event_name`: forwards user/assistant turns, auto-approves shell/MCP/read gates, no-ops on `stop` (reverse is the daemon's job). | -| `daemon.mjs` | Long-running reverse-push loop: poll inbox → paste each OpenHuman message into Cursor. | -| `postkeys.swift` | Experiment: `CGEventPostToPid` background key injection. **Does not work** for Cursor (see Findings) — kept as evidence. | -| `setup.mjs` | One-time: mint the bridge identity, publish Signal keys, send OpenHuman a contact request. | +| File | Role | +| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `common.mjs` | Shared SDK wiring (client/signer/FileSessionStore), the `SessionEnvelopeV1` + v2 `approval_request` builders, the cross-process lock, echo-suppression records, and `sendWithRetry` (self-heals a desynced session on a `400`/encrypt error). | +| `hook.mjs` | The Cursor hook handler. Forwards user/assistant turns; routes `beforeShellExecution`/`beforeMCPExecution` to OpenHuman for approval (blocks for the decision); auto-allows file reads; no-ops on `stop` (reverse is the daemon's job). | +| `daemon.mjs` | Long-running reverse-push loop: poll inbox → paste each OpenHuman message into Cursor. Pauses while an approval is pending so the hook owns the decision DM. | +| `postkeys.swift` | Experiment: `CGEventPostToPid` background key injection. **Does not work** for Cursor (see Findings) — kept as evidence. | +| `setup.mjs` | One-time: mint the bridge identity, publish Signal keys, send OpenHuman a contact request. | ## Setup @@ -66,8 +72,8 @@ Cursor GUI ◀──paste+Enter (daemon, macOS automation)────── d "beforeSubmitPrompt": [{ "command": "/abs/bridge.sh", "timeout": 30 }], "afterAgentResponse": [{ "command": "/abs/bridge.sh", "timeout": 30 }], "stop": [{ "command": "/abs/bridge.sh", "timeout": 30 }], - "beforeShellExecution": [{ "command": "/abs/bridge.sh", "timeout": 15 }], - "beforeMCPExecution": [{ "command": "/abs/bridge.sh", "timeout": 15 }], + "beforeShellExecution": [{ "command": "/abs/bridge.sh", "timeout": 300 }], + "beforeMCPExecution": [{ "command": "/abs/bridge.sh", "timeout": 300 }], "beforeReadFile": [{ "command": "/abs/bridge.sh", "timeout": 15 }] } } @@ -122,6 +128,22 @@ Now chat in Cursor (mirrors to OpenHuman) and message the bridge from OpenHuman 7. **First-contact ratchet desync** can drop the very first DM; recover with a session reset + re-handshake, then resend. +8. **The Signal session between two independently-managed stores is fragile.** The + app resets its store on restart while the bridge accumulates state, and the + daemon (reads) + hooks (sends) touch the same store concurrently. Once the + Double Ratchet diverges, `readMessages` **silently drops** what it can't + decrypt, so the receiving side never learns to reset — it stays broken until a + manual re-handshake. `sendWithRetry` self-heals the _sending_ side (reset the + session + retry on a `400`/encrypt error), but the _receiving_ side has no + equivalent (you can't retry a silent drop). A production adapter should own + both identities' stores or add an explicit re-key signal rather than lean on + two loosely-coupled ratchets. + +9. **Approval outcome must be derived, not just local state.** OpenHuman renders + the resolved Allow/Deny card from the persisted decision message (not only the + click), so it survives a reload — the button-only version reverted to + unresolved on remount. + ## ⚠️ Security caveats (demo only) - The Cursor hook config **auto-approves** shell/MCP/file gates so turns don't diff --git a/sdk/plugin-tinyplace/prototype/cursor-bridge/common.mjs b/sdk/plugin-tinyplace/prototype/cursor-bridge/common.mjs index 4f6fc716..9c8d5e13 100644 --- a/sdk/plugin-tinyplace/prototype/cursor-bridge/common.mjs +++ b/sdk/plugin-tinyplace/prototype/cursor-bridge/common.mjs @@ -1,10 +1,9 @@ // Shared wiring for the Cursor⇄OpenHuman bidirectional bridge prototype. // Uses the built TypeScript SDK (sdk/typescript/dist) directly — it must be -// >= 2.0.2, which fetches peer bundles by base58 cryptoId; older builds (e.g. -// the 1.0.1 in a stale node_modules) fetch by the base64 key and 404 on any key -// containing "/". Build it first: `pnpm --filter @tinyhumansai/tinyplace build`. -// The dist path is resolved relative to THIS file (repo-portable, cwd-independent, -// since Cursor runs hooks from arbitrary dirs); override with TINYPLACE_SDK_DIST. +// >= 2.0.2, which fetches peer bundles by base58 cryptoId; older builds fetch by +// the base64 key and 404 on any key containing "/". Build it first: +// `pnpm --filter @tinyhumansai/tinyplace build`. The dist path is resolved +// relative to THIS file (repo-portable, cwd-independent); override TINYPLACE_SDK_DIST. import { readFileSync, writeFileSync, @@ -41,6 +40,32 @@ export const log = (m) => { } catch {} }; +export const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +// OpenHuman sends its replies as a SessionEnvelopeV1 JSON body (role "owner"). +// Pull the human-readable text out; fall back to the raw string for plain DMs. +export function extractText(raw) { + try { + const o = JSON.parse(raw); + if ( + o && + o.envelope_version && + o.message && + typeof o.message.text === "string" + ) { + return o.message.text; + } + } catch { + /* plain DM */ + } + return raw; +} + +// Flag file the approval hook sets while it waits for an OpenHuman allow/deny, so +// the reverse daemon PAUSES inbox draining (the hook must be the sole reader then, +// or it would race the daemon for the decision DM). +export const AWAITING = join(HOME, ".awaiting-approval"); + // ── cross-process mutex ────────────────────────────────────────────────────── // The daemon (reverse: reads inbox) and the hook processes (forward: send) both // touch the SAME FileSessionStore. Concurrent read+write corrupts the Double @@ -116,7 +141,33 @@ export async function build() { signer, encryption: { store }, }); - return { signer, client, agent }; + return { signer, client, agent, store }; +} + +// Send with self-healing: on an encryption/session error (the intermittent +// "body must be encrypted ciphertext" 400 or a ratchet desync), drop the stale +// session with the recipient and retry once so a fresh X3DH re-establishes it. +// Prevents a single desynced message from silently dropping a forwarded reply. +export async function sendWithRetry(ctx, recipient, body) { + const { client, signer, agent, store } = ctx; + try { + return await agent.sendMessage(client, signer, recipient, body); + } catch (e) { + if ( + !/encrypted ciphertext|HTTP 400|No session|ratchet|decrypt/i.test( + String(e?.message), + ) + ) { + throw e; + } + try { + const to = await agent.resolveRecipientKey(client, recipient); + await store.removeSession(to); + } catch { + /* best-effort reset */ + } + return await agent.sendMessage(client, signer, recipient, body); + } } // ── echo suppression ────────────────────────────────────────────────────────── @@ -186,3 +237,43 @@ export function envelope({ role, text, convId, cwd }) { source: { path: "cursor", record_type: role }, }); } + +// SessionEnvelopeV2 with a typed `approval_request` event. OpenHuman's orchestration +// ingest (classify_v2) maps this to eventKind "approval_request" (display → body, +// tool_name, call_id) and the SessionTranscript renders an Allow/Deny card. Uses the +// SAME wrapper_session_id as the chat turns so it threads into the same session; the +// user's button reply comes back as a plain "allow"/"deny" DM. +export function approvalEnvelope({ + toolName, + display, + convId, + cwd, + requestId, +}) { + const sid = convId || "cursor-session"; + return JSON.stringify({ + envelope_version: "tinyplace.harness.session.v2", + version: 2, + scope: { + type: "session", + key: "cursor", + cwd: cwd || "", + wrapper_session_id: sid, + harness_session_id: sid, + }, + harness: { provider: "cursor", command: "cursor", argv: [] }, + event: { + id: requestId, + seq: Date.now(), + ts: new Date().toISOString(), + role: "agent", + kind: "approval_request", + payload: { + tool_name: toolName || "shell", + display: display || "", + call_id: requestId, + }, + }, + source: { path: "cursor", record_type: "approval_request" }, + }); +} diff --git a/sdk/plugin-tinyplace/prototype/cursor-bridge/daemon.mjs b/sdk/plugin-tinyplace/prototype/cursor-bridge/daemon.mjs index 66e6eb7e..bffe2507 100644 --- a/sdk/plugin-tinyplace/prototype/cursor-bridge/daemon.mjs +++ b/sdk/plugin-tinyplace/prototype/cursor-bridge/daemon.mjs @@ -10,11 +10,32 @@ // (System Preferences → Privacy & Security → Accessibility → enable your terminal // / node). First push fails loudly in the log if it's not granted. import { spawnSync } from "node:child_process"; -import { build, OPENHUMAN, log, recordPush, withLock } from "./common.mjs"; +import { existsSync, statSync } from "node:fs"; +import { + build, + OPENHUMAN, + log, + recordPush, + withLock, + AWAITING, +} from "./common.mjs"; const POLL_MS = Number(process.env.BRIDGE_POLL_MS) || 3000; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +// The approval hook holds this flag while it waits for an OpenHuman allow/deny; it +// must be the SOLE inbox reader then, so the daemon pauses. Ignore a stale flag +// (crashed hook) older than 6 min so a crash can't wedge reverse delivery forever. +function approvalPending() { + try { + return ( + existsSync(AWAITING) && Date.now() - statSync(AWAITING).mtimeMs < 360_000 + ); + } catch { + return false; + } +} + function extractText(raw) { try { const o = JSON.parse(raw); @@ -75,6 +96,12 @@ log( for (;;) { try { + // While an approval is pending, the hook owns the inbox (it's watching for the + // allow/deny DM) — don't drain it out from under them. + if (approvalPending()) { + await sleep(POLL_MS); + continue; + } // Serialize session-store access with the hooks (which SEND on the same store) // so the Double Ratchet state isn't corrupted by concurrent read/write (→ 400). const msgs = await withLock(() => diff --git a/sdk/plugin-tinyplace/prototype/cursor-bridge/hook.mjs b/sdk/plugin-tinyplace/prototype/cursor-bridge/hook.mjs index 5b9b3344..137dadc3 100644 --- a/sdk/plugin-tinyplace/prototype/cursor-bridge/hook.mjs +++ b/sdk/plugin-tinyplace/prototype/cursor-bridge/hook.mjs @@ -11,16 +11,26 @@ // back to OpenHuman — a full loop). // // Cursor pipes the hook payload as JSON on stdin and reads our JSON from stdout. -import { readFileSync } from "node:fs"; +import { readFileSync, writeFileSync, rmSync } from "node:fs"; import { build, envelope, + approvalEnvelope, log, OPENHUMAN, consumePush, withLock, + sendWithRetry, + extractText, + sleep, + AWAITING, } from "./common.mjs"; +// How long the approval hook waits for an OpenHuman decision before falling back +// to Cursor's own GUI prompt. MUST be < the hook's timeout in ~/.cursor/hooks.json +// (300s there → 270s here leaves a buffer so we return before Cursor kills us). +const APPROVAL_WAIT_MS = Number(process.env.BRIDGE_APPROVAL_WAIT_MS) || 270_000; + function readStdin() { try { return readFileSync(0, "utf8"); @@ -34,6 +44,74 @@ function emit(obj) { if (obj) process.stdout.write(JSON.stringify(obj)); } +// Human-readable description of what Cursor wants to run. +function describeCall(payload, ev) { + if (ev === "beforeShellExecution") { + return payload.command || payload.commandLine || "(shell command)"; + } + const tool = payload.tool_name || payload.name || "tool"; + const server = payload.server_name || payload.server || ""; + return `MCP tool: ${server ? `${server} / ` : ""}${tool}`; +} + +function parseDecision(text) { + const s = String(text).trim().toLowerCase(); + if (/^(allow|run|yes|y|approve|ok|go|1)\b/.test(s)) return "allow"; + if (/^(deny|skip|no|n|reject|stop|cancel|0)\b/.test(s)) return "deny"; + return null; +} + +// Route a Cursor tool-execution gate to OpenHuman: post the request, then block +// (draining the inbox) until the user replies allow/deny there — so approvals +// happen in OpenHuman without switching to Cursor. Returns "allow" | "deny" | +// "ask" (fallback to Cursor's own prompt on timeout/error). While waiting we hold +// the AWAITING flag so the reverse daemon doesn't steal the decision DM. +async function routeApproval(payload, ev, convId, cwd) { + const label = describeCall(payload, ev); + const toolName = ev === "beforeShellExecution" ? "shell" : "mcp"; + const requestId = `appr-${Date.now()}`; + const { signer, client, agent, store } = await build(); + writeFileSync(AWAITING, String(Date.now())); + try { + // V2 approval_request event → OpenHuman renders an Allow/Deny card; the user's + // button reply comes back as a plain "allow"/"deny" DM we parse below. + await withLock(() => + sendWithRetry( + { client, signer, agent, store }, + OPENHUMAN, + approvalEnvelope({ toolName, display: label, convId, cwd, requestId }), + ), + ); + log(`APPROVAL request -> OH (${requestId}): ${String(label).slice(0, 80)}`); + const deadline = Date.now() + APPROVAL_WAIT_MS; + while (Date.now() < deadline) { + const msgs = await withLock(() => + agent.readMessages(client, signer, { limit: 10 }), + ); + for (const m of msgs) { + if (m.from !== OPENHUMAN) continue; + const d = parseDecision(extractText(m.text)); + if (d) { + log(`APPROVAL decision=${d} for ${String(label).slice(0, 50)}`); + return d; + } + } + await sleep(2000); + } + log( + `APPROVAL timed out -> ask (Cursor GUI fallback): ${String(label).slice(0, 50)}`, + ); + return "ask"; + } catch (e) { + log(`APPROVAL error -> ask: ${e.message}`); + return "ask"; + } finally { + try { + rmSync(AWAITING); + } catch {} + } +} + async function main() { let payload; try { @@ -43,23 +121,6 @@ async function main() { return; } const ev = payload.hook_event_name ?? payload.hookEventName ?? ""; - - // Auto-approve gates FIRST (no SDK/network needed): a Cursor turn that stalls on - // "Waiting for Approval" never fires `stop`, so the reverse pull never runs. For - // a hands-off hijack we allow shell/MCP/read so turns always complete. - // ⚠️ DEMO CONVENIENCE: this lets an (untrusted) injected OpenHuman message run - // shell in your workspace. Remove these three hooks from ~/.cursor/hooks.json to - // go back to manual approval. - if ( - ev === "beforeShellExecution" || - ev === "beforeMCPExecution" || - ev === "beforeReadFile" - ) { - emit({ permission: "allow" }); - log(`auto-allow ${ev}`); - return; - } - const convId = payload.conversation_id || payload.conversationId || "cursor-session"; const cwd = @@ -68,7 +129,23 @@ async function main() { process.env.CURSOR_PROJECT_DIR || ""; - const { signer, client, agent } = await build(); + // File reads are low-risk and high-frequency — auto-allow (routing them to + // OpenHuman would be spam). + if (ev === "beforeReadFile") { + emit({ permission: "allow" }); + log(`auto-allow ${ev}`); + return; + } + + // Shell + MCP execution: route the approval to OpenHuman and wait for a decision + // there, so you never switch to Cursor to click Run. Falls back to Cursor's own + // prompt ("ask") if OpenHuman doesn't answer in time. + if (ev === "beforeShellExecution" || ev === "beforeMCPExecution") { + emit({ permission: await routeApproval(payload, ev, convId, cwd) }); + return; + } + + const { signer, client, agent, store } = await build(); if (ev === "beforeSubmitPrompt" || ev === "afterAgentResponse") { const role = ev === "beforeSubmitPrompt" ? "user" : "assistant"; @@ -86,9 +163,8 @@ async function main() { } try { await withLock(() => - agent.sendMessage( - client, - signer, + sendWithRetry( + { client, signer, agent, store }, OPENHUMAN, envelope({ role, text, convId, cwd }), ),