From 9317fd05a82b3481077c4b51f74fee257c91a4c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=8Dtalo=20Menezes?= Date: Mon, 15 Jun 2026 21:01:06 -0300 Subject: [PATCH 01/23] feat: add ACP registry data model, parser, and live-fetch hook --- src/defaults/acp-registry-snapshot.json | 1040 +++++++++++++++++++ src/defaults/agent-registry.ts | 16 + src/hooks/use-agent-registry-search.test.ts | 65 ++ src/hooks/use-agent-registry-search.ts | 17 + src/hooks/use-agent-registry.test.tsx | 58 ++ src/hooks/use-agent-registry.ts | 47 + src/lib/agent-registry-filter.test.ts | 215 ++++ src/lib/agent-registry-filter.ts | 125 +++ src/test-utils/react-query.tsx | 11 +- src/types/registry.ts | 41 + 10 files changed, 1633 insertions(+), 2 deletions(-) create mode 100644 src/defaults/acp-registry-snapshot.json create mode 100644 src/defaults/agent-registry.ts create mode 100644 src/hooks/use-agent-registry-search.test.ts create mode 100644 src/hooks/use-agent-registry-search.ts create mode 100644 src/hooks/use-agent-registry.test.tsx create mode 100644 src/hooks/use-agent-registry.ts create mode 100644 src/lib/agent-registry-filter.test.ts create mode 100644 src/lib/agent-registry-filter.ts create mode 100644 src/types/registry.ts diff --git a/src/defaults/acp-registry-snapshot.json b/src/defaults/acp-registry-snapshot.json new file mode 100644 index 000000000..f0174c585 --- /dev/null +++ b/src/defaults/acp-registry-snapshot.json @@ -0,0 +1,1040 @@ +{ + "version": "1.0.0", + "agents": [ + { + "id": "agoragentic-acp", + "name": "Agoragentic", + "version": "1.3.0", + "description": "Agent marketplace with 174+ AI capabilities. Browse, invoke, and pay for agent services settled in USDC on Base L2.", + "repository": "https://github.com/rhein1/agoragentic-integrations", + "website": "https://agoragentic.com", + "authors": ["ACRE / Agoragentic"], + "license": "MIT", + "distribution": { + "npx": { + "package": "agoragentic-mcp@1.3.0", + "args": ["--acp"] + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/agoragentic-acp.svg" + }, + { + "id": "amp-acp", + "name": "Amp", + "version": "0.8.1", + "description": "ACP wrapper for Amp - the frontier coding agent", + "repository": "https://github.com/tao12345666333/amp-acp", + "authors": ["tao12345666333"], + "license": "Apache-2.0", + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/amp-acp.svg", + "distribution": { + "binary": { + "darwin-aarch64": { + "archive": "https://github.com/tao12345666333/amp-acp/releases/download/v0.8.1/amp-acp-darwin-aarch64.tar.gz", + "cmd": "./amp-acp" + }, + "darwin-x86_64": { + "archive": "https://github.com/tao12345666333/amp-acp/releases/download/v0.8.1/amp-acp-darwin-x86_64.tar.gz", + "cmd": "./amp-acp" + }, + "linux-aarch64": { + "archive": "https://github.com/tao12345666333/amp-acp/releases/download/v0.8.1/amp-acp-linux-aarch64.tar.gz", + "cmd": "./amp-acp" + }, + "linux-x86_64": { + "archive": "https://github.com/tao12345666333/amp-acp/releases/download/v0.8.1/amp-acp-linux-x86_64.tar.gz", + "cmd": "./amp-acp" + }, + "windows-x86_64": { + "archive": "https://github.com/tao12345666333/amp-acp/releases/download/v0.8.1/amp-acp-windows-x86_64.zip", + "cmd": "amp-acp.exe" + } + } + } + }, + { + "id": "auggie", + "name": "Auggie CLI", + "version": "0.29.0", + "description": "Augment Code's powerful software agent, backed by industry-leading context engine", + "repository": "https://github.com/augmentcode/auggie", + "website": "https://www.augmentcode.com/", + "authors": ["Augment Code "], + "license": "proprietary", + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/auggie.svg", + "distribution": { + "npx": { + "package": "@augmentcode/auggie@0.29.0", + "args": ["--acp"], + "env": { + "AUGMENT_DISABLE_AUTO_UPDATE": "1" + } + } + } + }, + { + "id": "autohand", + "name": "Autohand Code", + "version": "0.2.1", + "description": "Autohand Code - AI coding agent powered by Autohand AI", + "repository": "https://github.com/autohandai/autohand-acp", + "website": "https://www.autohand.ai/cli/", + "authors": ["Autohand AI"], + "license": "Apache-2.0", + "distribution": { + "npx": { + "package": "@autohandai/autohand-acp@0.2.1" + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/autohand.svg" + }, + { + "id": "claude-acp", + "name": "Claude Agent", + "version": "0.44.0", + "description": "ACP wrapper for Anthropic's Claude", + "repository": "https://github.com/agentclientprotocol/claude-agent-acp", + "authors": ["Anthropic", "Zed Industries", "JetBrains"], + "license": "proprietary", + "distribution": { + "npx": { + "package": "@agentclientprotocol/claude-agent-acp@0.44.0" + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/claude-acp.svg" + }, + { + "id": "cline", + "name": "Cline", + "version": "3.0.24", + "description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more", + "repository": "https://github.com/cline/cline", + "website": "https://cline.bot/cli", + "authors": ["Cline Bot Inc."], + "license": "Apache-2.0", + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/cline.svg", + "distribution": { + "npx": { + "package": "cline@3.0.24", + "args": ["--acp"] + } + } + }, + { + "id": "codebuddy-code", + "name": "Codebuddy Code", + "version": "2.106.4", + "description": "Tencent Cloud's official intelligent coding tool", + "website": "https://www.codebuddy.cn/cli/", + "authors": ["Tencent Cloud"], + "license": "Proprietary", + "distribution": { + "npx": { + "package": "@tencent-ai/codebuddy-code@2.106.4", + "args": ["--acp"] + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/codebuddy-code.svg" + }, + { + "id": "codex-acp", + "name": "Codex CLI", + "version": "0.16.0", + "description": "ACP adapter for OpenAI's coding assistant", + "repository": "https://github.com/zed-industries/codex-acp", + "authors": ["OpenAI", "Zed Industries"], + "license": "Apache-2.0", + "distribution": { + "binary": { + "darwin-aarch64": { + "archive": "https://github.com/zed-industries/codex-acp/releases/download/v0.16.0/codex-acp-0.16.0-aarch64-apple-darwin.tar.gz", + "cmd": "./codex-acp" + }, + "darwin-x86_64": { + "archive": "https://github.com/zed-industries/codex-acp/releases/download/v0.16.0/codex-acp-0.16.0-x86_64-apple-darwin.tar.gz", + "cmd": "./codex-acp" + }, + "linux-aarch64": { + "archive": "https://github.com/zed-industries/codex-acp/releases/download/v0.16.0/codex-acp-0.16.0-aarch64-unknown-linux-gnu.tar.gz", + "cmd": "./codex-acp" + }, + "linux-x86_64": { + "archive": "https://github.com/zed-industries/codex-acp/releases/download/v0.16.0/codex-acp-0.16.0-x86_64-unknown-linux-gnu.tar.gz", + "cmd": "./codex-acp" + }, + "windows-aarch64": { + "archive": "https://github.com/zed-industries/codex-acp/releases/download/v0.16.0/codex-acp-0.16.0-aarch64-pc-windows-msvc.zip", + "cmd": "./codex-acp.exe" + }, + "windows-x86_64": { + "archive": "https://github.com/zed-industries/codex-acp/releases/download/v0.16.0/codex-acp-0.16.0-x86_64-pc-windows-msvc.zip", + "cmd": "./codex-acp.exe" + } + }, + "npx": { + "package": "@zed-industries/codex-acp@0.16.0" + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/codex-acp.svg" + }, + { + "id": "cortex-code", + "name": "Cortex Code", + "version": "1.0.73", + "description": "Snowflake's Cortex Code coding agent", + "repository": "https://docs.snowflake.com/en/user-guide/cortex-code/cortex-code", + "authors": ["Snowflake"], + "license": "proprietary", + "distribution": { + "binary": { + "darwin-aarch64": { + "archive": "https://sfc-repo.snowflakecomputing.com/cortex-code-cli/a4643c4278/1.0.73%2B180523.e6179a031de9/coco-1.0.73%2B180523.e6179a031de9-darwin-arm64.tar.gz", + "cmd": "./coco-1.0.73+180523.e6179a031de9-darwin-arm64/cortex", + "args": ["acp", "serve"] + }, + "darwin-x86_64": { + "archive": "https://sfc-repo.snowflakecomputing.com/cortex-code-cli/a4643c4278/1.0.73%2B180523.e6179a031de9/coco-1.0.73%2B180523.e6179a031de9-darwin-amd64.tar.gz", + "cmd": "./coco-1.0.73+180523.e6179a031de9-darwin-amd64/cortex", + "args": ["acp", "serve"] + }, + "linux-x86_64": { + "archive": "https://sfc-repo.snowflakecomputing.com/cortex-code-cli/a4643c4278/1.0.73%2B180523.e6179a031de9/coco-1.0.73%2B180523.e6179a031de9-linux-amd64.tar.gz", + "cmd": "./coco-1.0.73+180523.e6179a031de9-linux-amd64/cortex", + "args": ["acp", "serve"] + }, + "linux-aarch64": { + "archive": "https://sfc-repo.snowflakecomputing.com/cortex-code-cli/a4643c4278/1.0.73%2B180523.e6179a031de9/coco-1.0.73%2B180523.e6179a031de9-linux-arm64.tar.gz", + "cmd": "./coco-1.0.73+180523.e6179a031de9-linux-arm64/cortex", + "args": ["acp", "serve"] + }, + "windows-x86_64": { + "archive": "https://sfc-repo.snowflakecomputing.com/cortex-code-cli/a4643c4278/1.0.73%2B180523.e6179a031de9/coco-1.0.73%2B180523.e6179a031de9-windows-amd64.tar.gz", + "cmd": "./coco-1.0.73+180523.e6179a031de9-windows-amd64/cortex.exe", + "args": ["acp", "serve"] + }, + "windows-aarch64": { + "archive": "https://sfc-repo.snowflakecomputing.com/cortex-code-cli/a4643c4278/1.0.73%2B180523.e6179a031de9/coco-1.0.73%2B180523.e6179a031de9-windows-arm64.tar.gz", + "cmd": "./coco-1.0.73+180523.e6179a031de9-windows-arm64/cortex.exe", + "args": ["acp", "serve"] + } + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/cortex-code.svg" + }, + { + "id": "corust-agent", + "name": "Corust Agent", + "version": "0.6.0", + "description": "Co-building with a seasoned Rust partner.", + "repository": "https://github.com/Corust-ai/corust-agent-release", + "website": "https://corust.ai/", + "authors": ["Corust AI "], + "license": "GPL-3.0-or-later", + "distribution": { + "binary": { + "darwin-aarch64": { + "archive": "https://github.com/Corust-ai/corust-agent-release/releases/download/v0.6.0/agent-darwin-arm64.tar.gz", + "cmd": "./corust-agent-acp" + }, + "darwin-x86_64": { + "archive": "https://github.com/Corust-ai/corust-agent-release/releases/download/v0.6.0/agent-darwin-x64.tar.gz", + "cmd": "./corust-agent-acp" + }, + "linux-x86_64": { + "archive": "https://github.com/Corust-ai/corust-agent-release/releases/download/v0.6.0/agent-linux-x64.tar.gz", + "cmd": "./corust-agent-acp" + }, + "windows-x86_64": { + "archive": "https://github.com/Corust-ai/corust-agent-release/releases/download/v0.6.0/agent-windows-x64.zip", + "cmd": "./corust-agent-acp.exe" + } + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/corust-agent.svg" + }, + { + "id": "crow-cli", + "name": "crow-cli", + "version": "0.1.24", + "description": "Minimal ACP Native Coding Agent", + "repository": "https://github.com/crow-cli/crow-cli", + "website": "https://crow-ai.dev", + "authors": ["Thomas Wood"], + "license": "Apache-2.0", + "distribution": { + "binary": { + "darwin-aarch64": { + "archive": "https://github.com/crow-cli/crow-cli/releases/download/v0.1.24/crow-cli-darwin-aarch64.tar.gz", + "cmd": "./crow-cli", + "args": ["acp"] + }, + "darwin-x86_64": { + "archive": "https://github.com/crow-cli/crow-cli/releases/download/v0.1.24/crow-cli-darwin-x86_64.tar.gz", + "cmd": "./crow-cli", + "args": ["acp"] + }, + "linux-aarch64": { + "archive": "https://github.com/crow-cli/crow-cli/releases/download/v0.1.24/crow-cli-linux-aarch64.tar.gz", + "cmd": "./crow-cli", + "args": ["acp"] + }, + "linux-x86_64": { + "archive": "https://github.com/crow-cli/crow-cli/releases/download/v0.1.24/crow-cli-linux-x86_64.tar.gz", + "cmd": "./crow-cli", + "args": ["acp"] + }, + "windows-x86_64": { + "archive": "https://github.com/crow-cli/crow-cli/releases/download/v0.1.24/crow-cli-windows-x86_64.zip", + "cmd": "./crow-cli.exe", + "args": ["acp"] + } + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/crow-cli.svg" + }, + { + "id": "cursor", + "name": "Cursor", + "version": "2026.06.15", + "description": "Cursor's coding agent", + "website": "https://cursor.com/docs/cli/acp", + "authors": ["Cursor"], + "license": "proprietary", + "distribution": { + "binary": { + "darwin-aarch64": { + "archive": "https://downloads.cursor.com/lab/2026.06.15-03-48-54-da23e37/darwin/arm64/agent-cli-package.tar.gz", + "cmd": "./dist-package/cursor-agent", + "args": ["acp"] + }, + "darwin-x86_64": { + "archive": "https://downloads.cursor.com/lab/2026.06.15-03-48-54-da23e37/darwin/x64/agent-cli-package.tar.gz", + "cmd": "./dist-package/cursor-agent", + "args": ["acp"] + }, + "linux-aarch64": { + "archive": "https://downloads.cursor.com/lab/2026.06.15-03-48-54-da23e37/linux/arm64/agent-cli-package.tar.gz", + "cmd": "./dist-package/cursor-agent", + "args": ["acp"] + }, + "linux-x86_64": { + "archive": "https://downloads.cursor.com/lab/2026.06.15-03-48-54-da23e37/linux/x64/agent-cli-package.tar.gz", + "cmd": "./dist-package/cursor-agent", + "args": ["acp"] + }, + "windows-aarch64": { + "archive": "https://downloads.cursor.com/lab/2026.06.15-03-48-54-da23e37/windows/arm64/agent-cli-package.zip", + "cmd": "./dist-package\\cursor-agent.cmd", + "args": ["acp"] + }, + "windows-x86_64": { + "archive": "https://downloads.cursor.com/lab/2026.06.15-03-48-54-da23e37/windows/x64/agent-cli-package.zip", + "cmd": "./dist-package\\cursor-agent.cmd", + "args": ["acp"] + } + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/cursor.svg" + }, + { + "id": "deepagents", + "name": "DeepAgents", + "version": "0.1.7", + "description": "Batteries-included AI coding and general purpose agent powered by LangChain.", + "repository": "https://github.com/langchain-ai/deepagentsjs", + "website": "https://docs.langchain.com/oss/javascript/deepagents/overview", + "authors": ["LangChain"], + "license": "MIT", + "distribution": { + "npx": { + "package": "deepagents-acp@0.1.7", + "args": [] + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/deepagents.svg" + }, + { + "id": "devin", + "name": "Devin", + "version": "2026.5.26", + "description": "Devin CLI coding agent by Cognition", + "website": "https://docs.devin.ai/cli", + "authors": ["Cognition"], + "license": "proprietary", + "distribution": { + "binary": { + "darwin-aarch64": { + "archive": "https://static.devin.ai/cli/2026.5.26-8/devin-2026.5.26-8-aarch64-apple-darwin.tar.gz", + "cmd": "./bin/devin", + "args": ["acp"] + }, + "darwin-x86_64": { + "archive": "https://static.devin.ai/cli/2026.5.26-8/devin-2026.5.26-8-x86_64-apple-darwin.tar.gz", + "cmd": "./bin/devin", + "args": ["acp"] + }, + "linux-aarch64": { + "archive": "https://static.devin.ai/cli/2026.5.26-8/devin-2026.5.26-8-aarch64-unknown-linux.tar.gz", + "cmd": "./bin/devin", + "args": ["acp"] + }, + "linux-x86_64": { + "archive": "https://static.devin.ai/cli/2026.5.26-8/devin-2026.5.26-8-x86_64-unknown-linux.tar.gz", + "cmd": "./bin/devin", + "args": ["acp"] + }, + "windows-aarch64": { + "archive": "https://static.devin.ai/cli/2026.5.26-8/devin-2026.5.26-8-aarch64-pc-windows.zip", + "cmd": ".\\bin\\devin.exe", + "args": ["acp"] + }, + "windows-x86_64": { + "archive": "https://static.devin.ai/cli/2026.5.26-8/devin-2026.5.26-8-x86_64-pc-windows.zip", + "cmd": ".\\bin\\devin.exe", + "args": ["acp"] + } + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/devin.svg" + }, + { + "id": "dimcode", + "name": "DimCode", + "version": "0.2.2", + "description": "A coding agent that puts leading models at your command.", + "website": "https://dimcode.dev/docs/acp.html", + "authors": ["ArcShips"], + "license": "proprietary", + "distribution": { + "npx": { + "package": "dimcode@0.2.2", + "args": ["acp"] + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/dimcode.svg" + }, + { + "id": "dirac", + "name": "Dirac", + "version": "0.4.0", + "description": "Reduces API costs by more than 50%, produces better and faster work. Uses Hash anchored parallel edits, AST manipulation and a whole lot of neat optimizations. Fully Open Source.", + "repository": "https://github.com/dirac-run/dirac", + "website": "https://dirac.run", + "authors": ["Dirac Delta Labs"], + "license": "Apache-2.0", + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/dirac.svg", + "distribution": { + "npx": { + "package": "dirac-cli@0.4.0", + "args": ["--acp"] + } + } + }, + { + "id": "factory-droid", + "name": "Factory Droid", + "version": "0.148.0", + "description": "Factory Droid - AI coding agent powered by Factory AI", + "website": "https://factory.ai/product/cli", + "authors": ["Factory AI"], + "license": "proprietary", + "distribution": { + "npx": { + "package": "droid@0.148.0", + "args": ["exec", "--output-format", "acp-daemon"], + "env": { + "DROID_DISABLE_AUTO_UPDATE": "true", + "FACTORY_DROID_AUTO_UPDATE_ENABLED": "false" + } + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/factory-droid.svg" + }, + { + "id": "fast-agent", + "name": "fast-agent", + "version": "0.7.20", + "description": "Code and build agents with comprehensive multi-provider support", + "repository": "https://github.com/evalstate/fast-agent", + "website": "https://fast-agent.ai", + "authors": ["enquiries@fast-agent.ai"], + "license": "Apache 2.0", + "distribution": { + "uvx": { + "package": "fast-agent-acp==0.7.20", + "args": ["-x"] + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/fast-agent.svg" + }, + { + "id": "gemini", + "name": "Gemini CLI", + "version": "0.46.0", + "description": "Google's official CLI for Gemini", + "repository": "https://github.com/google-gemini/gemini-cli", + "website": "https://geminicli.com", + "authors": ["Google"], + "license": "Apache-2.0", + "distribution": { + "npx": { + "package": "@google/gemini-cli@0.46.0", + "args": ["--acp"] + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/gemini.svg" + }, + { + "id": "github-copilot-cli", + "name": "GitHub Copilot", + "version": "1.0.62", + "description": "GitHub's AI pair programmer", + "repository": "https://github.com/github/copilot-cli", + "website": "https://github.com/features/copilot/cli/", + "authors": ["GitHub"], + "license": "proprietary", + "distribution": { + "npx": { + "package": "@github/copilot@1.0.62", + "args": ["--acp"] + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/github-copilot-cli.svg" + }, + { + "id": "glm-acp-agent", + "name": "GLM Agent", + "version": "1.1.4", + "description": "ACP agent powered by Zhipu AI's GLM Coding Plan models (glm-5.1, glm-5-turbo, glm-4.7, glm-4.5-air). Supports streaming, tool calls, mid-session model switching, image input via Z.AI Coding Plan Vision MCP, and session load/fork/resume with on-disk persistence.", + "repository": "https://github.com/stefandevo/glm-acp-agent", + "authors": ["Stefan de Vogelaere"], + "license": "Apache-2.0", + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/glm-acp-agent.svg", + "distribution": { + "npx": { + "package": "glm-acp-agent@1.1.4" + } + } + }, + { + "id": "goose", + "name": "goose", + "version": "1.37.0", + "description": "A local, extensible, open source AI agent that automates engineering tasks", + "repository": "https://github.com/block/goose", + "website": "https://block.github.io/goose/", + "authors": ["Block"], + "license": "Apache-2.0", + "distribution": { + "binary": { + "darwin-aarch64": { + "archive": "https://github.com/block/goose/releases/download/v1.37.0/goose-aarch64-apple-darwin.tar.bz2", + "cmd": "./goose", + "args": ["acp"] + }, + "darwin-x86_64": { + "archive": "https://github.com/block/goose/releases/download/v1.37.0/goose-x86_64-apple-darwin.tar.bz2", + "cmd": "./goose", + "args": ["acp"] + }, + "linux-aarch64": { + "archive": "https://github.com/block/goose/releases/download/v1.37.0/goose-aarch64-unknown-linux-gnu.tar.bz2", + "cmd": "./goose", + "args": ["acp"] + }, + "linux-x86_64": { + "archive": "https://github.com/block/goose/releases/download/v1.37.0/goose-x86_64-unknown-linux-gnu.tar.bz2", + "cmd": "./goose", + "args": ["acp"] + }, + "windows-x86_64": { + "archive": "https://github.com/block/goose/releases/download/v1.37.0/goose-x86_64-pc-windows-msvc.zip", + "cmd": "./goose-package\\goose.exe", + "args": ["acp"] + } + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/goose.svg" + }, + { + "id": "grok-build", + "name": "Grok Build", + "version": "0.2.39", + "description": "xAI's coding agent and CLI", + "website": "https://x.ai/cli", + "authors": ["xAI"], + "license": "proprietary", + "distribution": { + "npx": { + "package": "@xai-official/grok@0.2.39", + "args": ["agent", "stdio"] + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/grok-build.svg" + }, + { + "id": "junie", + "name": "Junie", + "version": "1892.26.0", + "description": "AI Coding Agent by JetBrains", + "repository": "https://github.com/JetBrains/junie", + "website": "https://junie.jetbrains.com", + "authors": ["JetBrains"], + "license": "proprietary", + "distribution": { + "binary": { + "darwin-aarch64": { + "archive": "https://github.com/JetBrains/junie/releases/download/1892.26/junie-release-1892.26-macos-aarch64.zip", + "cmd": "./Applications/junie.app/Contents/MacOS/junie", + "args": ["--acp=true"] + }, + "darwin-x86_64": { + "archive": "https://github.com/JetBrains/junie/releases/download/1892.26/junie-release-1892.26-macos-amd64.zip", + "cmd": "./Applications/junie.app/Contents/MacOS/junie", + "args": ["--acp=true"] + }, + "linux-aarch64": { + "archive": "https://github.com/JetBrains/junie/releases/download/1892.26/junie-release-1892.26-linux-aarch64.zip", + "cmd": "./junie-app/bin/junie", + "args": ["--acp=true"] + }, + "linux-x86_64": { + "archive": "https://github.com/JetBrains/junie/releases/download/1892.26/junie-release-1892.26-linux-amd64.zip", + "cmd": "./junie-app/bin/junie", + "args": ["--acp=true"] + }, + "windows-x86_64": { + "archive": "https://github.com/JetBrains/junie/releases/download/1892.26/junie-release-1892.26-windows-amd64.zip", + "cmd": "./junie/junie.exe", + "args": ["--acp=true"] + } + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/junie.svg" + }, + { + "id": "kilo", + "name": "Kilo", + "version": "7.3.46", + "description": "The open source coding agent", + "repository": "https://github.com/Kilo-Org/kilocode", + "website": "https://kilo.ai/", + "authors": ["Kilo Code"], + "license": "MIT", + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/kilo.svg", + "distribution": { + "binary": { + "darwin-aarch64": { + "archive": "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.46/kilo-darwin-arm64.zip", + "cmd": "./kilo", + "args": ["acp"] + }, + "darwin-x86_64": { + "archive": "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.46/kilo-darwin-x64.zip", + "cmd": "./kilo", + "args": ["acp"] + }, + "linux-aarch64": { + "archive": "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.46/kilo-linux-arm64.tar.gz", + "cmd": "./kilo", + "args": ["acp"] + }, + "linux-x86_64": { + "archive": "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.46/kilo-linux-x64.tar.gz", + "cmd": "./kilo", + "args": ["acp"] + }, + "windows-x86_64": { + "archive": "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.46/kilo-windows-x64.zip", + "cmd": "./kilo.exe", + "args": ["acp"] + } + }, + "npx": { + "package": "@kilocode/cli@7.3.46", + "args": ["acp"] + } + } + }, + { + "id": "kimi", + "name": "Kimi CLI", + "version": "1.47.0", + "description": "Moonshot AI's coding assistant", + "repository": "https://github.com/MoonshotAI/kimi-cli", + "website": "https://moonshotai.github.io/kimi-cli/", + "authors": ["Moonshot AI"], + "license": "MIT", + "distribution": { + "binary": { + "darwin-aarch64": { + "archive": "https://github.com/MoonshotAI/kimi-cli/releases/download/1.47.0/kimi-1.47.0-aarch64-apple-darwin.tar.gz", + "cmd": "./kimi", + "args": ["acp"] + }, + "linux-aarch64": { + "archive": "https://github.com/MoonshotAI/kimi-cli/releases/download/1.47.0/kimi-1.47.0-aarch64-unknown-linux-gnu.tar.gz", + "cmd": "./kimi", + "args": ["acp"] + }, + "linux-x86_64": { + "archive": "https://github.com/MoonshotAI/kimi-cli/releases/download/1.47.0/kimi-1.47.0-x86_64-unknown-linux-gnu.tar.gz", + "cmd": "./kimi", + "args": ["acp"] + }, + "windows-aarch64": { + "archive": "https://github.com/MoonshotAI/kimi-cli/releases/download/1.47.0/kimi-1.47.0-aarch64-pc-windows-msvc.zip", + "cmd": "./kimi.exe", + "args": ["acp"] + }, + "windows-x86_64": { + "archive": "https://github.com/MoonshotAI/kimi-cli/releases/download/1.47.0/kimi-1.47.0-x86_64-pc-windows-msvc.zip", + "cmd": "./kimi.exe", + "args": ["acp"] + } + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/kimi.svg" + }, + { + "id": "minion-code", + "name": "Minion Code", + "version": "0.1.44", + "description": "An enhanced AI code assistant built on the Minion framework with rich development tools", + "repository": "https://github.com/femto/minion-code", + "authors": ["femto"], + "license": "AGPL-3.0", + "distribution": { + "uvx": { + "package": "minion-code@0.1.44", + "args": ["acp"] + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/minion-code.svg" + }, + { + "id": "mistral-vibe", + "name": "Mistral Vibe", + "version": "2.15.0", + "description": "Mistral's open-source coding assistant", + "repository": "https://github.com/mistralai/mistral-vibe", + "website": "https://mistral.ai/products/vibe", + "authors": ["Mistral AI"], + "license": "Apache-2.0", + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/mistral-vibe.svg", + "distribution": { + "binary": { + "darwin-aarch64": { + "archive": "https://github.com/mistralai/mistral-vibe/releases/download/v2.15.0/vibe-acp-darwin-aarch64-2.15.0.zip", + "cmd": "./vibe-acp" + }, + "darwin-x86_64": { + "archive": "https://github.com/mistralai/mistral-vibe/releases/download/v2.15.0/vibe-acp-darwin-x86_64-2.15.0.zip", + "cmd": "./vibe-acp" + }, + "linux-aarch64": { + "archive": "https://github.com/mistralai/mistral-vibe/releases/download/v2.15.0/vibe-acp-linux-aarch64-2.15.0.zip", + "cmd": "./vibe-acp" + }, + "linux-x86_64": { + "archive": "https://github.com/mistralai/mistral-vibe/releases/download/v2.15.0/vibe-acp-linux-x86_64-2.15.0.zip", + "cmd": "./vibe-acp" + }, + "windows-x86_64": { + "archive": "https://github.com/mistralai/mistral-vibe/releases/download/v2.15.0/vibe-acp-windows-x86_64-2.15.0.zip", + "cmd": "./vibe-acp.exe" + } + } + } + }, + { + "id": "nova", + "name": "Nova", + "version": "1.1.18", + "description": "Nova by Compass AI - a fully-fledged software engineer at your command", + "repository": "https://github.com/Compass-Agentic-Platform/nova", + "website": "https://www.compassap.ai/portfolio/nova.html", + "authors": ["Compass AI"], + "license": "proprietary", + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/nova.svg", + "distribution": { + "npx": { + "package": "@compass-ai/nova@1.1.18", + "args": ["acp"] + } + } + }, + { + "id": "opencode", + "name": "OpenCode", + "version": "1.17.7", + "description": "The open source coding agent", + "repository": "https://github.com/anomalyco/opencode", + "website": "https://opencode.ai", + "authors": ["Anomaly"], + "license": "MIT", + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/opencode.svg", + "distribution": { + "binary": { + "darwin-aarch64": { + "archive": "https://github.com/anomalyco/opencode/releases/download/v1.17.7/opencode-darwin-arm64.zip", + "cmd": "./opencode", + "args": ["acp"] + }, + "darwin-x86_64": { + "archive": "https://github.com/anomalyco/opencode/releases/download/v1.17.7/opencode-darwin-x64.zip", + "cmd": "./opencode", + "args": ["acp"] + }, + "linux-aarch64": { + "archive": "https://github.com/anomalyco/opencode/releases/download/v1.17.7/opencode-linux-arm64.tar.gz", + "cmd": "./opencode", + "args": ["acp"] + }, + "linux-x86_64": { + "archive": "https://github.com/anomalyco/opencode/releases/download/v1.17.7/opencode-linux-x64.tar.gz", + "cmd": "./opencode", + "args": ["acp"] + }, + "windows-aarch64": { + "archive": "https://github.com/anomalyco/opencode/releases/download/v1.17.7/opencode-windows-arm64.zip", + "cmd": "./opencode", + "args": ["acp"] + }, + "windows-x86_64": { + "archive": "https://github.com/anomalyco/opencode/releases/download/v1.17.7/opencode-windows-x64.zip", + "cmd": "./opencode.exe", + "args": ["acp"] + } + } + } + }, + { + "id": "pi-acp", + "name": "pi ACP", + "version": "0.0.28", + "description": "ACP adapter for pi coding agent", + "repository": "https://github.com/svkozak/pi-acp", + "authors": ["Sergii Kozak "], + "license": "MIT", + "distribution": { + "npx": { + "package": "pi-acp@0.0.28" + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/pi-acp.svg" + }, + { + "id": "poolside", + "name": "Poolside", + "version": "1.0.5", + "description": "Poolside's coding agent", + "repository": "https://github.com/poolsideai/pool", + "website": "https://poolside.ai", + "authors": ["Poolside "], + "license": "proprietary", + "distribution": { + "binary": { + "darwin-aarch64": { + "archive": "https://downloads.poolside.ai/pool/v1.0.5/pool-darwin-arm64.tar.gz", + "cmd": "./pool-darwin-arm64", + "args": ["acp"] + }, + "darwin-x86_64": { + "archive": "https://downloads.poolside.ai/pool/v1.0.5/pool-darwin-amd64.tar.gz", + "cmd": "./pool-darwin-amd64", + "args": ["acp"] + }, + "linux-aarch64": { + "archive": "https://downloads.poolside.ai/pool/v1.0.5/pool-linux-arm64.tar.gz", + "cmd": "./pool-linux-arm64", + "args": ["acp"] + }, + "linux-x86_64": { + "archive": "https://downloads.poolside.ai/pool/v1.0.5/pool-linux-amd64.tar.gz", + "cmd": "./pool-linux-amd64", + "args": ["acp"] + }, + "windows-aarch64": { + "archive": "https://downloads.poolside.ai/pool/v1.0.5/pool-windows-arm64.tar.gz", + "cmd": "./pool-windows-arm64.exe", + "args": ["acp"] + }, + "windows-x86_64": { + "archive": "https://downloads.poolside.ai/pool/v1.0.5/pool-windows-amd64.tar.gz", + "cmd": "./pool-windows-amd64.exe", + "args": ["acp"] + } + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/poolside.svg" + }, + { + "id": "qoder", + "name": "Qoder CLI", + "version": "0.2.14", + "description": "AI coding assistant with agentic capabilities", + "website": "https://qoder.com", + "authors": ["Qoder AI"], + "license": "proprietary", + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/qoder.svg", + "distribution": { + "npx": { + "package": "@qoder-ai/qodercli@0.2.14", + "args": ["--acp"] + } + } + }, + { + "id": "qwen-code", + "name": "Qwen Code", + "version": "0.18.1", + "description": "Alibaba's Qwen coding assistant", + "repository": "https://github.com/QwenLM/qwen-code", + "website": "https://qwenlm.github.io/qwen-code-docs/en/users/overview", + "authors": ["Alibaba Qwen Team"], + "license": "Apache-2.0", + "distribution": { + "npx": { + "package": "@qwen-code/qwen-code@0.18.1", + "args": ["--acp", "--experimental-skills"] + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/qwen-code.svg" + }, + { + "id": "sigit", + "name": "siGit Code", + "version": "1.1.0", + "description": "Local-first coding agent. Runs entirely on your machine with optional on-device LLM inference via Onde.", + "repository": "https://github.com/getsigit/sigit", + "website": "https://github.com/getsigit/sigit", + "authors": ["smbCloud"], + "license": "Apache-2.0", + "distribution": { + "binary": { + "darwin-aarch64": { + "archive": "https://github.com/getsigit/sigit/releases/download/v1.1.0/sigit-macos-arm64.tar.gz", + "cmd": "./sigit" + }, + "darwin-x86_64": { + "archive": "https://github.com/getsigit/sigit/releases/download/v1.1.0/sigit-macos-amd64.tar.gz", + "cmd": "./sigit" + }, + "linux-aarch64": { + "archive": "https://github.com/getsigit/sigit/releases/download/v1.1.0/sigit-linux-arm64", + "cmd": "./sigit-linux-arm64" + }, + "linux-x86_64": { + "archive": "https://github.com/getsigit/sigit/releases/download/v1.1.0/sigit-linux-amd64", + "cmd": "./sigit-linux-amd64" + }, + "windows-aarch64": { + "archive": "https://github.com/getsigit/sigit/releases/download/v1.1.0/sigit-win-arm64.exe", + "cmd": "./sigit-win-arm64.exe" + }, + "windows-x86_64": { + "archive": "https://github.com/getsigit/sigit/releases/download/v1.1.0/sigit-win-amd64.exe", + "cmd": "./sigit-win-amd64.exe" + } + }, + "npx": { + "package": "@smbcloud/sigit@1.1.0" + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/sigit.svg" + }, + { + "id": "stakpak", + "name": "Stakpak", + "version": "0.3.88", + "description": "Open-source DevOps agent in Rust with enterprise-grade security", + "repository": "https://github.com/stakpak/agent", + "website": "https://stakpak.dev", + "authors": ["Stakpak Team "], + "license": "Apache-2.0", + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/stakpak.svg", + "distribution": { + "binary": { + "darwin-aarch64": { + "archive": "https://github.com/stakpak/agent/releases/download/v0.3.88/stakpak-darwin-aarch64.tar.gz", + "cmd": "./stakpak", + "args": ["acp"] + }, + "darwin-x86_64": { + "archive": "https://github.com/stakpak/agent/releases/download/v0.3.88/stakpak-darwin-x86_64.tar.gz", + "cmd": "./stakpak", + "args": ["acp"] + }, + "linux-aarch64": { + "archive": "https://github.com/stakpak/agent/releases/download/v0.3.88/stakpak-linux-aarch64.tar.gz", + "cmd": "./stakpak", + "args": ["acp"] + }, + "linux-x86_64": { + "archive": "https://github.com/stakpak/agent/releases/download/v0.3.88/stakpak-linux-x86_64.tar.gz", + "cmd": "./stakpak", + "args": ["acp"] + }, + "windows-x86_64": { + "archive": "https://github.com/stakpak/agent/releases/download/v0.3.88/stakpak-windows-x86_64.zip", + "cmd": "./stakpak.exe", + "args": ["acp"] + } + } + } + }, + { + "id": "vtcode", + "name": "VT Code", + "version": "0.96.14", + "description": "An open-source coding agent with LLM-native code understanding and robust shell safety. Supports multiple LLM providers with automatic failover and efficient context management.", + "repository": "https://github.com/vinhnx/VTCode", + "website": "https://github.com/vinhnx/VTCode/blob/main/docs/guides/zed-acp.md", + "authors": ["vinhnx"], + "license": "MIT", + "distribution": { + "binary": { + "darwin-aarch64": { + "archive": "https://github.com/vinhnx/VTCode/releases/download/0.96.14/vtcode-0.96.14-aarch64-apple-darwin.tar.gz", + "cmd": "./vtcode", + "args": ["acp"], + "env": { + "VT_ACP_ENABLED": "1", + "VT_ACP_ZED_ENABLED": "1" + } + }, + "darwin-x86_64": { + "archive": "https://github.com/vinhnx/VTCode/releases/download/0.96.14/vtcode-0.96.14-x86_64-apple-darwin.tar.gz", + "cmd": "./vtcode", + "args": ["acp"], + "env": { + "VT_ACP_ENABLED": "1", + "VT_ACP_ZED_ENABLED": "1" + } + }, + "linux-x86_64": { + "archive": "https://github.com/vinhnx/VTCode/releases/download/0.96.14/vtcode-0.96.14-x86_64-unknown-linux-gnu.tar.gz", + "cmd": "./vtcode", + "args": ["acp"], + "env": { + "VT_ACP_ENABLED": "1", + "VT_ACP_ZED_ENABLED": "1" + } + }, + "windows-x86_64": { + "archive": "https://github.com/vinhnx/VTCode/releases/download/0.96.14/vtcode-0.96.14-x86_64-pc-windows-msvc.zip", + "cmd": "vtcode.exe", + "args": ["acp"], + "env": { + "VT_ACP_ENABLED": "1", + "VT_ACP_ZED_ENABLED": "1" + } + } + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/vtcode.svg" + } + ], + "extensions": [] +} diff --git a/src/defaults/agent-registry.ts b/src/defaults/agent-registry.ts new file mode 100644 index 000000000..a001c0a79 --- /dev/null +++ b/src/defaults/agent-registry.ts @@ -0,0 +1,16 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { parseRegistryJson } from '@/lib/agent-registry-filter' +import type { RegistryEntry } from '@/types/registry' +import snapshot from './acp-registry-snapshot.json' + +/** + * Bundled snapshot of the official ACP registry + * (https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json), + * parsed into typed entries. Shipped so the catalogue renders instantly and + * works offline; the live CDN fetch (see `useAgentRegistry`) refreshes it in the + * background and falls back here on any error. + */ +export const agentRegistrySnapshot: ReadonlyArray = parseRegistryJson(snapshot) diff --git a/src/hooks/use-agent-registry-search.test.ts b/src/hooks/use-agent-registry-search.test.ts new file mode 100644 index 000000000..0f9b86f71 --- /dev/null +++ b/src/hooks/use-agent-registry-search.test.ts @@ -0,0 +1,65 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { act, renderHook } from '@testing-library/react' +import { describe, expect, it } from 'bun:test' +import type { RegistryEntry } from '@/types/registry' +import { useAgentRegistrySearch } from './use-agent-registry-search' + +const entry = (id: string, name: string, description: string): RegistryEntry => ({ + id, + name, + description, + version: '1.0.0', + authors: ['Author'], + license: 'Apache-2.0', + distribution: { npx: { package: `${id}@1.0.0` } }, +}) + +const entries: ReadonlyArray = [ + entry('goose', 'goose', 'Extensible agent from Block'), + entry('gemini', 'Gemini CLI', 'Google terminal agent'), +] + +describe('useAgentRegistrySearch', () => { + it('returns all entries with an empty query', () => { + const { result } = renderHook(() => useAgentRegistrySearch(entries)) + expect(result.current.results).toEqual(entries) + expect(result.current.isEmpty).toBe(false) + }) + + it('returns all entries for a whitespace-only query', () => { + const { result } = renderHook(() => useAgentRegistrySearch(entries)) + + act(() => { + result.current.setQuery(' ') + }) + + expect(result.current.results).toEqual(entries) + expect(result.current.isEmpty).toBe(false) + }) + + it('derives results as the query changes', () => { + const { result } = renderHook(() => useAgentRegistrySearch(entries)) + + act(() => { + result.current.setQuery('gemini') + }) + + expect(result.current.results).toHaveLength(1) + expect(result.current.results[0]?.id).toBe('gemini') + expect(result.current.isEmpty).toBe(false) + }) + + it('is empty when nothing matches', () => { + const { result } = renderHook(() => useAgentRegistrySearch(entries)) + + act(() => { + result.current.setQuery('zzzqqqxx') + }) + + expect(result.current.results).toEqual([]) + expect(result.current.isEmpty).toBe(true) + }) +}) diff --git a/src/hooks/use-agent-registry-search.ts b/src/hooks/use-agent-registry-search.ts new file mode 100644 index 000000000..99e03d297 --- /dev/null +++ b/src/hooks/use-agent-registry-search.ts @@ -0,0 +1,17 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { useMemo, useState } from 'react' +import { filterRegistry } from '@/lib/agent-registry-filter' +import type { RegistryEntry } from '@/types/registry' + +/** + * Owns the catalogue's search query and derives the filtered results during + * render — no effect, since the results are pure state of `entries` + `query`. + */ +export const useAgentRegistrySearch = (entries: ReadonlyArray) => { + const [query, setQuery] = useState('') + const results = useMemo(() => filterRegistry(entries, query), [entries, query]) + return { query, setQuery, results, isEmpty: results.length === 0 } +} diff --git a/src/hooks/use-agent-registry.test.tsx b/src/hooks/use-agent-registry.test.tsx new file mode 100644 index 000000000..05243e5b3 --- /dev/null +++ b/src/hooks/use-agent-registry.test.tsx @@ -0,0 +1,58 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { act, renderHook } from '@testing-library/react' +import { describe, expect, it } from 'bun:test' +import { agentRegistrySnapshot } from '@/defaults/agent-registry' +import type { FetchFn } from '@/lib/proxy-fetch' +import { getClock } from '@/testing-library' +import { createQueryTestWrapper } from '@/test-utils/react-query' +import { useAgentRegistry } from './use-agent-registry' + +/** A `FetchFn` that always resolves to `body` as a JSON `Response`. */ +const proxyFetchReturning = (body: unknown): FetchFn => + Object.assign( + async () => new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } }), + { preconnect: () => Promise.resolve(false) }, + ) as FetchFn + +describe('useAgentRegistry', () => { + it('returns the bundled snapshot immediately as initialData', () => { + const { result } = renderHook(() => useAgentRegistry(), { wrapper: createQueryTestWrapper() }) + expect(result.current).toBe(agentRegistrySnapshot) + }) + + it('updates with the live registry when the proxy fetch succeeds', async () => { + const liveRegistry = { + version: '9.9.9', + agents: [{ id: 'live-only', name: 'Live Only', distribution: {} }], + } + const wrapper = createQueryTestWrapper({ proxyFetch: proxyFetchReturning(liveRegistry) }) + const { result } = renderHook(() => useAgentRegistry(), { wrapper }) + + // Seeded immediately from the snapshot... + expect(result.current).toBe(agentRegistrySnapshot) + + // ...then refreshed from the live proxy response. (The global fake-timer + // setup makes refetch-on-mount timing unreliable, so drive it explicitly.) + await act(async () => { + await wrapper.queryClient.refetchQueries({ queryKey: ['acp-agent-registry'] }) + await getClock().runAllAsync() + }) + + expect(result.current.map((entry) => entry.id)).toEqual(['live-only']) + }) + + it('keeps the snapshot when the live response is empty (degenerate-response guard)', async () => { + const wrapper = createQueryTestWrapper({ proxyFetch: proxyFetchReturning({ agents: [] }) }) + const { result } = renderHook(() => useAgentRegistry(), { wrapper }) + + await act(async () => { + await wrapper.queryClient.refetchQueries({ queryKey: ['acp-agent-registry'] }) + await getClock().runAllAsync() + }) + + expect(result.current).toEqual(agentRegistrySnapshot) + }) +}) diff --git a/src/hooks/use-agent-registry.ts b/src/hooks/use-agent-registry.ts new file mode 100644 index 000000000..fbec990f3 --- /dev/null +++ b/src/hooks/use-agent-registry.ts @@ -0,0 +1,47 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { agentRegistrySnapshot } from '@/defaults/agent-registry' +import { parseRegistryJson } from '@/lib/agent-registry-filter' +import type { FetchFn } from '@/lib/proxy-fetch' +import { useFetch } from '@/lib/proxy-fetch-context' +import type { RegistryEntry } from '@/types/registry' +import { useQuery } from '@tanstack/react-query' + +/** The official, machine-readable ACP registry. */ +export const acpRegistryUrl = 'https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json' + +const staleTime = 5 * 60 * 1000 // 5 minutes + +/** + * Fetches the live registry through the universal proxy. The CDN sends no + * `access-control-allow-origin`, so a direct browser fetch is CORS-blocked — + * the proxy fetch (Hosted: `/v1/proxy`; Standalone: upstream-direct) is the + * only way a browser can read it. A degenerate (empty) live response falls back + * to the snapshot so a bad CDN payload can never blank the catalogue. + */ +const fetchAgentRegistry = (proxyFetch: FetchFn) => async (): Promise> => { + const response = await proxyFetch(acpRegistryUrl) + const parsed = parseRegistryJson(await response.json()) + return parsed.length > 0 ? parsed : agentRegistrySnapshot +} + +/** + * Returns the ACP agent catalogue. The bundled snapshot is the immediate seed + * (`initialData`), so the array is always non-empty and the UI renders instantly + * even offline. React Query refreshes from the live CDN through the universal + * proxy in the background; on any fetch/parse error it keeps the last good data, + * which falls back to the snapshot. Anonymous / offline / proxy-unavailable users + * therefore always see the snapshot. + */ +export const useAgentRegistry = (): ReadonlyArray => { + const proxyFetch = useFetch() + const { data } = useQuery({ + queryKey: ['acp-agent-registry'], + queryFn: fetchAgentRegistry(proxyFetch), + initialData: agentRegistrySnapshot, + staleTime, + }) + return data +} diff --git a/src/lib/agent-registry-filter.test.ts b/src/lib/agent-registry-filter.test.ts new file mode 100644 index 000000000..71f90ae2e --- /dev/null +++ b/src/lib/agent-registry-filter.test.ts @@ -0,0 +1,215 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { describe, expect, it } from 'bun:test' +import type { RegistryEntry } from '@/types/registry' +import { + distributionLabel, + filterRegistry, + normalizeQuery, + parseRegistryJson, + primaryDistributionKind, +} from './agent-registry-filter' + +const makeEntry = (overrides: Partial = {}): RegistryEntry => ({ + id: 'goose', + name: 'goose', + version: '1.0.0', + description: 'Extensible open-source AI agent from Block', + authors: ['Block'], + license: 'Apache-2.0', + repository: 'https://github.com/block/goose', + website: 'https://block.github.io/goose/', + icon: 'https://cdn.example.com/goose.svg', + distribution: { npx: { package: 'goose@1.0.0' } }, + ...overrides, +}) + +const entries: ReadonlyArray = [ + makeEntry({ id: 'goose', name: 'goose', description: 'Extensible agent from Block', authors: ['Block'] }), + makeEntry({ id: 'gemini', name: 'Gemini CLI', description: 'Google terminal agent', authors: ['Google'] }), + makeEntry({ id: 'claude-acp', name: 'Claude Code', description: 'Anthropic coding tool', authors: ['Anthropic'] }), +] + +describe('parseRegistryJson', () => { + it('parses a valid registry object into typed entries', () => { + const raw = { + version: '1.0.0', + agents: [ + { + id: 'claude-acp', + name: 'Claude Agent', + version: '0.44.0', + description: 'ACP wrapper', + authors: ['Anthropic'], + license: 'proprietary', + repository: 'https://github.com/x/y', + icon: 'https://cdn/x.svg', + distribution: { npx: { package: '@x/y@0.44.0', args: ['--acp'] } }, + }, + ], + } + const parsed = parseRegistryJson(raw) + expect(parsed).toHaveLength(1) + expect(parsed[0]).toEqual({ + id: 'claude-acp', + name: 'Claude Agent', + version: '0.44.0', + description: 'ACP wrapper', + authors: ['Anthropic'], + license: 'proprietary', + repository: 'https://github.com/x/y', + website: undefined, + icon: 'https://cdn/x.svg', + distribution: { npx: { package: '@x/y@0.44.0', args: ['--acp'] } }, + }) + }) + + it('accepts a bare entry array', () => { + const parsed = parseRegistryJson([{ id: 'a', name: 'A', distribution: {} }]) + expect(parsed).toHaveLength(1) + expect(parsed[0]?.id).toBe('a') + }) + + it('drops entries missing id or name', () => { + const parsed = parseRegistryJson({ + agents: [{ id: 'ok', name: 'Ok', distribution: {} }, { name: 'No id' }, { id: 'no-name' }, 'garbage', null], + }) + expect(parsed.map((entry) => entry.id)).toEqual(['ok']) + }) + + it('defaults missing optional fields and normalizes distribution', () => { + const parsed = parseRegistryJson([{ id: 'min', name: 'Min', distribution: { uvx: { package: 'min' } } }]) + expect(parsed[0]).toEqual({ + id: 'min', + name: 'Min', + version: '', + description: '', + authors: [], + license: '', + repository: undefined, + website: undefined, + icon: undefined, + distribution: { uvx: { package: 'min', args: [] } }, + }) + }) + + it('drops non-http(s) repository / website / icon URLs (javascript:/data: injection)', () => { + const parsed = parseRegistryJson([ + { + id: 'evil', + name: 'Evil', + repository: 'data:text/html,', + website: 'javascript:alert(1)', + icon: 'javascript:alert(document.cookie)', + distribution: {}, + }, + ]) + expect(parsed[0]?.repository).toBeUndefined() + expect(parsed[0]?.website).toBeUndefined() + expect(parsed[0]?.icon).toBeUndefined() + }) + + it('keeps valid http(s) repository / website / icon URLs', () => { + const parsed = parseRegistryJson([ + { + id: 'safe', + name: 'Safe', + repository: 'https://github.com/x/y', + website: 'http://example.com', + icon: 'https://cdn.example.com/x.svg', + distribution: {}, + }, + ]) + expect(parsed[0]?.repository).toBe('https://github.com/x/y') + expect(parsed[0]?.website).toBe('http://example.com') + expect(parsed[0]?.icon).toBe('https://cdn.example.com/x.svg') + }) + + it('returns [] for non-array / garbage input', () => { + expect(parseRegistryJson(null)).toEqual([]) + expect(parseRegistryJson(undefined)).toEqual([]) + expect(parseRegistryJson(42)).toEqual([]) + expect(parseRegistryJson('nope')).toEqual([]) + expect(parseRegistryJson({})).toEqual([]) + expect(parseRegistryJson({ agents: 'not-an-array' })).toEqual([]) + }) +}) + +describe('primaryDistributionKind', () => { + it('prefers npx over uvx and binary', () => { + expect(primaryDistributionKind(makeEntry({ distribution: { npx: { package: 'a' }, uvx: { package: 'b' } } }))).toBe( + 'npx', + ) + }) + + it('falls back to uvx then binary', () => { + expect(primaryDistributionKind(makeEntry({ distribution: { uvx: { package: 'b' } } }))).toBe('uvx') + expect(primaryDistributionKind(makeEntry({ distribution: { binary: { 'darwin-aarch64': {} } } }))).toBe('binary') + }) + + it('returns null when there is no distribution', () => { + expect(primaryDistributionKind(makeEntry({ distribution: {} }))).toBeNull() + }) +}) + +describe('distributionLabel', () => { + it('maps kinds to human labels', () => { + expect(distributionLabel('npx')).toBe('Node.js') + expect(distributionLabel('uvx')).toBe('Python') + expect(distributionLabel('binary')).toBe('Binary') + }) +}) + +describe('normalizeQuery', () => { + it('trims and lowercases', () => { + expect(normalizeQuery(' GoOSE ')).toBe('goose') + }) + + it('returns empty string for whitespace-only input', () => { + expect(normalizeQuery(' ')).toBe('') + }) +}) + +describe('filterRegistry', () => { + it('returns all entries for an empty query', () => { + expect(filterRegistry(entries, '')).toEqual(entries) + }) + + it('returns all entries for a whitespace-only query', () => { + expect(filterRegistry(entries, ' ')).toEqual(entries) + }) + + it('matches on name case-insensitively', () => { + const result = filterRegistry(entries, 'GEMINI') + expect(result).toHaveLength(1) + expect(result[0]?.id).toBe('gemini') + }) + + it('matches on description', () => { + const result = filterRegistry(entries, 'anthropic') + expect(result).toHaveLength(1) + expect(result[0]?.id).toBe('claude-acp') + }) + + it('matches on id', () => { + const result = filterRegistry(entries, 'claude-acp') + expect(result.map((entry) => entry.id)).toEqual(['claude-acp']) + }) + + it('matches on authors', () => { + const result = filterRegistry(entries, 'google') + expect(result.map((entry) => entry.id)).toEqual(['gemini']) + }) + + it('returns an empty array when nothing matches', () => { + expect(filterRegistry(entries, 'zzzqqqxx')).toEqual([]) + }) + + it('returns multiple matches (but not all)', () => { + // "agent" appears in the goose and gemini descriptions only. + const result = filterRegistry(entries, 'agent') + expect(result.map((entry) => entry.id)).toEqual(['goose', 'gemini']) + }) +}) diff --git a/src/lib/agent-registry-filter.ts b/src/lib/agent-registry-filter.ts new file mode 100644 index 000000000..960de8638 --- /dev/null +++ b/src/lib/agent-registry-filter.ts @@ -0,0 +1,125 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import type { RegistryDistribution, RegistryEntry } from '@/types/registry' + +/** A distribution kind we surface as a badge. `binary` covers any platform map. */ +export type DistributionKind = 'npx' | 'uvx' | 'binary' + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value) + +const asStringArray = (value: unknown): ReadonlyArray => + Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : [] + +/** Accepts a value only when it's an `http(s)` URL, dropping anything else to + * `undefined`. The registry is untrusted network data, so this keeps + * `javascript:` / `data:` payloads out of `` and ``. */ +const asHttpUrl = (value: unknown): string | undefined => + typeof value === 'string' && /^https?:\/\//i.test(value) ? value : undefined + +/** Parse the registry's `distribution` object, dropping anything malformed. */ +const parseDistribution = (raw: unknown): RegistryDistribution => { + if (!isRecord(raw)) { + return {} + } + const distribution: RegistryDistribution = {} + if (isRecord(raw.npx) && typeof raw.npx.package === 'string') { + distribution.npx = { package: raw.npx.package, args: asStringArray(raw.npx.args) } + } + if (isRecord(raw.uvx) && typeof raw.uvx.package === 'string') { + distribution.uvx = { package: raw.uvx.package, args: asStringArray(raw.uvx.args) } + } + if (isRecord(raw.binary)) { + distribution.binary = raw.binary + } + return distribution +} + +const parseEntry = (raw: unknown): RegistryEntry | null => { + if (!isRecord(raw) || typeof raw.id !== 'string' || typeof raw.name !== 'string') { + return null + } + return { + id: raw.id, + name: raw.name, + version: typeof raw.version === 'string' ? raw.version : '', + description: typeof raw.description === 'string' ? raw.description : '', + authors: asStringArray(raw.authors), + license: typeof raw.license === 'string' ? raw.license : '', + repository: asHttpUrl(raw.repository), + website: asHttpUrl(raw.website), + icon: asHttpUrl(raw.icon), + distribution: parseDistribution(raw.distribution), + } +} + +/** + * Defensively normalizes untrusted registry JSON (live CDN fetch or bundled + * snapshot) into a typed `RegistryEntry[]`. Accepts either the raw registry + * object (`{ agents: [...] }`) or a bare entry array; drops any entry missing an + * `id` or `name`. This is the one place defensive parsing belongs — the input is + * network data we don't control. + */ +export const parseRegistryJson = (raw: unknown): ReadonlyArray => { + const agents = Array.isArray(raw) ? raw : isRecord(raw) ? raw.agents : null + if (!Array.isArray(agents)) { + return [] + } + return agents.map(parseEntry).filter((entry): entry is RegistryEntry => entry !== null) +} + +/** The distribution kind to surface on a card, preferring npx > uvx > binary. */ +export const primaryDistributionKind = (entry: RegistryEntry): DistributionKind | null => { + if (entry.distribution.npx) { + return 'npx' + } + if (entry.distribution.uvx) { + return 'uvx' + } + if (entry.distribution.binary) { + return 'binary' + } + return null +} + +/** Human-readable badge label for a distribution kind. */ +export const distributionLabel = (kind: DistributionKind): string => { + switch (kind) { + case 'npx': + return 'Node.js' + case 'uvx': + return 'Python' + case 'binary': + return 'Binary' + } +} + +/** + * Normalizes a raw search query for matching: trims surrounding whitespace and + * lowercases so comparisons are case-insensitive. + */ +export const normalizeQuery = (q: string): string => q.trim().toLowerCase() + +/** + * Filters registry entries by a search query. An empty or whitespace-only query + * returns every entry; otherwise entries whose name, description, id, or authors + * contain the query (case-insensitive) are kept. + * + * Matching is plain substring containment with no ranking — by design, the + * catalogue is small enough that relevance ordering adds no value. + */ +export const filterRegistry = (entries: ReadonlyArray, query: string): ReadonlyArray => { + const normalized = normalizeQuery(query) + if (normalized.length === 0) { + return entries + } + return entries.filter( + (entry) => + entry.name.toLowerCase().includes(normalized) || + entry.description.toLowerCase().includes(normalized) || + entry.id.toLowerCase().includes(normalized) || + entry.authors.some((author) => author.toLowerCase().includes(normalized)), + ) +} diff --git a/src/test-utils/react-query.tsx b/src/test-utils/react-query.tsx index cdeebc9ee..a1daad824 100644 --- a/src/test-utils/react-query.tsx +++ b/src/test-utils/react-query.tsx @@ -26,6 +26,9 @@ export const createQueryTestWrapper = (options?: { staleTime?: number } } + /** Override the proxy fetch so hooks that fetch through the universal proxy + * can assert on a mocked response. Defaults to a no-op empty `Response`. */ + proxyFetch?: FetchFn }) => { const queryClient = new QueryClient({ defaultOptions: { @@ -39,13 +42,14 @@ export const createQueryTestWrapper = (options?: { }) const mockHttpClient = createMockHttpClient() + const proxyFetch = options?.proxyFetch ?? mockProxyFetch - return ({ children }: { children: ReactNode }) => { + const Wrapper = ({ children }: { children: ReactNode }) => { const inner = ( - {children} + {children} @@ -55,6 +59,9 @@ export const createQueryTestWrapper = (options?: { } return inner } + // Expose the client so tests can drive explicit refetch/invalidation when the + // global fake-timer setup makes automatic refetch-on-mount timing unreliable. + return Object.assign(Wrapper, { queryClient }) } /** diff --git a/src/types/registry.ts b/src/types/registry.ts new file mode 100644 index 000000000..60a723eb6 --- /dev/null +++ b/src/types/registry.ts @@ -0,0 +1,41 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Types mirroring the official Agent Client Protocol registry + * (https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json). + * + * Every shipped agent is a CLI (npx / uvx / binary). In Thunderbolt's + * remote-only model there is nothing to install locally — the catalogue is a + * read-only directory of "bridge" agents whose cards link out to their own + * websites and source repositories. + */ + +/** How an agent is distributed. Mirrors the registry's `distribution` object; + * `binary` is a per-platform record we only surface as a "Binary" badge, so its + * inner shape is left opaque. */ +export type RegistryDistribution = { + npx?: { package: string; args?: ReadonlyArray } + uvx?: { package: string; args?: ReadonlyArray } + binary?: Readonly> +} + +export type RegistryEntry = { + id: string + name: string + version: string + description: string + authors: ReadonlyArray + license: string + repository?: string + website?: string + icon?: string + distribution: RegistryDistribution +} + +export type AgentRegistry = { + version: string + agents: ReadonlyArray + extensions?: ReadonlyArray +} From e5279a3416ddae408e78c1ac6d0458b76a6a3703 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=8Dtalo=20Menezes?= Date: Mon, 15 Jun 2026 21:01:20 -0300 Subject: [PATCH 02/23] feat: add browseable ACP agent catalog UI to settings --- e2e/acp-agents-catalog.spec.ts | 39 ++++++ .../settings/agents/agent-catalog-card.tsx | 79 +++++++++++ .../agents/agent-catalog-view.test.tsx | 132 ++++++++++++++++++ .../settings/agents/agent-catalog-view.tsx | 42 ++++++ .../settings/agents/agent-catalog.tsx | 14 ++ src/routes/settings/agents/index.tsx | 3 + 6 files changed, 309 insertions(+) create mode 100644 e2e/acp-agents-catalog.spec.ts create mode 100644 src/components/settings/agents/agent-catalog-card.tsx create mode 100644 src/components/settings/agents/agent-catalog-view.test.tsx create mode 100644 src/components/settings/agents/agent-catalog-view.tsx create mode 100644 src/components/settings/agents/agent-catalog.tsx diff --git a/e2e/acp-agents-catalog.spec.ts b/e2e/acp-agents-catalog.spec.ts new file mode 100644 index 000000000..00979f3f2 --- /dev/null +++ b/e2e/acp-agents-catalog.spec.ts @@ -0,0 +1,39 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { test, expect } from '@playwright/test' +import { collectPageErrors, loginViaOidc } from './helpers' + +test.describe('Agents catalog', () => { + test('browse, link out, search, and empty state work without page errors', async ({ page }) => { + const errors = collectPageErrors(page) + await loginViaOidc(page) + + await page.goto('/settings/agents') + + // The bundled ACP registry snapshot renders immediately — no live network needed. + // Assert a few known registry cards by id. + const geminiCard = page.getByTestId('agent-catalog-card-gemini') + await expect(geminiCard).toBeVisible({ timeout: 10_000 }) + await expect(page.getByTestId('agent-catalog-card-claude-acp')).toBeVisible() + await expect(page.getByTestId('agent-catalog-card-goose')).toBeVisible() + + // At least one link-out is present on a card. + await expect(geminiCard.getByRole('link').first()).toBeVisible() + + // Search filters the grid: 'gemini' keeps the gemini card, drops claude-acp. + const search = page.getByPlaceholder('Search agents') + await search.fill('gemini') + await expect(page.getByTestId('agent-catalog-card-gemini')).toBeVisible() + await expect(page.getByTestId('agent-catalog-card-claude-acp')).toHaveCount(0) + + // A guaranteed-no-match query shows the empty state. + await search.fill('zzzqqqxx') + await expect(page.getByText(/no agents found/i)).toBeVisible() + + // The background CDN fetch must not surface page errors even if it fails — + // the snapshot fallback covers it. + expect(errors).toEqual([]) + }) +}) diff --git a/src/components/settings/agents/agent-catalog-card.tsx b/src/components/settings/agents/agent-catalog-card.tsx new file mode 100644 index 000000000..145f9aaec --- /dev/null +++ b/src/components/settings/agents/agent-catalog-card.tsx @@ -0,0 +1,79 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { Code2, ExternalLink, Terminal } from 'lucide-react' +import { useState } from 'react' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardHeader } from '@/components/ui/card' +import { distributionLabel, primaryDistributionKind } from '@/lib/agent-registry-filter' +import type { RegistryEntry } from '@/types/registry' + +type AgentCatalogCardProps = { + entry: RegistryEntry +} + +/** A read-only catalogue card for a "bridge" agent: shows the agent's identity + * and metadata and links out to its website and source. There's no install + * action — these CLIs run on the user's own machine, not inside Thunderbolt. */ +export const AgentCatalogCard = ({ entry }: AgentCatalogCardProps) => { + const [iconFailed, setIconFailed] = useState(false) + + const distributionKind = primaryDistributionKind(entry) + const websiteUrl = entry.website ?? entry.repository + const sourceUrl = entry.repository && entry.repository !== websiteUrl ? entry.repository : null + const showIcon = entry.icon && !iconFailed + const metadata = [entry.version ? `v${entry.version}` : null, entry.authors.join(', ') || null, entry.license || null] + .filter(Boolean) + .join(' · ') + + return ( + + +
+ {showIcon ? ( + setIconFailed(true)} + /> + ) : ( +
+
+ +

{entry.description}

+

{metadata}

+
+ + + ) +} diff --git a/src/components/settings/agents/agent-catalog-view.test.tsx b/src/components/settings/agents/agent-catalog-view.test.tsx new file mode 100644 index 000000000..1e37223a4 --- /dev/null +++ b/src/components/settings/agents/agent-catalog-view.test.tsx @@ -0,0 +1,132 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import '@testing-library/jest-dom' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'bun:test' +import type { RegistryEntry } from '@/types/registry' +import { AgentCatalogView } from './agent-catalog-view' + +const entry = (overrides: Partial & Pick): RegistryEntry => ({ + version: '1.2.3', + description: `${overrides.name} description`, + authors: ['Author Inc.'], + license: 'Apache-2.0', + website: `https://example.com/${overrides.id}`, + repository: `https://github.com/example/${overrides.id}`, + distribution: { npx: { package: `${overrides.id}@1.2.3` } }, + ...overrides, +}) + +const fixtures: ReadonlyArray = [ + entry({ id: 'goose', name: 'goose', description: 'Extensible agent from Block', icon: 'https://cdn/goose.svg' }), + entry({ id: 'gemini', name: 'Gemini CLI', description: 'Google terminal agent' }), +] + +const renderCatalog = (entries: ReadonlyArray = fixtures) => + render() + +describe('AgentCatalogView', () => { + afterEach(cleanup) + + it('renders one card per entry', () => { + renderCatalog() + expect(screen.getByTestId('agent-catalog-card-goose')).toBeInTheDocument() + expect(screen.getByTestId('agent-catalog-card-gemini')).toBeInTheDocument() + }) + + it('renders a distribution badge per card', () => { + renderCatalog() + expect(screen.getAllByText('Node.js')).toHaveLength(fixtures.length) + }) + + it('renders Website and Source link-outs with correct attributes', () => { + renderCatalog([entry({ id: 'goose', name: 'goose' })]) + const card = screen.getByTestId('agent-catalog-card-goose') + const links = card.querySelectorAll('a') + expect(links).toHaveLength(2) + + const website = card.querySelector('a[href="https://example.com/goose"]') + const source = card.querySelector('a[href="https://github.com/example/goose"]') + expect(website).toBeInTheDocument() + expect(source).toBeInTheDocument() + + for (const link of [website, source]) { + expect(link).toHaveAttribute('target', '_blank') + expect(link?.getAttribute('rel')).toContain('noopener') + expect(link?.getAttribute('rel')).toContain('noreferrer') + } + }) + + it('falls back the Website link to the repository when no website is set, and hides the duplicate Source link', () => { + renderCatalog([entry({ id: 'claude-acp', name: 'Claude Agent', website: undefined })]) + const card = screen.getByTestId('agent-catalog-card-claude-acp') + const links = card.querySelectorAll('a') + expect(links).toHaveLength(1) + expect(card.querySelector('a[href="https://github.com/example/claude-acp"]')).toBeInTheDocument() + }) + + it('filters by search query', () => { + renderCatalog() + fireEvent.change(screen.getByPlaceholderText('Search agents'), { target: { value: 'gemini' } }) + + expect(screen.getByTestId('agent-catalog-card-gemini')).toBeInTheDocument() + expect(screen.queryByTestId('agent-catalog-card-goose')).not.toBeInTheDocument() + }) + + it('shows a no-results message when nothing matches', () => { + renderCatalog() + fireEvent.change(screen.getByPlaceholderText('Search agents'), { target: { value: 'zzzqqqxx' } }) + + expect(screen.getByText(/no agents found/i)).toBeInTheDocument() + expect(screen.queryByTestId('agent-catalog-card-goose')).not.toBeInTheDocument() + }) + + it('renders an icon image when icon is set', () => { + renderCatalog([entry({ id: 'goose', name: 'goose', icon: 'https://cdn/goose.svg' })]) + const header = screen.getByTestId('agent-catalog-card-goose').querySelector('[data-slot="card-header"]') + const img = header?.querySelector('img') + expect(img).toBeInTheDocument() + expect(img).toHaveAttribute('src', 'https://cdn/goose.svg') + // The Terminal fallback (an svg) must not render while the icon image loads cleanly. + expect(header?.querySelector('svg')).not.toBeInTheDocument() + }) + + it('falls back to the Terminal icon when the image fails to load', () => { + renderCatalog([entry({ id: 'goose', name: 'goose', icon: 'https://cdn/broken.svg' })]) + const header = screen.getByTestId('agent-catalog-card-goose').querySelector('[data-slot="card-header"]') + const img = header?.querySelector('img') + expect(img).toBeInTheDocument() + + fireEvent.error(img as HTMLImageElement) + + expect(header?.querySelector('img')).not.toBeInTheDocument() + expect(header?.querySelector('svg')).toBeInTheDocument() + }) + + it('renders the Terminal icon when no icon is set', () => { + renderCatalog([entry({ id: 'goose', name: 'goose', icon: undefined })]) + const header = screen.getByTestId('agent-catalog-card-goose').querySelector('[data-slot="card-header"]') + expect(header?.querySelector('img')).not.toBeInTheDocument() + expect(header?.querySelector('svg')).toBeInTheDocument() + }) + + it('exposes only link-out actions per card, never an install action', () => { + renderCatalog([entry({ id: 'goose', name: 'goose' })]) + const card = screen.getByTestId('agent-catalog-card-goose') + + expect(card.querySelectorAll('a').length).toBeGreaterThan(0) + expect(card.querySelector('button')).not.toBeInTheDocument() + expect(card.querySelector('button[type="submit"]')).not.toBeInTheDocument() + }) + + it('keeps all cards visible for a whitespace-only query', () => { + renderCatalog() + fireEvent.change(screen.getByPlaceholderText('Search agents'), { target: { value: ' ' } }) + + expect(screen.queryByText(/no agents found/i)).not.toBeInTheDocument() + expect(screen.getByTestId('agent-catalog-card-goose')).toBeInTheDocument() + expect(screen.getByTestId('agent-catalog-card-gemini')).toBeInTheDocument() + }) +}) diff --git a/src/components/settings/agents/agent-catalog-view.tsx b/src/components/settings/agents/agent-catalog-view.tsx new file mode 100644 index 000000000..f02baba2f --- /dev/null +++ b/src/components/settings/agents/agent-catalog-view.tsx @@ -0,0 +1,42 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { SearchInput } from '@/components/ui/search-input' +import { useAgentRegistrySearch } from '@/hooks/use-agent-registry-search' +import type { RegistryEntry } from '@/types/registry' +import { AgentCatalogCard } from './agent-catalog-card' + +type AgentCatalogViewProps = { + /** The agents to render. Always non-empty in production (the snapshot seeds it). */ + entries: ReadonlyArray +} + +/** Presentational catalogue: search + grid of read-only agent cards. Takes its + * entries as a prop and owns no data fetching, so it renders purely from inputs + * and is unit-testable without react-query. */ +export const AgentCatalogView = ({ entries }: AgentCatalogViewProps) => { + const { query, setQuery, results, isEmpty } = useAgentRegistrySearch(entries) + const showEmptyState = isEmpty && query.trim().length > 0 + + return ( +
+

Browse agents

+ setQuery(event.target.value)} + /> + {showEmptyState ? ( +

No agents found

+ ) : ( +
+ {results.map((entry) => ( + + ))} +
+ )} +
+ ) +} diff --git a/src/components/settings/agents/agent-catalog.tsx b/src/components/settings/agents/agent-catalog.tsx new file mode 100644 index 000000000..b39481ecc --- /dev/null +++ b/src/components/settings/agents/agent-catalog.tsx @@ -0,0 +1,14 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { useAgentRegistry } from '@/hooks/use-agent-registry' +import { AgentCatalogView } from './agent-catalog-view' + +/** Container for the bridgeable-agent catalogue: reads the live registry hook + * (snapshot-seeded, so always non-empty) and hands the entries to the + * presentational view. */ +export const AgentCatalog = () => { + const entries = useAgentRegistry() + return +} diff --git a/src/routes/settings/agents/index.tsx b/src/routes/settings/agents/index.tsx index 012457db4..e2b9d944f 100644 --- a/src/routes/settings/agents/index.tsx +++ b/src/routes/settings/agents/index.tsx @@ -9,6 +9,7 @@ import { Plus } from 'lucide-react' import { Button } from '@/components/ui/button' import { PageHeader } from '@/components/ui/page-header' import { AgentList } from '@/components/settings/agents/agent-list' +import { AgentCatalog } from '@/components/settings/agents/agent-catalog' import { AddCustomAgentDialog, type AddCustomAgentPayload } from '@/components/settings/agents/add-custom-agent-dialog' import { testAcpConnection } from '@/acp' import { createAgent, deleteAgent, updateAgent, useAllAgents } from '@/dal' @@ -106,6 +107,8 @@ export default function AgentsSettingsPage({ isStandalone }: AgentsSettingsPageP + + Date: Mon, 15 Jun 2026 22:33:40 -0300 Subject: [PATCH 03/23] fix: clear button now resets agent catalog search --- e2e/acp-agents-catalog.spec.ts | 5 +++++ .../settings/agents/agent-catalog-view.test.tsx | 15 +++++++++++++++ .../settings/agents/agent-catalog-view.tsx | 3 +++ 3 files changed, 23 insertions(+) diff --git a/e2e/acp-agents-catalog.spec.ts b/e2e/acp-agents-catalog.spec.ts index 00979f3f2..960849e9d 100644 --- a/e2e/acp-agents-catalog.spec.ts +++ b/e2e/acp-agents-catalog.spec.ts @@ -28,6 +28,11 @@ test.describe('Agents catalog', () => { await expect(page.getByTestId('agent-catalog-card-gemini')).toBeVisible() await expect(page.getByTestId('agent-catalog-card-claude-acp')).toHaveCount(0) + // The clear (X) button resets the query and restores the filtered-out card. + await page.getByRole('button', { name: /clear search/i }).click() + await expect(search).toHaveValue('') + await expect(page.getByTestId('agent-catalog-card-claude-acp')).toBeVisible() + // A guaranteed-no-match query shows the empty state. await search.fill('zzzqqqxx') await expect(page.getByText(/no agents found/i)).toBeVisible() diff --git a/src/components/settings/agents/agent-catalog-view.test.tsx b/src/components/settings/agents/agent-catalog-view.test.tsx index 1e37223a4..8905aaeb0 100644 --- a/src/components/settings/agents/agent-catalog-view.test.tsx +++ b/src/components/settings/agents/agent-catalog-view.test.tsx @@ -75,6 +75,21 @@ describe('AgentCatalogView', () => { expect(screen.queryByTestId('agent-catalog-card-goose')).not.toBeInTheDocument() }) + it('clears the query and restores every card when the clear button is clicked', () => { + renderCatalog() + const input = screen.getByPlaceholderText('Search agents') as HTMLInputElement + + fireEvent.change(input, { target: { value: 'gemini' } }) + expect(input.value).toBe('gemini') + expect(screen.queryByTestId('agent-catalog-card-goose')).not.toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: /clear search/i })) + + expect(input.value).toBe('') + expect(screen.getByTestId('agent-catalog-card-gemini')).toBeInTheDocument() + expect(screen.getByTestId('agent-catalog-card-goose')).toBeInTheDocument() + }) + it('shows a no-results message when nothing matches', () => { renderCatalog() fireEvent.change(screen.getByPlaceholderText('Search agents'), { target: { value: 'zzzqqqxx' } }) diff --git a/src/components/settings/agents/agent-catalog-view.tsx b/src/components/settings/agents/agent-catalog-view.tsx index f02baba2f..e6e769042 100644 --- a/src/components/settings/agents/agent-catalog-view.tsx +++ b/src/components/settings/agents/agent-catalog-view.tsx @@ -5,6 +5,7 @@ import { SearchInput } from '@/components/ui/search-input' import { useAgentRegistrySearch } from '@/hooks/use-agent-registry-search' import type { RegistryEntry } from '@/types/registry' +import { useRef } from 'react' import { AgentCatalogCard } from './agent-catalog-card' type AgentCatalogViewProps = { @@ -18,11 +19,13 @@ type AgentCatalogViewProps = { export const AgentCatalogView = ({ entries }: AgentCatalogViewProps) => { const { query, setQuery, results, isEmpty } = useAgentRegistrySearch(entries) const showEmptyState = isEmpty && query.trim().length > 0 + const searchRef = useRef(null) return (

Browse agents

Date: Tue, 16 Jun 2026 11:21:33 -0300 Subject: [PATCH 04/23] feat: add acp-bridge package for local stdio ACP agents --- acp-bridge/README.md | 129 ++++++++++++++ acp-bridge/bin/cli.js | 114 ++++++++++++ acp-bridge/bun.lock | 15 ++ acp-bridge/package.json | 24 +++ acp-bridge/src/args.js | 139 +++++++++++++++ acp-bridge/src/args.test.js | 111 ++++++++++++ acp-bridge/src/errors.js | 103 +++++++++++ acp-bridge/src/errors.test.js | 87 +++++++++ acp-bridge/src/log.js | 280 +++++++++++++++++++++++++++++ acp-bridge/src/log.test.js | 232 ++++++++++++++++++++++++ acp-bridge/src/relay.js | 114 ++++++++++++ acp-bridge/src/relay.test.js | 133 ++++++++++++++ acp-bridge/src/server.js | 296 +++++++++++++++++++++++++++++++ acp-bridge/src/server.test.js | 321 ++++++++++++++++++++++++++++++++++ 14 files changed, 2098 insertions(+) create mode 100644 acp-bridge/README.md create mode 100644 acp-bridge/bin/cli.js create mode 100644 acp-bridge/bun.lock create mode 100644 acp-bridge/package.json create mode 100644 acp-bridge/src/args.js create mode 100644 acp-bridge/src/args.test.js create mode 100644 acp-bridge/src/errors.js create mode 100644 acp-bridge/src/errors.test.js create mode 100644 acp-bridge/src/log.js create mode 100644 acp-bridge/src/log.test.js create mode 100644 acp-bridge/src/relay.js create mode 100644 acp-bridge/src/relay.test.js create mode 100644 acp-bridge/src/server.js create mode 100644 acp-bridge/src/server.test.js diff --git a/acp-bridge/README.md b/acp-bridge/README.md new file mode 100644 index 000000000..6829ecbb7 --- /dev/null +++ b/acp-bridge/README.md @@ -0,0 +1,129 @@ +# acp-bridge + +A tiny CLI that lets [Thunderbolt](https://thunderbird.net) talk to a **local stdio +ACP agent** (Claude Code, Gemini, any [Agent Client Protocol](https://agentclientprotocol.com) +agent) over a localhost WebSocket. + +Thunderbolt runs in the browser and can only reach an agent over a WebSocket. +Most ACP agents speak over **stdio** (newline-delimited JSON-RPC). `acp-bridge` +spawns the agent and relays its stdio to a `ws://127.0.0.1:PORT` socket — one +JSON object per WebSocket message, exactly what Thunderbolt expects. + +``` +Thunderbolt (browser) ⇄ ws://127.0.0.1:PORT ⇄ acp-bridge ⇄ stdio ⇄ your agent +``` + +## Usage + +```bash +npx acp-bridge -- [agent-args...] +``` + +Everything after `--` is the agent command, passed **straight to the OS with no +shell** (no quoting bugs, no injection). For example: + +```bash +npx acp-bridge -- npx -y @zed-industries/claude-code-acp +``` + +It prints a banner like: + +``` +acp-bridge ready + Agent: npx + Listening: ws://127.0.0.1:51847 + +Paste this URL into Thunderbolt → Add Custom Agent: + ws://127.0.0.1:51847 + +Ctrl-C to stop. +``` + +Copy the `ws://127.0.0.1:PORT` URL and paste it into Thunderbolt under +**Add Custom Agent**. Press **Ctrl-C** to stop the bridge (it cleanly shuts the +agent down too). + +### Options + +| Flag | Default | Meaning | +| -------------------- | ----------- | ------------------------------------------------------------- | +| `--port ` | ephemeral | WebSocket port. Omit it to let the OS pick a free one. | +| `--host ` | `127.0.0.1` | Bind address. Loopback only by default — keep it that way. A non-loopback host prints a prominent warning (other machines on your network could then reach the agent). | +| `--allow-origin ` | — | Extra WebSocket `Origin` to accept (repeatable). The Thunderbolt app origins are allowed by default; use this for dev/self-host. | +| `--allow-any-origin` | off | Disable the `Origin` check entirely. Loud escape hatch — see [Origin allowlist](#origin-allowlist). | +| `--verbose` | off | Per-frame logging (method + size, **redacted** — never content). | +| `--json` | off | Emit logs as raw JSON instead of pretty one-liners. | +| `--help` | | Show help. | +| `--version` | | Print the version. | + +### Origin allowlist + +Browser WebSocket connections are **not** same-origin-protected: without a guard, +any web page open in a browser on your machine could connect to +`ws://127.0.0.1:PORT` and drive your local agent (read/write files, run terminal +commands). To close that, the bridge only accepts connections whose `Origin` +header is a known Thunderbolt app origin: + +- `https://app.thunderbolt.io` (production web app) +- `tauri://localhost` and `http://tauri.localhost` (Tauri desktop/mobile webview) +- `http://localhost:1420` (Vite dev server — web + Tauri dev) +- a **missing/empty** `Origin` (native and Tauri webviews routinely send none) + +A connection with any other `Origin` is rejected (WebSocket close code `1008`). +Extend the list with `--allow-origin ` (repeatable) for dev or self-host. +`--allow-any-origin` turns the check off entirely and prints a startup warning — +only use it on a trusted machine. + +## Desktop vs web + +- **Thunderbolt desktop (Tauri):** the app can open the localhost WebSocket + directly. +- **Thunderbolt web (browser):** the browser may ask permission to reach your + local network (Chrome's Local Network Access prompt) — click **Allow**. The + connection still goes browser → your own machine; nothing leaves your computer. + +The bridge binds to `127.0.0.1` only by default, so it is reachable solely from +your own machine. + +## Privacy + +`acp-bridge` is a **dumb relay** — it forwards bytes between the agent and +Thunderbolt and never inspects, stores, or transmits your prompts or the agent's +output anywhere else. + +Logging is **allowlist-based**: log lines only ever contain structural scalars +(timestamp, direction, message kind, a fixed set of known method names, JSON-RPC +id, byte size, status, integer error codes, lifecycle events). Prompt text, tool +output, file paths, tokens, and your full command line are **never** logged — +even with `--verbose`. The agent's own stderr passes through to your terminal +untouched. + +## How it works + +- **Framing.** ACP stdio is newline-delimited JSON-RPC; Thunderbolt's WebSocket + expects one JSON object per message. The bridge splits the agent's stdout into + lines and sends each non-empty line as exactly one WebSocket frame, and writes + each inbound WebSocket message to the agent's stdin with a trailing newline. + Non-JSON stdout lines are dropped (and warned about) so Thunderbolt's + `JSON.parse` never chokes. +- **One persistent agent.** A single child process is spawned and reused across + WebSocket reconnects, so session state survives Thunderbolt's reconnect + attempts. +- **Clean shutdown.** Ctrl-C (or `SIGTERM`) closes the WebSocket and sends the + agent `SIGTERM`, then waits for the agent to actually exit before exiting + itself. If a stubborn agent ignores `SIGTERM`, a 2-second fallback escalates to + `SIGKILL` and then exits — so the agent is never orphaned. If the agent exits + on its own, the bridge tears down with it. + +## Requirements + +- Node.js ≥ 18 +- One dependency: [`ws`](https://github.com/websockets/ws). Everything else is a + Node built-in. + +## Development + +```bash +bun install +bun test +``` diff --git a/acp-bridge/bin/cli.js b/acp-bridge/bin/cli.js new file mode 100644 index 000000000..133671fca --- /dev/null +++ b/acp-bridge/bin/cli.js @@ -0,0 +1,114 @@ +#!/usr/bin/env node + +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * acp-bridge CLI entry point. + * + * Thin wiring only: parse argv, build the injectable deps (spawn, ws server, + * line reader, logger), start the bridge, and translate signals into a graceful + * stop. All testable logic lives in ./src/*. + */ + +import { spawn } from 'node:child_process' +import { createInterface } from 'node:readline' +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { dirname, join } from 'node:path' +import { WebSocketServer } from 'ws' + +import { parseArgs } from '../src/args.js' +import { usageError, exitCodes } from '../src/errors.js' +import { createLogger } from '../src/log.js' +import { startBridge } from '../src/server.js' + +const here = dirname(fileURLToPath(import.meta.url)) + +/** Read the package version without importing JSON (Node version-portable). */ +const readVersion = () => { + const pkg = JSON.parse(readFileSync(join(here, '..', 'package.json'), 'utf8')) + return pkg.version +} + +/** + * Print the prominent, copyable ready banner to stderr (so it never mixes with + * the agent's stdout/ACP frames). + * @param {string} wsUrl + * @param {string} cmd0 + */ +const printBanner = (wsUrl, cmd0) => { + process.stderr.write( + [ + '', + 'acp-bridge ready', + ` Agent: ${cmd0}`, + ` Listening: ${wsUrl}`, + '', + `Paste this URL into Thunderbolt → Add Custom Agent:`, + ` ${wsUrl}`, + '', + 'Ctrl-C to stop.', + '', + ].join('\n'), + ) +} + +const main = async () => { + const args = parseArgs(process.argv.slice(2)) + + if (args.help) { + process.stdout.write(`${args.helpText}\n`) + process.exit(exitCodes.ok) + } + if (args.version) { + process.stdout.write(`${readVersion()}\n`) + process.exit(exitCodes.ok) + } + if (args.error) { + const { message, exitCode } = usageError(args.error) + process.stderr.write(`${message}\n\n${args.helpText}\n`) + process.exit(exitCode) + } + + const logger = createLogger({ json: args.json, verbose: args.verbose }) + const cmd0 = args.agentCmd[0] + + /** @type {((reason: string, code: number) => void) | null} */ + let stopFn = null + const installSignalHandlers = () => { + const onSignal = () => stopFn?.('signal', exitCodes.interrupted) + process.on('SIGINT', onSignal) + process.on('SIGTERM', onSignal) + } + installSignalHandlers() + + await startBridge( + { + agentCmd: args.agentCmd, + host: args.host, + port: args.port, + allowOrigins: args.allowOrigins, + allowAnyOrigin: args.allowAnyOrigin, + logger, + }, + { + spawn, + WebSocketServer, + createLineReader: (stream) => createInterface({ input: stream }), + onBanner: (wsUrl) => printBanner(wsUrl, cmd0), + // Capture `stop` immediately (before the grace window resolves) so a + // Ctrl-C during startup still tears the child + ws down cleanly. + onStop: (stop) => { + stopFn = stop + }, + }, + ) +} + +main().catch((err) => { + // startBridge already printed an actionable message + set the exit code. + const exitCode = typeof err?.exitCode === 'number' ? err.exitCode : exitCodes.unavailable + process.exit(exitCode) +}) diff --git a/acp-bridge/bun.lock b/acp-bridge/bun.lock new file mode 100644 index 000000000..da1a76230 --- /dev/null +++ b/acp-bridge/bun.lock @@ -0,0 +1,15 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "acp-bridge", + "dependencies": { + "ws": "^8.18.0", + }, + }, + }, + "packages": { + "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], + } +} diff --git a/acp-bridge/package.json b/acp-bridge/package.json new file mode 100644 index 000000000..dd3ac9d51 --- /dev/null +++ b/acp-bridge/package.json @@ -0,0 +1,24 @@ +{ + "name": "acp-bridge", + "version": "0.1.0", + "description": "Tiny CLI that relays a local stdio ACP agent to a localhost WebSocket for Thunderbolt.", + "type": "module", + "bin": { + "acp-bridge": "bin/cli.js" + }, + "files": [ + "bin", + "src", + "README.md" + ], + "scripts": { + "test": "bun test" + }, + "engines": { + "node": ">=18" + }, + "license": "MPL-2.0", + "dependencies": { + "ws": "^8.18.0" + } +} diff --git a/acp-bridge/src/args.js b/acp-bridge/src/args.js new file mode 100644 index 000000000..486f2ecb5 --- /dev/null +++ b/acp-bridge/src/args.js @@ -0,0 +1,139 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Pure CLI argument parser for acp-bridge. + * + * Everything BEFORE the `--` separator is a bridge flag. Everything AFTER it is + * the agent command + argv, passed verbatim to `spawn` (no shell, no quoting). + * A standalone `--` is mandatory to separate bridge flags from the agent argv. + */ + +const HELP_TEXT = `acp-bridge — relay a local stdio ACP agent to a localhost WebSocket for Thunderbolt. + +Usage: + npx acp-bridge [options] -- [agent-args...] + +Everything after \`--\` is the agent command, passed straight to the OS (no shell). + +Options: + --port WebSocket port (default: ephemeral, auto-picked) + --host Bind address (default: 127.0.0.1, loopback only) + --allow-origin Extra WebSocket Origin to accept (repeatable). The + Thunderbolt app origins are allowed by default. + --allow-any-origin Accept ANY Origin (disables the cross-origin guard). + Escape hatch for dev/self-host only — not recommended. + --verbose Per-frame logging (method + size, redacted; never content) + --json Emit logs as raw JSON instead of pretty one-liners + --help Show this help and exit + --version Print the version and exit + +Example: + npx acp-bridge -- npx -y @zed-industries/claude-code-acp + +Paste the printed ws://127.0.0.1:PORT URL into Thunderbolt → Add Custom Agent.` + +/** + * Parse process argv (the slice AFTER node + script path) into a structured + * config. Pure: no side effects, no process access. + * + * @param {string[]} argv - args after `node bin/cli.js` + * @returns {{ + * help: boolean, + * version: boolean, + * verbose: boolean, + * json: boolean, + * host: string, + * port: number, + * allowOrigins: string[], + * allowAnyOrigin: boolean, + * agentCmd: string[], + * error: string | null, + * helpText: string, + * }} + */ +export const parseArgs = (argv) => { + const base = { + help: false, + version: false, + verbose: false, + json: false, + host: '127.0.0.1', + port: 0, + allowOrigins: [], + allowAnyOrigin: false, + agentCmd: [], + error: null, + helpText: HELP_TEXT, + } + + const separatorIndex = argv.indexOf('--') + const flags = separatorIndex === -1 ? argv : argv.slice(0, separatorIndex) + const agentCmd = separatorIndex === -1 ? [] : argv.slice(separatorIndex + 1) + + const result = { ...base, agentCmd } + + let i = 0 + while (i < flags.length) { + const flag = flags[i] + if (flag === '--help' || flag === '-h') { + return { ...result, help: true } + } + if (flag === '--version' || flag === '-v') { + return { ...result, version: true } + } + if (flag === '--verbose') { + result.verbose = true + i += 1 + continue + } + if (flag === '--json') { + result.json = true + i += 1 + continue + } + if (flag === '--allow-any-origin') { + result.allowAnyOrigin = true + i += 1 + continue + } + if (flag === '--allow-origin' || flag.startsWith('--allow-origin=')) { + const value = flag.includes('=') ? flag.slice('--allow-origin='.length) : flags[i + 1] + if (!value) return { ...result, error: '--allow-origin requires a value' } + result.allowOrigins.push(value) + i += flag.includes('=') ? 1 : 2 + continue + } + if (flag === '--host' || flag.startsWith('--host=')) { + const value = flag.includes('=') ? flag.slice('--host='.length) : flags[i + 1] + if (!value) return { ...result, error: '--host requires a value' } + result.host = value + i += flag.includes('=') ? 1 : 2 + continue + } + if (flag === '--port' || flag.startsWith('--port=')) { + const value = flag.includes('=') ? flag.slice('--port='.length) : flags[i + 1] + if (!value) return { ...result, error: '--port requires a value' } + const port = Number(value) + if (!Number.isInteger(port) || port < 0 || port > 65535) { + return { ...result, error: `invalid --port: ${value}` } + } + result.port = port + i += flag.includes('=') ? 1 : 2 + continue + } + if (!flag.startsWith('-')) { + // A bare token before `--` almost always means the user forgot the + // separator (e.g. `acp-bridge my-agent` instead of `acp-bridge -- my-agent`). + return { ...result, error: 'no agent command given (did you forget the `--` before the agent command?)' } + } + return { ...result, error: `unknown option: ${flag}` } + } + + if (separatorIndex === -1 || agentCmd.length === 0) { + return { ...result, error: 'no agent command given' } + } + + return result +} diff --git a/acp-bridge/src/args.test.js b/acp-bridge/src/args.test.js new file mode 100644 index 000000000..0be349853 --- /dev/null +++ b/acp-bridge/src/args.test.js @@ -0,0 +1,111 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { describe, it, expect } from 'bun:test' +import { parseArgs } from './args.js' + +describe('parseArgs', () => { + it('parses agent command after -- verbatim (no shell)', () => { + const r = parseArgs(['--', 'npx', '-y', '@zed-industries/claude-code-acp']) + expect(r.error).toBeNull() + expect(r.agentCmd).toEqual(['npx', '-y', '@zed-industries/claude-code-acp']) + expect(r.host).toBe('127.0.0.1') + expect(r.port).toBe(0) + }) + + it('keeps agent flags that look like bridge flags (they are after --)', () => { + const r = parseArgs(['--', 'my-agent', '--port', '9999', '--verbose']) + expect(r.agentCmd).toEqual(['my-agent', '--port', '9999', '--verbose']) + expect(r.port).toBe(0) // bridge port untouched + expect(r.verbose).toBe(false) + }) + + it('parses --port before --', () => { + const r = parseArgs(['--port', '8123', '--', 'agent']) + expect(r.error).toBeNull() + expect(r.port).toBe(8123) + expect(r.agentCmd).toEqual(['agent']) + }) + + it('supports --port=NNNN form', () => { + const r = parseArgs(['--port=8123', '--', 'agent']) + expect(r.port).toBe(8123) + }) + + it('parses --host before --', () => { + const r = parseArgs(['--host', '0.0.0.0', '--', 'agent']) + expect(r.host).toBe('0.0.0.0') + expect(r.error).toBeNull() + }) + + it('parses --verbose and --json', () => { + const r = parseArgs(['--verbose', '--json', '--', 'agent']) + expect(r.verbose).toBe(true) + expect(r.json).toBe(true) + }) + + it('defaults the origin allowlist to empty extras and check enabled', () => { + const r = parseArgs(['--', 'agent']) + expect(r.allowOrigins).toEqual([]) + expect(r.allowAnyOrigin).toBe(false) + }) + + it('collects repeatable --allow-origin values', () => { + const r = parseArgs(['--allow-origin', 'http://localhost:3000', '--allow-origin=https://dev.test', '--', 'agent']) + expect(r.allowOrigins).toEqual(['http://localhost:3000', 'https://dev.test']) + expect(r.error).toBeNull() + }) + + it('errors when --allow-origin is missing a value', () => { + expect(parseArgs(['--allow-origin']).error).toBe('--allow-origin requires a value') + }) + + it('parses --allow-any-origin', () => { + const r = parseArgs(['--allow-any-origin', '--', 'agent']) + expect(r.allowAnyOrigin).toBe(true) + }) + + it('sets help (and short -h)', () => { + expect(parseArgs(['--help']).help).toBe(true) + expect(parseArgs(['-h']).help).toBe(true) + }) + + it('sets version (and short -v)', () => { + expect(parseArgs(['--version']).version).toBe(true) + expect(parseArgs(['-v']).version).toBe(true) + }) + + it('errors when no -- separator is present (suggests --)', () => { + const r = parseArgs(['agent', 'arg']) + expect(r.error).toContain('no agent command given') + expect(r.error).toContain('--') + }) + + it('errors when -- is present but no command follows', () => { + const r = parseArgs(['--port', '8080', '--']) + expect(r.error).toBe('no agent command given') + }) + + it('errors on unknown option before --', () => { + const r = parseArgs(['--nope', '--', 'agent']) + expect(r.error).toBe('unknown option: --nope') + }) + + it('errors on non-integer port', () => { + expect(parseArgs(['--port', 'abc', '--', 'agent']).error).toBe('invalid --port: abc') + }) + + it('errors on out-of-range port', () => { + expect(parseArgs(['--port', '99999', '--', 'agent']).error).toBe('invalid --port: 99999') + }) + + it('errors when --host is missing a value', () => { + expect(parseArgs(['--host']).error).toBe('--host requires a value') + }) + + it('always exposes help text', () => { + expect(parseArgs([]).helpText).toContain('Usage:') + expect(parseArgs([]).helpText).toContain('Add Custom Agent') + }) +}) diff --git a/acp-bridge/src/errors.js b/acp-bridge/src/errors.js new file mode 100644 index 000000000..5741b312b --- /dev/null +++ b/acp-bridge/src/errors.js @@ -0,0 +1,103 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Error → actionable message + exit-code mapping (pure). + * + * Exit codes follow sysexits.h conventions: + * 64 (EX_USAGE) — bad CLI invocation (no/invalid args) + * 69 (EX_UNAVAILABLE) — agent/runtime problem (ENOENT, EADDRINUSE, early exit) + * 130 (128+SIGINT) — clean Ctrl-C stop + * 0 — clean shutdown + */ + +export const exitCodes = { + ok: 0, + usage: 64, + unavailable: 69, + interrupted: 130, +} + +/** + * Map a usage problem (bad/missing args) to a message + exit code. + * Always pairs with the help text at the call site. + * + * @param {string} reason - the parser's error string + * @returns {{ message: string, exitCode: number }} + */ +export const usageError = (reason) => ({ + message: `acp-bridge: ${reason}`, + exitCode: exitCodes.usage, +}) + +/** + * Map a Node spawn/server error to an actionable message + exit code. + * Only allowlisted scalars (code, the command name) reach the message — + * never argv tail, paths, or env. + * + * @param {{ code?: string }} err - the Node error (e.g. ENOENT, EADDRINUSE) + * @param {{ cmd0?: string, host?: string, port?: number }} [ctx] + * @returns {{ message: string, exitCode: number }} + */ +export const spawnError = (err, ctx = {}) => { + const code = err?.code + if (code === 'ENOENT') { + const cmd0 = ctx.cmd0 ?? 'the agent command' + return { + message: `command not found: ${cmd0} — is it installed and on your PATH?`, + exitCode: exitCodes.unavailable, + } + } + if (code === 'EACCES') { + const cmd0 = ctx.cmd0 ?? 'the agent command' + return { + message: `permission denied launching: ${cmd0} — is it executable?`, + exitCode: exitCodes.unavailable, + } + } + return { + message: `failed to start agent (${code ?? 'unknown error'})`, + exitCode: exitCodes.unavailable, + } +} + +/** + * Map a WebSocket-server bind error to a message + exit code. + * + * @param {{ code?: string }} err + * @param {{ host?: string, port?: number }} [ctx] + * @returns {{ message: string, exitCode: number }} + */ +export const serverError = (err, ctx = {}) => { + if (err?.code === 'EADDRINUSE') { + const where = ctx.port ? `${ctx.host ?? '127.0.0.1'}:${ctx.port}` : 'the requested port' + return { + message: `port already in use (${where}) — omit --port to auto-pick, or choose another`, + exitCode: exitCodes.unavailable, + } + } + return { + message: `WebSocket server error (${err?.code ?? 'unknown error'})`, + exitCode: exitCodes.unavailable, + } +} + +/** + * Map an early agent exit (before it spoke ACP) to a message + exit code. + * The caller appends redacted stderr tail separately. + * + * @param {{ code?: number | null, signal?: string | null, cmd0?: string }} info + * @returns {{ message: string, exitCode: number }} + */ +export const earlyExitError = (info) => { + const cmd0 = info.cmd0 ?? 'the agent' + const how = + info.signal != null + ? `signal ${info.signal}` + : `code ${info.code ?? 'unknown'}` + return { + message: `agent exited (${how}) before speaking ACP — try running ${cmd0} directly to see why`, + exitCode: exitCodes.unavailable, + } +} diff --git a/acp-bridge/src/errors.test.js b/acp-bridge/src/errors.test.js new file mode 100644 index 000000000..cbb7ac051 --- /dev/null +++ b/acp-bridge/src/errors.test.js @@ -0,0 +1,87 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { describe, it, expect } from 'bun:test' +import { + exitCodes, + usageError, + spawnError, + serverError, + earlyExitError, +} from './errors.js' + +describe('exitCodes', () => { + it('uses sysexits-style codes', () => { + expect(exitCodes).toEqual({ ok: 0, usage: 64, unavailable: 69, interrupted: 130 }) + }) +}) + +describe('usageError', () => { + it('maps to exit 64 and prefixes the reason', () => { + const r = usageError('no agent command given') + expect(r.exitCode).toBe(64) + expect(r.message).toBe('acp-bridge: no agent command given') + }) +}) + +describe('spawnError', () => { + it('maps ENOENT to an actionable "command not found" + exit 69', () => { + const r = spawnError({ code: 'ENOENT' }, { cmd0: 'my-agent' }) + expect(r.exitCode).toBe(69) + expect(r.message).toContain('command not found: my-agent') + expect(r.message).toContain('PATH') + }) + + it('maps EACCES to permission denied + exit 69', () => { + const r = spawnError({ code: 'EACCES' }, { cmd0: 'my-agent' }) + expect(r.exitCode).toBe(69) + expect(r.message).toContain('permission denied') + expect(r.message).toContain('my-agent') + }) + + it('falls back for unknown spawn errors', () => { + const r = spawnError({ code: 'EWHATEVER' }, { cmd0: 'x' }) + expect(r.exitCode).toBe(69) + expect(r.message).toContain('EWHATEVER') + }) + + it('never leaks argv beyond cmd0', () => { + const r = spawnError({ code: 'ENOENT' }, { cmd0: 'agent' }) + expect(r.message).not.toContain('--secret-token') + }) +}) + +describe('serverError', () => { + it('maps EADDRINUSE to a port-in-use message + exit 69', () => { + const r = serverError({ code: 'EADDRINUSE' }, { host: '127.0.0.1', port: 8080 }) + expect(r.exitCode).toBe(69) + expect(r.message).toContain('127.0.0.1:8080') + expect(r.message).toContain('--port') + }) + + it('handles EADDRINUSE without an explicit port', () => { + const r = serverError({ code: 'EADDRINUSE' }, {}) + expect(r.message).toContain('the requested port') + }) + + it('falls back for unknown server errors', () => { + const r = serverError({ code: 'EACCES' }, {}) + expect(r.exitCode).toBe(69) + expect(r.message).toContain('EACCES') + }) +}) + +describe('earlyExitError', () => { + it('reports the exit code path + exit 69', () => { + const r = earlyExitError({ code: 1, signal: null, cmd0: 'agent' }) + expect(r.exitCode).toBe(69) + expect(r.message).toContain('code 1') + expect(r.message).toContain('agent') + }) + + it('reports a signal when the agent was killed', () => { + const r = earlyExitError({ code: null, signal: 'SIGKILL', cmd0: 'agent' }) + expect(r.message).toContain('signal SIGKILL') + }) +}) diff --git a/acp-bridge/src/log.js b/acp-bridge/src/log.js new file mode 100644 index 000000000..508be63f8 --- /dev/null +++ b/acp-bridge/src/log.js @@ -0,0 +1,280 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * PII-safe logging for acp-bridge. + * + * The cardinal rule: log objects are built from an ALLOWLIST of extracted + * scalars. The raw ACP frame is NEVER handed to the logger, so prompt text, + * tool output, file paths, tokens, and argv can never leak — there is no code + * path that copies the frame body into a log line. + * + * Allowlisted fields: timestamp, direction, kind, method (validated enum), + * id, byteSize, status, errorCode, lifecycle, closeCode, origin (sanitized). + */ + +/** Known ACP method names. Anything else is collapsed to 'other' so a method + * string (which is structural, not content) can't smuggle data into a log. */ +const KNOWN_METHODS = new Set([ + 'initialize', + 'authenticate', + 'session/new', + 'session/load', + 'session/prompt', + 'session/cancel', + 'session/update', + 'session/request_permission', + 'fs/read_text_file', + 'fs/write_text_file', + 'terminal/create', + 'terminal/output', + 'terminal/release', + 'terminal/wait_for_exit', + 'terminal/kill', +]) + +/** + * Coerce an arbitrary method string to the known enum or 'other'. + * @param {unknown} method + * @returns {string | undefined} + */ +const safeMethod = (method) => { + if (typeof method !== 'string') return undefined + return KNOWN_METHODS.has(method) ? method : 'other' +} + +/** Max length of a string id before it's truncated. A JSON-RPC id is meant to + * be structural, but it can be an arbitrary string — an agent could embed + * content there. Numbers are inherently bounded and pass through untouched. */ +const MAX_ID_LEN = 16 + +/** + * Coerce a JSON-RPC id to a safe scalar (string/number only). Objects/arrays + * are dropped — an id is structural, but we still refuse to serialize anything + * non-scalar into a log line. Numbers pass through; a string longer than + * MAX_ID_LEN is truncated to its first 8 chars + '…' so no realistic content + * can be exfiltrated through the id field. + * @param {unknown} id + * @returns {string | number | undefined} + */ +const safeId = (id) => { + if (typeof id === 'number') return id + if (typeof id !== 'string') return undefined + return id.length > MAX_ID_LEN ? `${id.slice(0, 8)}…` : id +} + +/** + * Classify a parsed JSON-RPC object into a kind without reading its payload. + * @param {Record} obj + * @returns {'request' | 'response' | 'notification' | 'error'} + */ +const classifyKind = (obj) => { + if ('error' in obj) return 'error' + if ('method' in obj) return 'id' in obj ? 'request' : 'notification' + return 'response' +} + +/** + * Extract a PII-safe log event from a single ACP/JSON-RPC frame. + * + * Returns ONLY allowlisted scalars. The frame's params/result/error.data + * (which hold prompts, tool output, file contents, paths) are never read. + * + * @param {object} args + * @param {'agent->ws' | 'ws->agent'} args.direction + * @param {string} args.line - the raw ndjson line (used only for byteSize) + * @returns {{ + * direction: string, + * kind: string, + * method?: string, + * id?: string | number, + * byteSize: number, + * status?: 'ok' | 'error', + * errorCode?: number, + * parseError?: true, + * }} + */ +export const extractLogEvent = ({ direction, line }) => { + const byteSize = Buffer.byteLength(line, 'utf8') + + const parsed = tryParse(line) + if (parsed === undefined) { + return { direction, kind: 'non-json', byteSize, parseError: true } + } + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { + // Valid JSON but not a JSON-RPC object — still no content extracted. + return { direction, kind: 'non-rpc', byteSize } + } + + const obj = /** @type {Record} */ (parsed) + const kind = classifyKind(obj) + + const event = { + direction, + kind, + byteSize, + method: safeMethod(obj.method), + id: safeId(obj.id), + } + + if (kind === 'error') { + const errorObj = obj.error + const errorCode = + errorObj && typeof errorObj === 'object' && typeof (/** @type {Record} */ (errorObj).code) === 'number' + ? /** @type {number} */ (/** @type {Record} */ (errorObj).code) + : undefined + return { ...event, status: 'error', errorCode } + } + + if (kind === 'response') { + return { ...event, status: 'ok' } + } + + return event +} + +/** + * Parse JSON, returning `undefined` on failure (so callers can distinguish a + * parse failure from a legitimate `null` value). + * @param {string} line + * @returns {unknown | undefined} + */ +const tryParse = (line) => { + try { + return JSON.parse(line) + } catch { + return undefined + } +} + +/** + * Sanitize an Origin header for logging. Keeps only the scheme + host:port + * structure (which is what we care about for handshake diagnostics) and never + * logs a path/query that could carry data. + * @param {unknown} origin + * @returns {string} + */ +export const sanitizeOrigin = (origin) => { + if (typeof origin !== 'string' || origin.length === 0) return 'none' + try { + const url = new URL(origin) + return `${url.protocol}//${url.host}` + } catch { + return 'invalid' + } +} + +/** + * Default WebSocket Origin allowlist — the Thunderbolt app origins. + * + * Browser WebSocket connections are NOT same-origin-protected, so without this + * check any web page open on the machine could connect to ws://127.0.0.1:PORT + * and drive the user's local agent (fs read/write, terminal exec). These are the + * canonical Thunderbolt origins (see backend/src/config/settings.ts corsOrigins + * + the hardcoded prod web origin in isOAuthRedirectUriAllowed): + * - https://app.thunderbolt.io — production web app + * - tauri://localhost / http://tauri.localhost — Tauri desktop/mobile webview + * - http://localhost:1420 — Vite dev server (web + Tauri dev) + * A missing/empty Origin is allowed separately (native/Tauri webviews often send + * none); see isOriginAllowed. + */ +export const defaultAllowedOrigins = Object.freeze([ + 'https://app.thunderbolt.io', + 'tauri://localhost', + 'http://tauri.localhost', + 'http://localhost:1420', +]) + +/** + * Decide whether an incoming WebSocket Origin may connect. + * + * A missing/empty Origin is allowed: native/Tauri webviews frequently send no + * Origin header, and a non-browser client (which is not subject to the + * cross-origin hijack this guards against) likewise sends none. A present Origin + * must exactly match an entry in the allowlist (scheme + host:port), normalized + * the same way as sanitizeOrigin so a trailing slash or default port can't slip + * past or be falsely rejected. + * + * @param {unknown} origin - the raw Origin header (or undefined) + * @param {readonly string[]} allowlist - exact-match allowed origins + * @returns {boolean} + */ +export const isOriginAllowed = (origin, allowlist) => { + if (typeof origin !== 'string' || origin.length === 0) return true + const normalized = normalizeOrigin(origin) + if (normalized === null) return false + return allowlist.some((allowed) => normalizeOrigin(allowed) === normalized) +} + +/** + * Normalize an origin to `scheme://host[:port]` for exact comparison, or null if + * it isn't a parseable origin. + * @param {string} origin + * @returns {string | null} + */ +const normalizeOrigin = (origin) => { + try { + const url = new URL(origin) + return `${url.protocol}//${url.host}` + } catch { + return null + } +} + +const LEVELS = { debug: 10, info: 20, warn: 30, error: 40 } + +/** + * Create a minimal, dependency-free, PII-safe structured logger. + * + * - `json: true` → one JSON object per line (raw scalars). + * - `json: false` → a compact, human one-liner with NO content column. + * - `verbose` → enables debug-level (per-frame) events. + * + * Always writes to the provided stream (stderr in production) so the agent's + * own stdout/ACP traffic is never polluted. + * + * @param {object} [opts] + * @param {boolean} [opts.json] + * @param {boolean} [opts.verbose] + * @param {{ write: (s: string) => void }} [opts.stream] + * @returns {{ + * debug: (event: Record) => void, + * info: (event: Record) => void, + * warn: (event: Record) => void, + * error: (event: Record) => void, + * }} + */ +export const createLogger = ({ json = false, verbose = false, stream } = {}) => { + const out = stream ?? process.stderr + const threshold = verbose ? LEVELS.debug : LEVELS.info + + const write = (level, event) => { + if (LEVELS[level] < threshold) return + const record = { level, ...event } + out.write(json ? `${JSON.stringify(record)}\n` : `${formatPretty(record)}\n`) + } + + return { + debug: (event) => write('debug', event), + info: (event) => write('info', event), + warn: (event) => write('warn', event), + error: (event) => write('error', event), + } +} + +/** + * Render a safe log record as a compact one-liner. Only iterates the record's + * own scalar keys — there is no content column and nothing nested is expanded. + * @param {Record} record + * @returns {string} + */ +const formatPretty = (record) => { + const { level, ...rest } = record + const tag = String(level).toUpperCase().padEnd(5) + const fields = Object.entries(rest) + .filter(([, value]) => value !== undefined) + .map(([key, value]) => `${key}=${value}`) + .join(' ') + return `${tag} ${fields}`.trimEnd() +} diff --git a/acp-bridge/src/log.test.js b/acp-bridge/src/log.test.js new file mode 100644 index 000000000..1c08da6b4 --- /dev/null +++ b/acp-bridge/src/log.test.js @@ -0,0 +1,232 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { describe, it, expect } from 'bun:test' +import { + extractLogEvent, + sanitizeOrigin, + createLogger, + isOriginAllowed, + defaultAllowedOrigins, +} from './log.js' + +describe('extractLogEvent — PII safety (the whole point)', () => { + it('extracts method + size but NEVER the prompt text', () => { + const secret = 'My SSN is 123-45-6789 and my API key is sk-deadbeef' + const frame = JSON.stringify({ + jsonrpc: '2.0', + id: 7, + method: 'session/prompt', + params: { + sessionId: 'sess-abc', + prompt: [{ type: 'text', text: secret }], + }, + }) + + const event = extractLogEvent({ direction: 'ws->agent', line: frame }) + const serialized = JSON.stringify(event) + + // The safe scalars ARE present. + expect(event.method).toBe('session/prompt') + expect(event.kind).toBe('request') + expect(event.id).toBe(7) + expect(event.byteSize).toBe(Buffer.byteLength(frame)) + + // None of the content leaks — not the prompt text, not the SSN, not the + // key, not the sessionId, not the params shape. (The method name + // "session/prompt" IS expected: it's an allowlisted structural enum value, + // not user content.) + expect(serialized).not.toContain('SSN') + expect(serialized).not.toContain('123-45-6789') + expect(serialized).not.toContain('sk-deadbeef') + expect(serialized).not.toContain('sess-abc') + expect(serialized).not.toContain('params') + expect(serialized).not.toContain('text') + }) + + it('collapses unknown/attacker-controlled methods to "other"', () => { + const frame = JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'leak/secret-token-aaa-bbb-ccc', + }) + const event = extractLogEvent({ direction: 'agent->ws', line: frame }) + expect(event.method).toBe('other') + expect(JSON.stringify(event)).not.toContain('secret-token') + }) + + it('does not leak tool output from a session/update notification', () => { + const frame = JSON.stringify({ + jsonrpc: '2.0', + method: 'session/update', + params: { update: { kind: 'agent_message_chunk', content: { text: 'internal file /etc/passwd contents' } } }, + }) + const event = extractLogEvent({ direction: 'agent->ws', line: frame }) + expect(event.kind).toBe('notification') + expect(event.method).toBe('session/update') + expect(JSON.stringify(event)).not.toContain('/etc/passwd') + }) + + it('classifies a response (id, result, no method) as kind=response status=ok', () => { + const frame = JSON.stringify({ jsonrpc: '2.0', id: 9, result: { secret: 'value' } }) + const event = extractLogEvent({ direction: 'agent->ws', line: frame }) + expect(event.kind).toBe('response') + expect(event.status).toBe('ok') + expect(event.id).toBe(9) + expect(JSON.stringify(event)).not.toContain('secret') + }) + + it('extracts an integer error.code but not error.message/data', () => { + const frame = JSON.stringify({ + jsonrpc: '2.0', + id: 4, + error: { code: -32601, message: 'Method not found: leak', data: { path: '/home/u/.ssh/id_rsa' } }, + }) + const event = extractLogEvent({ direction: 'agent->ws', line: frame }) + expect(event.kind).toBe('error') + expect(event.status).toBe('error') + expect(event.errorCode).toBe(-32601) + const serialized = JSON.stringify(event) + expect(serialized).not.toContain('id_rsa') + expect(serialized).not.toContain('Method not found') + }) + + it('flags a non-JSON line without echoing its content', () => { + const event = extractLogEvent({ direction: 'agent->ws', line: 'WARN booting with token=abc123' }) + expect(event.kind).toBe('non-json') + expect(event.parseError).toBe(true) + expect(event.byteSize).toBeGreaterThan(0) + expect(JSON.stringify(event)).not.toContain('abc123') + }) + + it('drops a non-scalar id rather than serializing it', () => { + const frame = JSON.stringify({ jsonrpc: '2.0', id: { nested: 'secret' }, method: 'initialize' }) + const event = extractLogEvent({ direction: 'agent->ws', line: frame }) + expect(event.id).toBeUndefined() + expect(JSON.stringify(event)).not.toContain('secret') + }) + + it('passes a numeric id through untouched (numbers are bounded)', () => { + const event = extractLogEvent({ direction: 'agent->ws', line: JSON.stringify({ id: 123456789, method: 'initialize' }) }) + expect(event.id).toBe(123456789) + }) + + it('keeps a short string id verbatim', () => { + const event = extractLogEvent({ direction: 'agent->ws', line: JSON.stringify({ id: 'req-42', method: 'initialize' }) }) + expect(event.id).toBe('req-42') + }) + + it('truncates a long content-bearing string id so the content cannot leak', () => { + // The first 8 chars are a harmless prefix; the secrets live past the cutoff. + const content = 'req-0001-SSN-123-45-6789-and-api-key-sk-deadbeef-hidden' + const frame = JSON.stringify({ jsonrpc: '2.0', id: content, method: 'initialize' }) + const event = extractLogEvent({ direction: 'agent->ws', line: frame }) + + expect(typeof event.id).toBe('string') + expect(event.id.length).toBeLessThanOrEqual(9) // 8 chars + '…' + expect(event.id).toBe('req-0001…') + const serialized = JSON.stringify(event) + expect(serialized).not.toContain('SSN') + expect(serialized).not.toContain('123-45-6789') + expect(serialized).not.toContain('sk-deadbeef') + }) + + it('handles valid JSON that is not an rpc object', () => { + const event = extractLogEvent({ direction: 'agent->ws', line: '[1,2,3]' }) + expect(event.kind).toBe('non-rpc') + }) +}) + +describe('sanitizeOrigin', () => { + it('keeps only scheme + host of a browser origin', () => { + expect(sanitizeOrigin('https://app.thunderbird.net')).toBe('https://app.thunderbird.net') + }) + + it('strips any path/query', () => { + expect(sanitizeOrigin('https://evil.test/leak?token=abc')).toBe('https://evil.test') + }) + + it('returns "none" for missing origin', () => { + expect(sanitizeOrigin(undefined)).toBe('none') + expect(sanitizeOrigin('')).toBe('none') + }) + + it('returns "invalid" for an unparseable origin', () => { + expect(sanitizeOrigin('not a url')).toBe('invalid') + }) +}) + +describe('isOriginAllowed', () => { + it('allowlists the Thunderbolt app origins by default', () => { + expect(defaultAllowedOrigins).toContain('https://app.thunderbolt.io') + expect(defaultAllowedOrigins).toContain('tauri://localhost') + expect(defaultAllowedOrigins).toContain('http://tauri.localhost') + expect(defaultAllowedOrigins).toContain('http://localhost:1420') + for (const origin of defaultAllowedOrigins) { + expect(isOriginAllowed(origin, defaultAllowedOrigins)).toBe(true) + } + }) + + it('allows a missing/empty origin (native + Tauri webviews send none)', () => { + expect(isOriginAllowed(undefined, defaultAllowedOrigins)).toBe(true) + expect(isOriginAllowed('', defaultAllowedOrigins)).toBe(true) + }) + + it('rejects an unknown origin', () => { + expect(isOriginAllowed('https://evil.example', defaultAllowedOrigins)).toBe(false) + }) + + it('ignores a trailing slash / path when matching (normalized)', () => { + expect(isOriginAllowed('https://app.thunderbolt.io/', defaultAllowedOrigins)).toBe(true) + }) + + it('rejects an unparseable origin', () => { + expect(isOriginAllowed('not a url', defaultAllowedOrigins)).toBe(false) + }) + + it('honors an extended allowlist', () => { + const extended = [...defaultAllowedOrigins, 'http://localhost:9999'] + expect(isOriginAllowed('http://localhost:9999', extended)).toBe(true) + expect(isOriginAllowed('http://localhost:9999', defaultAllowedOrigins)).toBe(false) + }) +}) + +describe('createLogger', () => { + const capture = () => { + const out = [] + return { stream: { write: (s) => out.push(s) }, out } + } + + it('json mode emits one JSON object per line with level', () => { + const { stream, out } = capture() + const log = createLogger({ json: true, stream }) + log.info({ lifecycle: 'listening', port: 8080 }) + expect(out).toHaveLength(1) + const parsed = JSON.parse(out[0]) + expect(parsed).toEqual({ level: 'info', lifecycle: 'listening', port: 8080 }) + }) + + it('pretty mode emits a compact one-liner with no content column', () => { + const { stream, out } = capture() + const log = createLogger({ json: false, stream }) + log.info({ lifecycle: 'listening', port: 8080 }) + expect(out[0]).toBe('INFO lifecycle=listening port=8080\n') + }) + + it('suppresses debug events unless verbose', () => { + const quiet = capture() + createLogger({ stream: quiet.stream }).debug({ kind: 'request' }) + expect(quiet.out).toHaveLength(0) + + const loud = capture() + createLogger({ verbose: true, stream: loud.stream }).debug({ kind: 'request' }) + expect(loud.out).toHaveLength(1) + }) + + it('omits undefined fields from pretty output', () => { + const { stream, out } = capture() + createLogger({ stream }).info({ lifecycle: 'connected', origin: undefined }) + expect(out[0]).toBe('INFO lifecycle=connected\n') + }) +}) diff --git a/acp-bridge/src/relay.js b/acp-bridge/src/relay.js new file mode 100644 index 000000000..a495dee58 --- /dev/null +++ b/acp-bridge/src/relay.js @@ -0,0 +1,114 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Pure framing relay between an ACP stdio agent and a Thunderbolt WebSocket. + * + * Two different framings meet here: + * - ACP stdio = ndjson: newline-delimited JSON-RPC, one object per line. + * - Thunderbolt WS = one JSON object per WebSocket message. + * + * The bridge's whole job is to translate between them: + * - agent stdout → ws: split into lines, send each non-empty JSON line as ONE + * ws frame. Line-splitting is mandatory — a raw stdout chunk can contain + * several lines (or a partial line), and Thunderbolt does an unguarded + * `JSON.parse(event.data)` per message, so each frame MUST be exactly one + * JSON object. + * - ws message → agent stdin: write the message verbatim plus a trailing '\n' + * so the agent's ndjson reader sees one complete line. + * + * Non-JSON stdout lines are DROPPED (never forwarded) and reported via `onDrop`, + * protecting Thunderbolt's unguarded parse. Empty lines are skipped silently. + * + * This module is pure wiring: it takes a readline interface (already created + * over child.stdout), a `send` function, and a `write` function. No spawning, + * no sockets — fully unit-testable with fakes. + */ + +/** + * Determine whether a stdout line is a forwardable JSON-RPC frame. + * + * A real ACP frame is ALWAYS a JSON object. Bare scalars/arrays (`123`, `"x"`, + * `true`, `null`, `[]`) are never valid JSON-RPC, so they're junk — drop them + * rather than forward them to Thunderbolt's unguarded parse. + * @param {string} line + * @returns {boolean} + */ +const isForwardableJson = (line) => { + const trimmed = line.trim() + if (trimmed.length === 0) return false + try { + const parsed = JSON.parse(trimmed) + return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed) + } catch { + return false + } +} + +/** + * Wire the agent→ws direction: for each line emitted by the readline interface, + * forward it as one ws frame if it is non-empty valid JSON, otherwise drop it. + * + * @param {object} args + * @param {import('node:events').EventEmitter} args.lines - emits 'line' events (a readline.Interface) + * @param {(line: string) => void} args.send - sends one ws frame (caller guards readyState) + * @param {(line: string) => void} [args.onForward] - observability hook for a forwarded line + * @param {(line: string) => void} [args.onDrop] - called with a dropped non-JSON line + * @returns {() => void} detach function removing the listener + */ +export const wireAgentToWs = ({ lines, send, onForward, onDrop }) => { + const handler = (rawLine) => { + const line = rawLine.replace(/\r$/, '') + if (line.trim().length === 0) return + if (!isForwardableJson(line)) { + onDrop?.(line) + return + } + send(line) + onForward?.(line) + } + lines.on('line', handler) + return () => lines.off('line', handler) +} + +/** + * Frame a single ws message for the agent's stdin: stringify Buffers, append the + * mandatory trailing newline. Empty messages produce `null` (nothing to write). + * + * @param {string | Buffer | ArrayBuffer | Buffer[]} data - the ws message payload + * @returns {string | null} + */ +export const frameForStdin = (data) => { + const text = wsDataToString(data) + if (text.length === 0) return null + // Strip any trailing newline the sender added, then add exactly one. + return `${text.replace(/\n+$/, '')}\n` +} + +/** + * Handle one inbound ws message: frame it and write to the agent's stdin. + * + * @param {object} args + * @param {string | Buffer | ArrayBuffer | Buffer[]} args.data - ws message payload + * @param {(chunk: string) => void} args.write - writes to child.stdin + * @param {(chunk: string) => void} [args.onWrite] - observability hook + */ +export const handleWsMessage = ({ data, write, onWrite }) => { + const framed = frameForStdin(data) + if (framed === null) return + write(framed) + onWrite?.(framed) +} + +/** + * Normalize the various ws message payload shapes into a UTF-8 string. + * @param {string | Buffer | ArrayBuffer | Buffer[]} data + * @returns {string} + */ +const wsDataToString = (data) => { + if (typeof data === 'string') return data + if (Array.isArray(data)) return Buffer.concat(data).toString('utf8') + if (data instanceof ArrayBuffer) return Buffer.from(data).toString('utf8') + return Buffer.from(data).toString('utf8') +} diff --git a/acp-bridge/src/relay.test.js b/acp-bridge/src/relay.test.js new file mode 100644 index 000000000..15448bb8e --- /dev/null +++ b/acp-bridge/src/relay.test.js @@ -0,0 +1,133 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { describe, it, expect } from 'bun:test' +import { EventEmitter } from 'node:events' +import { wireAgentToWs, frameForStdin, handleWsMessage } from './relay.js' + +describe('wireAgentToWs (agent stdout -> ws)', () => { + const setup = () => { + const lines = new EventEmitter() + const sent = [] + const dropped = [] + const detach = wireAgentToWs({ + lines, + send: (line) => sent.push(line), + onDrop: (line) => dropped.push(line), + }) + return { lines, sent, dropped, detach } + } + + it('forwards each non-empty JSON line as one ws frame (multi-line chunk -> multi-frame)', () => { + const { lines, sent } = setup() + // readline already splits chunks into lines; simulate three lines arriving. + lines.emit('line', '{"jsonrpc":"2.0","id":1,"method":"initialize"}') + lines.emit('line', '{"jsonrpc":"2.0","id":2,"method":"session/new"}') + lines.emit('line', '{"jsonrpc":"2.0","method":"session/update"}') + + expect(sent).toHaveLength(3) + expect(sent[0]).toBe('{"jsonrpc":"2.0","id":1,"method":"initialize"}') + expect(sent[2]).toBe('{"jsonrpc":"2.0","method":"session/update"}') + }) + + it('each frame is exactly one JSON object (Thunderbolt JSON.parse-safe)', () => { + const { lines, sent } = setup() + lines.emit('line', '{"a":1}') + lines.emit('line', '{"b":2}') + for (const frame of sent) { + expect(() => JSON.parse(frame)).not.toThrow() + } + }) + + it('drops non-JSON lines (does not forward) and reports them', () => { + const { lines, sent, dropped } = setup() + lines.emit('line', 'Starting agent v1.2.3...') + lines.emit('line', '{"jsonrpc":"2.0","id":1}') + lines.emit('line', 'plain log line') + + expect(sent).toEqual(['{"jsonrpc":"2.0","id":1}']) + expect(dropped).toEqual(['Starting agent v1.2.3...', 'plain log line']) + }) + + it('skips empty and whitespace-only lines', () => { + const { lines, sent, dropped } = setup() + lines.emit('line', '') + lines.emit('line', ' ') + lines.emit('line', '\t') + lines.emit('line', '{"ok":true}') + + expect(sent).toEqual(['{"ok":true}']) + expect(dropped).toEqual([]) // empties are skipped, not "dropped" + }) + + it('strips a trailing carriage return (CRLF agents)', () => { + const { lines, sent } = setup() + lines.emit('line', '{"id":1}\r') + expect(sent).toEqual(['{"id":1}']) + }) + + it('drops bare scalars and arrays — a real ACP frame is always a JSON object', () => { + const { lines, sent, dropped } = setup() + lines.emit('line', '123') + lines.emit('line', '"x"') + lines.emit('line', 'true') + lines.emit('line', 'null') + lines.emit('line', '[]') + lines.emit('line', '[{"id":1}]') + lines.emit('line', '{"id":1}') // the only forwardable object + + expect(sent).toEqual(['{"id":1}']) + expect(dropped).toEqual(['123', '"x"', 'true', 'null', '[]', '[{"id":1}]']) + }) + + it('detach removes the listener', () => { + const { lines, sent, detach } = setup() + detach() + lines.emit('line', '{"id":1}') + expect(sent).toEqual([]) + }) +}) + +describe('frameForStdin (ws -> agent stdin framing)', () => { + it('appends exactly one trailing newline', () => { + expect(frameForStdin('{"id":1}')).toBe('{"id":1}\n') + }) + + it('does not double the newline if the sender already added one', () => { + expect(frameForStdin('{"id":1}\n')).toBe('{"id":1}\n') + expect(frameForStdin('{"id":1}\n\n')).toBe('{"id":1}\n') + }) + + it('handles Buffer payloads', () => { + expect(frameForStdin(Buffer.from('{"id":2}'))).toBe('{"id":2}\n') + }) + + it('handles ArrayBuffer payloads', () => { + const ab = new TextEncoder().encode('{"id":3}').buffer + expect(frameForStdin(ab)).toBe('{"id":3}\n') + }) + + it('handles fragmented Buffer[] payloads', () => { + expect(frameForStdin([Buffer.from('{"id'), Buffer.from('":4}')])).toBe('{"id":4}\n') + }) + + it('returns null for empty messages (nothing to write)', () => { + expect(frameForStdin('')).toBeNull() + expect(frameForStdin(Buffer.from(''))).toBeNull() + }) +}) + +describe('handleWsMessage', () => { + it('writes the framed message to stdin', () => { + const written = [] + handleWsMessage({ data: '{"id":1}', write: (chunk) => written.push(chunk) }) + expect(written).toEqual(['{"id":1}\n']) + }) + + it('writes nothing for an empty message', () => { + const written = [] + handleWsMessage({ data: '', write: (chunk) => written.push(chunk) }) + expect(written).toEqual([]) + }) +}) diff --git a/acp-bridge/src/server.js b/acp-bridge/src/server.js new file mode 100644 index 000000000..66d1c7401 --- /dev/null +++ b/acp-bridge/src/server.js @@ -0,0 +1,296 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Lifecycle wiring for acp-bridge: spawn the agent, stand up a localhost + * WebSocket server, connect them through the pure relay, and manage shutdown. + * + * Design constraints honored here: + * - Single persistent child reused across ws reconnects (Thunderbolt + * reconnects up to 3x; a per-connection child would lose session state). + * - stdio ['pipe','pipe','inherit'] — the agent's own stderr passes through + * untouched to the user's terminal (PII-safe: we never parse or log it). + * - error handlers everywhere (ENOENT, EADDRINUSE, EPIPE) so Node never + * crashes with an unhandled 'error'. + * - Ready banner only after the server is listening AND the child has + * survived the grace window. + * - SIGINT/SIGTERM → close ws 1000 + SIGTERM the child, escalating to SIGKILL. + * - child exit → close ws 1011 + exit. + * + * Dependencies (spawn, WebSocketServer, readline factory, clock) are injected so + * the lifecycle can be exercised with fakes. + */ + +import { exitCodes, spawnError, serverError, earlyExitError } from './errors.js' +import { wireAgentToWs, handleWsMessage } from './relay.js' +import { extractLogEvent, sanitizeOrigin, isOriginAllowed, defaultAllowedOrigins } from './log.js' + +const GRACE_MS = 750 +const KILL_ESCALATION_MS = 2000 +const WS_OPEN = 1 +const WS_CLOSE_POLICY_VIOLATION = 1008 + +/** + * Start the bridge. Resolves once the ready banner has been emitted (server + * listening + child survived grace). Rejects on a fatal startup error after + * printing an actionable message and setting the exit code. + * + * @param {object} cfg + * @param {string[]} cfg.agentCmd - [command, ...args] + * @param {string} cfg.host + * @param {number} cfg.port - 0 = ephemeral + * @param {string[]} [cfg.allowOrigins] - extra Origins to accept (beyond the Thunderbolt defaults) + * @param {boolean} [cfg.allowAnyOrigin] - disable the Origin check entirely (loud escape hatch) + * @param {ReturnType} cfg.logger + * @param {object} deps + * @param {typeof import('node:child_process').spawn} deps.spawn + * @param {new (opts: object) => import('ws').WebSocketServer} deps.WebSocketServer + * @param {(stream: NodeJS.ReadableStream) => import('node:events').EventEmitter} deps.createLineReader + * @param {(label: string) => void} [deps.onBanner] - prints the ready banner + * @param {(stop: (reason: string, code: number) => void) => void} [deps.onStop] - receives the stop fn synchronously (before grace resolves) + * @param {(code: number) => void} [deps.exit] - process.exit (injectable) + * @returns {Promise<{ stop: (reason: string, code: number) => void }>} + */ +export const startBridge = async (cfg, deps) => { + const { agentCmd, host, port, logger, allowOrigins = [], allowAnyOrigin = false } = cfg + const { spawn, WebSocketServer, createLineReader, onBanner, exit = process.exit } = deps + + const cmd0 = agentCmd[0] + const allowlist = [...defaultAllowedOrigins, ...allowOrigins] + + if (allowAnyOrigin) { + logger.warn({ lifecycle: 'origin-check-disabled' }) + process.stderr.write( + '\nWARNING: --allow-any-origin is set — the Origin check is OFF.\n' + + 'Any web page open in a browser on this machine can connect to the bridge\n' + + 'and drive your agent. Use this only for trusted dev/self-host setups.\n', + ) + } + + if (!isLoopbackHost(host)) { + process.stderr.write( + `\nWARNING: --host ${host} is not a loopback address — the bridge (and your\n` + + 'agent) is now reachable by other hosts on the network, not just this\n' + + 'machine. Keep the default 127.0.0.1 unless you really need remote access.\n', + ) + } + + const child = spawn(cmd0, agentCmd.slice(1), { stdio: ['pipe', 'pipe', 'inherit'] }) + + /** @type {import('ws').WebSocketServer | null} */ + let wss = null + /** @type {import('ws').WebSocket | null} */ + let activeSocket = null + let shuttingDown = false + let ready = false + let exited = false + + // One-shot final exit. After a signal-driven stop, the actual exit is deferred + // to the child's 'exit' event (or the SIGKILL fallback timer), so guard it. + const finalExit = (code) => { + if (exited) return + exited = true + exit(code) + } + + const safeExit = (code) => { + if (shuttingDown) return + shuttingDown = true + finalExit(code) + } + + // --- agent stdout → ws (single persistent reader, reused across reconnects) --- + const lines = createLineReader(child.stdout) + wireAgentToWs({ + lines, + send: (line) => { + if (activeSocket && activeSocket.readyState === WS_OPEN) activeSocket.send(line) + }, + onForward: (line) => + logger.debug(extractLogEvent({ direction: 'agent->ws', line })), + // A dropped line is a raw, non-JSON stdout line that may contain content. + // Extract ONLY its byte length here — the line text is never logged. + onDrop: (line) => + logger.warn({ lifecycle: 'dropped-non-json', byteSize: Buffer.byteLength(line) }), + }) + + child.stdin.on('error', (err) => { + // EPIPE when the agent closed stdin — log lifecycle, don't crash. + logger.warn({ lifecycle: 'stdin-error', errorCode: err.code }) + }) + + child.stdout.on('error', (err) => { + // An unhandled stdout 'error' would crash Node — log the code only (PII-safe). + logger.warn({ lifecycle: 'stdout-error', errorCode: err.code }) + }) + + return new Promise((resolve, reject) => { + const closeWebSocket = (code) => { + if (activeSocket && activeSocket.readyState === WS_OPEN) activeSocket.close(code) + wss?.close() + } + + // The exit code a signal-driven stop should ultimately exit with. The child's + // 'exit' handler reads it so the actual process.exit happens only once the + // child has died (or the SIGKILL fallback fires). + let stopCode = null + /** @type {ReturnType | null} */ + let killTimer = null + + /** + * Stop the bridge on a signal: close the ws, SIGTERM the child, and DEFER the + * final exit — let the child's 'exit' handler drive it once the agent dies. + * A REF'd fallback timer escalates to SIGKILL (and forces exit) if a stubborn + * agent ignores SIGTERM, so it can never be orphaned. + * @param {string} reason + * @param {number} code + */ + const stop = (reason, code) => { + if (shuttingDown) return + shuttingDown = true + stopCode = code + logger.info({ lifecycle: 'stopping', reason }) + closeWebSocket(1000) + process.stderr.write('\nStopping…\n') + + // Already dead? Exit straight away. + if (child.exitCode !== null || child.signalCode !== null) { + finalExit(code) + return + } + + child.kill('SIGTERM') + killTimer = setTimeout(() => { + logger.warn({ lifecycle: 'kill-escalation' }) + child.kill('SIGKILL') + finalExit(code) + }, KILL_ESCALATION_MS) + } + + // --- child error / early-exit handling ----------------------------------- + child.on('error', (err) => { + const { message, exitCode } = spawnError(err, { cmd0 }) + logger.error({ lifecycle: 'spawn-failed', errorCode: err.code }) + process.stderr.write(`\n${message}\n`) + closeWebSocket(1011) + reject(Object.assign(new Error(message), { exitCode })) + safeExit(exitCode) + }) + + child.on('exit', (code, signal) => { + // A signal-driven stop is in progress: the child has now died, so clear the + // SIGKILL fallback and drive the deferred final exit. + if (shuttingDown) { + if (killTimer) clearTimeout(killTimer) + logger.info({ lifecycle: 'agent-exited', exitCode: code ?? undefined, signal: signal ?? undefined }) + process.stderr.write('\nStopped.\n') + finalExit(stopCode ?? exitCodes.ok) + return + } + if (!ready) { + const { message, exitCode } = earlyExitError({ code, signal, cmd0 }) + logger.error({ lifecycle: 'agent-early-exit', exitCode: code ?? undefined, signal: signal ?? undefined }) + process.stderr.write(`\n${message}\n`) + process.stderr.write('(the agent\'s own output above may say why)\n') + closeWebSocket(1011) + reject(Object.assign(new Error(message), { exitCode })) + safeExit(exitCode) + return + } + logger.info({ lifecycle: 'agent-exited', exitCode: code ?? undefined, signal: signal ?? undefined }) + process.stderr.write('\nAgent exited. Stopping bridge.\n') + closeWebSocket(1011) + safeExit(code === 0 || code == null ? exitCodes.ok : exitCodes.unavailable) + }) + + // --- WebSocket server ----------------------------------------------------- + // verifyClient runs DURING the upgrade handshake: a disallowed Origin is + // rejected with HTTP 403 and the WebSocket is never established, so a hostile + // web page can't even briefly connect. The 'connection' handler below repeats + // the check as deterministic defense-in-depth (closing with 1008) for any + // path that bypasses verifyClient. + const verifyClient = ({ origin }) => + allowAnyOrigin || isOriginAllowed(origin, allowlist) + wss = new WebSocketServer({ host, port, verifyClient }) + + wss.on('error', (err) => { + const { message, exitCode } = serverError(err, { host, port }) + logger.error({ lifecycle: 'server-error', errorCode: err.code }) + process.stderr.write(`\n${message}\n`) + reject(Object.assign(new Error(message), { exitCode })) + safeExit(exitCode) + }) + + wss.on('connection', (socket, request) => { + const rawOrigin = request?.headers?.origin + const origin = sanitizeOrigin(rawOrigin) + + // Browser WebSocket connections aren't same-origin-protected: reject any + // Origin that isn't a known Thunderbolt app origin so a random web page on + // this machine can't hijack the local agent. The origin string is PII-safe + // to log (sanitized to scheme + host). + if (!allowAnyOrigin && !isOriginAllowed(rawOrigin, allowlist)) { + logger.warn({ lifecycle: 'origin-rejected', origin }) + socket.close(WS_CLOSE_POLICY_VIOLATION) + return + } + + logger.info({ lifecycle: 'connected', origin }) + activeSocket = socket + + socket.on('message', (data) => + handleWsMessage({ + data, + write: (chunk) => child.stdin.write(chunk), + onWrite: (chunk) => + logger.debug(extractLogEvent({ direction: 'ws->agent', line: chunk.replace(/\n$/, '') })), + }), + ) + socket.on('error', (err) => logger.warn({ lifecycle: 'socket-error', errorCode: err.code })) + socket.on('close', (closeCode) => { + if (activeSocket === socket) activeSocket = null + logger.info({ lifecycle: 'disconnected', closeCode }) + }) + }) + + wss.on('listening', () => { + const resolvedPort = resolvePort(wss, port) + logger.info({ lifecycle: 'listening', port: resolvedPort }) + // Banner only after the child also survives the grace window. + setTimeout(() => { + if (shuttingDown) return + if (child.exitCode !== null || child.signalCode !== null) return // exit handler already fired + ready = true + onBanner?.(`ws://${host}:${resolvedPort}`) + resolve({ stop }) + }, GRACE_MS) + }) + + deps.onStop?.(stop) + }) +} + +/** + * Whether a bind host is a loopback address (only reachable from this machine). + * A non-loopback host exposes the agent to other hosts on the network, which + * warrants a prominent startup warning. + * @param {string} host + * @returns {boolean} + */ +const isLoopbackHost = (host) => + host === '127.0.0.1' || host === 'localhost' || host === '::1' + +/** + * Resolve the actual listening port (ephemeral 0 → OS-assigned). + * @param {import('ws').WebSocketServer | null} wss + * @param {number} requested + * @returns {number} + */ +const resolvePort = (wss, requested) => { + const address = wss?.address?.() + if (address && typeof address === 'object' && typeof address.port === 'number') { + return address.port + } + return requested +} diff --git a/acp-bridge/src/server.test.js b/acp-bridge/src/server.test.js new file mode 100644 index 000000000..c68884423 --- /dev/null +++ b/acp-bridge/src/server.test.js @@ -0,0 +1,321 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { describe, it, expect } from 'bun:test' +import { EventEmitter } from 'node:events' +import { startBridge } from './server.js' +import { createLogger } from './log.js' + +/** + * A fake child process: pipes for stdin/stdout, emits exit/error. + * + * @param {{ ignoreSigterm?: boolean }} [opts] - when ignoreSigterm is set, the + * child records the signal but does NOT die on SIGTERM (it only dies on + * SIGKILL), modeling a stubborn agent so the escalation path can be tested. + */ +const makeFakeChild = ({ ignoreSigterm = false } = {}) => { + const child = new EventEmitter() + child.exitCode = null + child.signalCode = null + child.stdin = Object.assign(new EventEmitter(), { + written: [], + write(chunk) { + this.written.push(chunk) + return true + }, + }) + child.stdout = new EventEmitter() + child.killed = [] + child.kill = (sig) => { + child.killed.push(sig) + if (sig === 'SIGTERM' && ignoreSigterm) return true + // A real child dies asynchronously, then Node emits 'exit'. Mirror that. + child.exitCode = 0 + child.signalCode = sig + queueMicrotask(() => child.emit('exit', 0, sig)) + return true + } + return child +} + +const ALLOWED_ORIGIN = 'https://app.thunderbolt.io' + +/** A fake WebSocketServer that lets the test drive listening/connection. */ +const makeFakeWss = (port) => { + const wss = new EventEmitter() + wss.closed = false + wss.address = () => ({ port }) + wss.close = () => { + wss.closed = true + } + return wss +} + +const makeFakeSocket = () => { + const socket = new EventEmitter() + socket.readyState = 1 // OPEN + socket.sent = [] + socket.closedWith = null + socket.send = (line) => socket.sent.push(line) + socket.close = (code) => { + socket.closedWith = code + } + return socket +} + +const quietLogger = () => createLogger({ stream: { write: () => {} } }) + +/** Drive a bridge to "ready" and return all the moving parts. */ +const startReady = async ({ port = 5000, grace = 800, child = makeFakeChild(), cfg = {} } = {}) => { + const wss = makeFakeWss(port) + let exited = null + let stopFn = null + let banner = null + + const promise = startBridge( + { agentCmd: ['my-agent', '--flag'], host: '127.0.0.1', port: 0, logger: quietLogger(), ...cfg }, + { + spawn: () => child, + WebSocketServer: function () { + return wss + }, + createLineReader: (stream) => { + const lines = new EventEmitter() + stream.on('data', (chunk) => { + for (const line of String(chunk).split('\n')) lines.emit('line', line) + }) + return lines + }, + onBanner: (url) => { + banner = url + }, + onStop: (stop) => { + stopFn = stop + }, + exit: (code) => { + exited = code + }, + }, + ) + + // Server reports listening, then the grace timer fires. + wss.emit('listening') + await new Promise((r) => setTimeout(r, grace)) + await promise + + return { child, wss, getExit: () => exited, getStop: () => stopFn, getBanner: () => banner } +} + +/** Open an allowed connection (default Thunderbolt origin) on a ready bridge. */ +const connect = (wss, { origin = ALLOWED_ORIGIN } = {}) => { + const socket = makeFakeSocket() + const headers = origin === undefined ? {} : { origin } + wss.emit('connection', socket, { headers }) + return socket +} + +describe('startBridge lifecycle', () => { + it('prints the banner with the resolved ephemeral port after grace', async () => { + const { getBanner } = await startReady({ port: 54321 }) + expect(getBanner()).toBe('ws://127.0.0.1:54321') + }) + + it('relays agent stdout lines to the connected socket', async () => { + const { child, wss } = await startReady() + const socket = connect(wss) + + child.stdout.emit('data', '{"id":1}\n{"id":2}\nplain log\n') + expect(socket.sent).toEqual(['{"id":1}', '{"id":2}']) // non-JSON dropped + }) + + it('relays socket messages to agent stdin with a trailing newline', async () => { + const { child, wss } = await startReady() + // A missing Origin is allowed (native/Tauri webviews send none). + const socket = connect(wss, { origin: undefined }) + + socket.emit('message', '{"id":9}') + expect(child.stdin.written).toEqual(['{"id":9}\n']) + }) + + it('reuses the single child across reconnects', async () => { + const { child, wss } = await startReady() + const first = connect(wss) + first.emit('close', 1000) + + const second = connect(wss) + child.stdout.emit('data', '{"id":3}\n') + + expect(second.sent).toEqual(['{"id":3}']) + expect(first.sent).toEqual([]) // old socket no longer receives + }) + + it('stop() closes ws with 1000, SIGTERMs the child, and exits 130 once it dies', async () => { + const { child, wss, getStop, getExit } = await startReady() + const socket = connect(wss) + + getStop()('signal', 130) + expect(socket.closedWith).toBe(1000) + expect(wss.closed).toBe(true) + expect(child.killed).toContain('SIGTERM') + // Exit is deferred until the child actually exits (driven by child 'exit'). + await new Promise((r) => setTimeout(r, 0)) + expect(getExit()).toBe(130) + }) + + it('stop() SIGKILLs a stubborn child that ignores SIGTERM, then exits', async () => { + const stubborn = makeFakeChild({ ignoreSigterm: true }) + const { getStop, getExit } = await startReady({ child: stubborn }) + + getStop()('signal', 130) + expect(stubborn.killed).toContain('SIGTERM') + await new Promise((r) => setTimeout(r, 0)) + expect(getExit()).toBeNull() // SIGTERM ignored → not dead yet, no exit + + // Fast-forward past the 2s escalation window → SIGKILL + forced exit. + await new Promise((r) => setTimeout(r, 2100)) + expect(stubborn.killed).toContain('SIGKILL') + expect(getExit()).toBe(130) + }) + + it('child early-exit before ready rejects with exit 69', async () => { + const child = makeFakeChild() + const wss = makeFakeWss(6000) + let exited = null + + const promise = startBridge( + { agentCmd: ['broken-agent'], host: '127.0.0.1', port: 0, logger: quietLogger() }, + { + spawn: () => child, + WebSocketServer: function () { + return wss + }, + createLineReader: () => new EventEmitter(), + exit: (code) => { + exited = code + }, + }, + ) + + wss.emit('listening') + // Child dies during the grace window, before the banner. + child.emit('exit', 1, null) + + await expect(promise).rejects.toMatchObject({ exitCode: 69 }) + expect(exited).toBe(69) + }) + + it('spawn ENOENT rejects with exit 69', async () => { + const child = makeFakeChild() + const wss = makeFakeWss(6001) + let exited = null + + const promise = startBridge( + { agentCmd: ['nope'], host: '127.0.0.1', port: 0, logger: quietLogger() }, + { + spawn: () => child, + WebSocketServer: function () { + return wss + }, + createLineReader: () => new EventEmitter(), + exit: (code) => { + exited = code + }, + }, + ) + + child.emit('error', Object.assign(new Error('spawn nope ENOENT'), { code: 'ENOENT' })) + + await expect(promise).rejects.toMatchObject({ exitCode: 69 }) + expect(exited).toBe(69) + }) + + it('server EADDRINUSE rejects with exit 69', async () => { + const child = makeFakeChild() + const wss = makeFakeWss(6002) + let exited = null + + const promise = startBridge( + { agentCmd: ['agent'], host: '127.0.0.1', port: 8080, logger: quietLogger() }, + { + spawn: () => child, + WebSocketServer: function () { + return wss + }, + createLineReader: () => new EventEmitter(), + exit: (code) => { + exited = code + }, + }, + ) + + wss.emit('error', Object.assign(new Error('listen EADDRINUSE'), { code: 'EADDRINUSE' })) + + await expect(promise).rejects.toMatchObject({ exitCode: 69 }) + expect(exited).toBe(69) + }) +}) + +describe('startBridge — Origin allowlist (cross-origin hijack guard)', () => { + it('accepts an allowed Thunderbolt origin', async () => { + const { child, wss } = await startReady() + const socket = connect(wss, { origin: 'https://app.thunderbolt.io' }) + + expect(socket.closedWith).toBeNull() + child.stdout.emit('data', '{"id":1}\n') + expect(socket.sent).toEqual(['{"id":1}']) + }) + + it('rejects a disallowed origin with close code 1008 and forwards nothing', async () => { + const { child, wss } = await startReady() + const socket = connect(wss, { origin: 'https://evil.example' }) + + expect(socket.closedWith).toBe(1008) + child.stdout.emit('data', '{"id":1}\n') + expect(socket.sent).toEqual([]) // never wired up + }) + + it('allows a missing/empty origin (native + Tauri webviews send none)', async () => { + const { child, wss } = await startReady() + const socket = connect(wss, { origin: undefined }) + + expect(socket.closedWith).toBeNull() + child.stdout.emit('data', '{"id":1}\n') + expect(socket.sent).toEqual(['{"id":1}']) + }) + + it('--allow-origin extends the allowlist', async () => { + const { wss } = await startReady({ cfg: { allowOrigins: ['http://localhost:9999'] } }) + const socket = connect(wss, { origin: 'http://localhost:9999' }) + expect(socket.closedWith).toBeNull() + }) + + it('--allow-any-origin accepts everything and warns once at startup', async () => { + const warned = [] + const logger = createLogger({ stream: { write: (s) => warned.push(s) } }) + const { wss } = await startReady({ cfg: { allowAnyOrigin: true, logger } }) + + const socket = connect(wss, { origin: 'https://evil.example' }) + expect(socket.closedWith).toBeNull() // accepted despite a junk origin + + expect(warned.some((line) => line.includes('origin-check-disabled'))).toBe(true) + }) +}) + +describe('startBridge — dropped non-JSON line never logs content', () => { + it('logs only lifecycle + byteSize for a dropped stdout line, never the text', async () => { + const logged = [] + const logger = createLogger({ verbose: true, stream: { write: (s) => logged.push(s) } }) + const { child, wss } = await startReady({ cfg: { logger } }) + connect(wss) + + const secret = 'WARN booting with token=sk-deadbeef-secret' + child.stdout.emit('data', `${secret}\n`) + + const all = logged.join('') + expect(all).toContain('dropped-non-json') + expect(all).toContain(`byteSize=${Buffer.byteLength(secret)}`) + expect(all).not.toContain('sk-deadbeef-secret') + expect(all).not.toContain('token=') + }) +}) From 54974c4b0f45b74f631c6264f6a046f5a0aa8f2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=8Dtalo=20Menezes?= Date: Tue, 16 Jun 2026 11:21:40 -0300 Subject: [PATCH 05/23] feat: route loopback agent connections directly, skipping the proxy --- src/acp/transports/index.test.ts | 51 +++++++++++++++++++ src/acp/transports/index.ts | 15 +++++- src/acp/transports/is-loopback.test.ts | 27 ++++++++++ src/acp/transports/is-loopback.ts | 46 +++++++++++++++++ .../agents/add-custom-agent-dialog.test.tsx | 13 +++++ .../agents/add-custom-agent-dialog.tsx | 9 ++++ 6 files changed, 160 insertions(+), 1 deletion(-) create mode 100644 src/acp/transports/is-loopback.test.ts create mode 100644 src/acp/transports/is-loopback.ts diff --git a/src/acp/transports/index.test.ts b/src/acp/transports/index.test.ts index dca112df4..d6eb15484 100644 --- a/src/acp/transports/index.test.ts +++ b/src/acp/transports/index.test.ts @@ -223,4 +223,55 @@ describe('openTransport — agent-type routing', () => { transport.close() }) + + it.each(['ws://127.0.0.1:7777/acp', 'ws://localhost:7777/acp', 'ws://[::1]:7777/acp', 'ws://sub.localhost:7777/acp'])( + 'remote-acp loopback target %s connects directly on Web (no proxy)', + async (url) => { + // The acp-bridge carve-out: a loopback remote-acp target is the local + // bridge socket. On Web (Connected) it must skip the cloud proxy — the + // proxy can't reach localhost — and connect natively to the URL as-is. + const transport = await openTransport({ + url, + transport: 'websocket', + agentType: 'remote-acp', + signal: new AbortController().signal, + isStandalone: () => false, + readProxyEnabled: () => null, + backoffMs: () => 1, + httpClient: stubHttpClient, + getAuthToken: () => 'token-abc', + }) + + expect(FakeBrowserSocket.instances).toHaveLength(1) + const socket = FakeBrowserSocket.instances[0] + expect(socket.url).toBe(url) + // No proxy target subprotocol, and no bearer — it's a direct local connect. + expect(socket.protocols.some((p) => p.startsWith(wsTargetPrefix))).toBe(false) + expect(socket.protocols).toHaveLength(0) + + transport.close() + }, + ) + + it('remote-acp non-loopback target on Web still routes through the proxy (unchanged)', async () => { + // Guard against the loopback carve-out leaking into the public-host path. + const transport = await openTransport({ + url: 'wss://agent.example.com/acp', + transport: 'websocket', + agentType: 'remote-acp', + signal: new AbortController().signal, + isStandalone: () => false, + readProxyEnabled: () => null, + backoffMs: () => 1, + httpClient: stubHttpClient, + getAuthToken: () => 'proxy-token-xyz', + }) + + expect(FakeBrowserSocket.instances).toHaveLength(1) + const socket = FakeBrowserSocket.instances[0] + expect(socket.url).toBe('ws://cloud.test/v1/proxy/ws') + expect(socket.protocols.some((p) => p.startsWith(wsTargetPrefix))).toBe(true) + + transport.close() + }) }) diff --git a/src/acp/transports/index.ts b/src/acp/transports/index.ts index 782c22b12..57a4a5429 100644 --- a/src/acp/transports/index.ts +++ b/src/acp/transports/index.ts @@ -19,6 +19,12 @@ * (true Standalone — no backend reachable). * - `remote-acp` (user-configured external agents): Connected vs Standalone * is layered orthogonally: + * - Loopback target (127.0.0.1 / localhost / [::1] / *.localhost): native + * `new WebSocket()` on every platform, web included. A browser reaching + * its own machine has no SSRF surface — the proxy's localhost rejection + * protects the *cloud backend*, which is irrelevant here — and the proxy + * would reject the `ws://`/private-host target anyway. This is the + * `acp-bridge` path: a local stdio agent bridged to a localhost socket. * - Web (always Connected): proxied WebSocket via `createProxyWebSocket`. * - Tauri + proxy toggle ON (Connected): proxied WebSocket. * - Tauri + proxy toggle OFF (Standalone): native `new WebSocket()`. @@ -36,6 +42,7 @@ import { useLocalSettingsStore } from '@/stores/local-settings-store' import type { AgentType } from '@shared/acp-types' import { encodeWsBearer, wsBearerSubprotocolPrefix, wsCarrierSubprotocol } from '@shared/ws-bearer' import type { AcpTransport } from '../types' +import { isLoopbackUrl } from './is-loopback' import { openWebSocketTransport, type WebSocketFactory, type WebSocketLike } from './websocket' export type OpenTransportInputs = { @@ -104,10 +111,16 @@ export const openTransport = async (inputs: OpenTransportInputs): Promise { +export const resolveWebSocketFactory = (inputs: OpenTransportInputs): WebSocketFactory => { if (inputs.agentType === 'managed-acp') { return resolveManagedAcpFactory(inputs) } + // A loopback remote-acp target is the local `acp-bridge` socket — connect + // directly, skipping the cloud proxy, on every platform (web included). The + // proxy can't reach localhost and would reject the target regardless. + if (isLoopbackUrl(inputs.url)) { + return nativeWebSocketFactory + } if (isStandaloneTransport(inputs.isStandalone, inputs.readProxyEnabled)) { return nativeWebSocketFactory } diff --git a/src/acp/transports/is-loopback.test.ts b/src/acp/transports/is-loopback.test.ts new file mode 100644 index 000000000..fe2875d74 --- /dev/null +++ b/src/acp/transports/is-loopback.test.ts @@ -0,0 +1,27 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { describe, expect, it } from 'bun:test' +import { isLoopbackHost } from './is-loopback' + +describe('isLoopbackHost', () => { + it.each(['localhost', 'LOCALHOST', '127.0.0.1', '127.1.2.3', '::1', '[::1]', 'sub.localhost', 'app.dev.localhost'])( + 'treats %s as loopback', + (host) => { + expect(isLoopbackHost(host)).toBe(true) + }, + ) + + it.each([ + 'example.com', + '192.0.2.1', + 'wss-public.example.org', + '10.0.0.1', + 'localhost.example.com', + '::2', + '128.0.0.1', + ])('treats %s as non-loopback', (host) => { + expect(isLoopbackHost(host)).toBe(false) + }) +}) diff --git a/src/acp/transports/is-loopback.ts b/src/acp/transports/is-loopback.ts new file mode 100644 index 000000000..de8eac151 --- /dev/null +++ b/src/acp/transports/is-loopback.ts @@ -0,0 +1,46 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * True when `host` refers to the loopback interface — `localhost`, any + * `*.localhost` subdomain, the IPv4 loopback block `127.0.0.0/8`, or the IPv6 + * loopback `::1`. Mirrors the backend's loopback test in + * `backend/src/utils/url-validation.ts` (kept in sync by hand — both sides care + * about the same set), but stays dependency-free so it doesn't pull `ipaddr.js` + * into the frontend bundle. + * + * Used to carve loopback ACP targets out of the cloud-proxy path: a browser + * connecting to its own machine has no SSRF surface (the proxy's localhost + * rejection protects the *cloud backend*, which is irrelevant here), so we let + * it connect directly with a native `WebSocket`. + * + * Accepts a bare hostname; bracketed IPv6 (`[::1]`) is unwrapped so callers can + * pass either `URL.hostname` (already unbracketed) or a raw host token. + */ +export const isLoopbackHost = (host: string): boolean => { + const unwrapped = host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host + const h = unwrapped.toLowerCase() + if (h === 'localhost' || h.endsWith('.localhost')) { + return true + } + if (h === '::1') { + return true + } + // IPv4 loopback block 127.0.0.0/8 — any address whose first octet is 127. + return /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(h) +} + +/** + * True when `url` is a parseable WebSocket/HTTP URL whose host is loopback (see + * `isLoopbackHost`). Unparseable input is treated as non-loopback. The browser's + * URL parser canonicalizes IPv4 shorthand/octal/hex (e.g. `0x7f.0.0.1`, + * `127.1`, `2130706433`) to `127.0.0.1` before the host check. + */ +export const isLoopbackUrl = (url: string): boolean => { + try { + return isLoopbackHost(new URL(url).hostname) + } catch { + return false + } +} diff --git a/src/components/settings/agents/add-custom-agent-dialog.test.tsx b/src/components/settings/agents/add-custom-agent-dialog.test.tsx index 8140660e8..00397ade2 100644 --- a/src/components/settings/agents/add-custom-agent-dialog.test.tsx +++ b/src/components/settings/agents/add-custom-agent-dialog.test.tsx @@ -282,6 +282,19 @@ describe('AddCustomAgentDialog — connection status', () => { expect(submitAfterSuccess).not.toBeDisabled() }) + it('shows the local-network hint for a loopback URL and hides it for a public URL', () => { + renderWithProbe(async () => ({ success: true })) + const hint = /local network/i + + expect(screen.queryByText(hint)).not.toBeInTheDocument() + + fireEvent.change(screen.getByLabelText(/url/i), { target: { value: 'ws://127.0.0.1:7777/acp' } }) + expect(screen.getByText(hint)).toBeInTheDocument() + + fireEvent.change(screen.getByLabelText(/url/i), { target: { value: 'wss://agent.example.com/acp' } }) + expect(screen.queryByText(hint)).not.toBeInTheDocument() + }) + it('clears a prior connection result when the URL changes', async () => { renderWithProbe(async () => ({ success: true })) diff --git a/src/components/settings/agents/add-custom-agent-dialog.tsx b/src/components/settings/agents/add-custom-agent-dialog.tsx index b3c66bc9c..e08910372 100644 --- a/src/components/settings/agents/add-custom-agent-dialog.tsx +++ b/src/components/settings/agents/add-custom-agent-dialog.tsx @@ -17,6 +17,7 @@ import { Dialog } from '@/components/ui/dialog' import { StatusCard } from '@/components/ui/status-card' import { getPlatform, isTauri } from '@/lib/platform' import { testAcpConnection as defaultTestAcpConnection } from '@/acp' +import { isLoopbackUrl } from '@/acp/transports/is-loopback' /** Maps a user-entered URL to the ACP transport flavor we support, or `null` * when the scheme is unsupported (or the URL is malformed). WebSocket is the @@ -158,6 +159,9 @@ export const AddCustomAgentDialog = ({ trimmedName.length > 0 && trimmedUrl.length > 0 && state.connectionStatus === 'success' && !state.submitting // The probe is only meaningful once the URL is a valid WebSocket endpoint. const canTestConnection = trimmedUrl.length > 0 && !urlError + // Loopback targets (the local acp-bridge socket) trip the browser's Local + // Network Access prompt — hint the user so the Allow dialog isn't a surprise. + const showLoopbackHint = !urlError && isLoopbackUrl(trimmedUrl) const handleOpenChange = (next: boolean) => { if (!next) { @@ -226,6 +230,11 @@ export const AddCustomAgentDialog = ({

WebSocket endpoint for the remote ACP agent

+ {showLoopbackHint && ( +

+ Your browser may ask permission to reach your local network — click Allow. +

+ )}
From 344c9e9dccaf9d362ccf6e9c9f55decb36cb1353 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=8Dtalo=20Menezes?= Date: Tue, 16 Jun 2026 11:21:45 -0300 Subject: [PATCH 06/23] feat: add Connect via bridge action to the agent catalog --- .../settings/agents/agent-catalog-card.tsx | 25 ++++- .../agents/agent-catalog-view.test.tsx | 27 ++++- .../settings/agents/agent-catalog-view.tsx | 12 ++- .../settings/agents/agent-catalog.tsx | 9 +- .../settings/agents/bridge-connect-dialog.tsx | 98 +++++++++++++++++++ .../settings/agents/copyable-command.tsx | 36 +++++++ src/lib/agent-bridge-command.test.ts | 77 +++++++++++++++ src/lib/agent-bridge-command.ts | 60 ++++++++++++ src/routes/settings/agents/index.tsx | 2 +- 9 files changed, 328 insertions(+), 18 deletions(-) create mode 100644 src/components/settings/agents/bridge-connect-dialog.tsx create mode 100644 src/components/settings/agents/copyable-command.tsx create mode 100644 src/lib/agent-bridge-command.test.ts create mode 100644 src/lib/agent-bridge-command.ts diff --git a/src/components/settings/agents/agent-catalog-card.tsx b/src/components/settings/agents/agent-catalog-card.tsx index 145f9aaec..5ddb4011b 100644 --- a/src/components/settings/agents/agent-catalog-card.tsx +++ b/src/components/settings/agents/agent-catalog-card.tsx @@ -2,22 +2,27 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { Code2, ExternalLink, Terminal } from 'lucide-react' +import { Code2, ExternalLink, Plug, Terminal } from 'lucide-react' import { useState } from 'react' import { Button } from '@/components/ui/button' import { Card, CardContent, CardHeader } from '@/components/ui/card' import { distributionLabel, primaryDistributionKind } from '@/lib/agent-registry-filter' import type { RegistryEntry } from '@/types/registry' +import { BridgeConnectDialog } from './bridge-connect-dialog' type AgentCatalogCardProps = { entry: RegistryEntry + /** Hands off to the existing Add Custom Agent flow from the bridge dialog. */ + onAddCustomAgent: () => void } -/** A read-only catalogue card for a "bridge" agent: shows the agent's identity - * and metadata and links out to its website and source. There's no install - * action — these CLIs run on the user's own machine, not inside Thunderbolt. */ -export const AgentCatalogCard = ({ entry }: AgentCatalogCardProps) => { +/** A catalogue card for a "bridge" agent: shows the agent's identity and + * metadata, links out to its website and source, and offers a "Connect via + * bridge" action that walks the user through running the CLI locally and + * bridging it into Thunderbolt over a localhost WebSocket. */ +export const AgentCatalogCard = ({ entry, onAddCustomAgent }: AgentCatalogCardProps) => { const [iconFailed, setIconFailed] = useState(false) + const [bridgeOpen, setBridgeOpen] = useState(false) const distributionKind = primaryDistributionKind(entry) const websiteUrl = entry.website ?? entry.repository @@ -56,6 +61,10 @@ export const AgentCatalogCard = ({ entry }: AgentCatalogCardProps) => {

{entry.description}

{metadata}

+ ) } diff --git a/src/components/settings/agents/agent-catalog-view.test.tsx b/src/components/settings/agents/agent-catalog-view.test.tsx index 8905aaeb0..39648d1aa 100644 --- a/src/components/settings/agents/agent-catalog-view.test.tsx +++ b/src/components/settings/agents/agent-catalog-view.test.tsx @@ -4,7 +4,7 @@ import '@testing-library/jest-dom' import { cleanup, fireEvent, render, screen } from '@testing-library/react' -import { afterEach, describe, expect, it } from 'bun:test' +import { afterEach, describe, expect, it, mock } from 'bun:test' import type { RegistryEntry } from '@/types/registry' import { AgentCatalogView } from './agent-catalog-view' @@ -24,8 +24,8 @@ const fixtures: ReadonlyArray = [ entry({ id: 'gemini', name: 'Gemini CLI', description: 'Google terminal agent' }), ] -const renderCatalog = (entries: ReadonlyArray = fixtures) => - render() +const renderCatalog = (entries: ReadonlyArray = fixtures, onAddCustomAgent: () => void = () => {}) => + render() describe('AgentCatalogView', () => { afterEach(cleanup) @@ -127,13 +127,30 @@ describe('AgentCatalogView', () => { expect(header?.querySelector('svg')).toBeInTheDocument() }) - it('exposes only link-out actions per card, never an install action', () => { + it('exposes link-outs and a Connect via bridge action, never an install/submit action', () => { renderCatalog([entry({ id: 'goose', name: 'goose' })]) const card = screen.getByTestId('agent-catalog-card-goose') expect(card.querySelectorAll('a').length).toBeGreaterThan(0) - expect(card.querySelector('button')).not.toBeInTheDocument() expect(card.querySelector('button[type="submit"]')).not.toBeInTheDocument() + // The only action button is "Connect via bridge" — these CLIs run on the + // user's own machine, so there's no in-app install action. + const buttons = Array.from(card.querySelectorAll('button')) + expect(buttons).toHaveLength(1) + expect(buttons[0]).toHaveTextContent(/connect via bridge/i) + }) + + it('opens the bridge dialog and hands off Add the agent to the host flow', () => { + const onAddCustomAgent = mock(() => {}) + renderCatalog([entry({ id: 'goose', name: 'goose' })], onAddCustomAgent) + + fireEvent.click(screen.getByRole('button', { name: /connect via bridge/i })) + + // The bridge command is composed from the npx distribution and shown copyable. + expect(screen.getByText('npx acp-bridge -- npx goose@1.2.3')).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: /add the agent/i })) + expect(onAddCustomAgent).toHaveBeenCalledTimes(1) }) it('keeps all cards visible for a whitespace-only query', () => { diff --git a/src/components/settings/agents/agent-catalog-view.tsx b/src/components/settings/agents/agent-catalog-view.tsx index e6e769042..0a198d7db 100644 --- a/src/components/settings/agents/agent-catalog-view.tsx +++ b/src/components/settings/agents/agent-catalog-view.tsx @@ -11,12 +11,14 @@ import { AgentCatalogCard } from './agent-catalog-card' type AgentCatalogViewProps = { /** The agents to render. Always non-empty in production (the snapshot seeds it). */ entries: ReadonlyArray + /** Forwarded to each card's bridge dialog to open the Add Custom Agent flow. */ + onAddCustomAgent: () => void } -/** Presentational catalogue: search + grid of read-only agent cards. Takes its - * entries as a prop and owns no data fetching, so it renders purely from inputs - * and is unit-testable without react-query. */ -export const AgentCatalogView = ({ entries }: AgentCatalogViewProps) => { +/** Presentational catalogue: search + grid of agent cards. Takes its entries as + * a prop and owns no data fetching, so it renders purely from inputs and is + * unit-testable without react-query. */ +export const AgentCatalogView = ({ entries, onAddCustomAgent }: AgentCatalogViewProps) => { const { query, setQuery, results, isEmpty } = useAgentRegistrySearch(entries) const showEmptyState = isEmpty && query.trim().length > 0 const searchRef = useRef(null) @@ -36,7 +38,7 @@ export const AgentCatalogView = ({ entries }: AgentCatalogViewProps) => { ) : (
{results.map((entry) => ( - + ))}
)} diff --git a/src/components/settings/agents/agent-catalog.tsx b/src/components/settings/agents/agent-catalog.tsx index b39481ecc..200dc5174 100644 --- a/src/components/settings/agents/agent-catalog.tsx +++ b/src/components/settings/agents/agent-catalog.tsx @@ -5,10 +5,15 @@ import { useAgentRegistry } from '@/hooks/use-agent-registry' import { AgentCatalogView } from './agent-catalog-view' +type AgentCatalogProps = { + /** Opens the Add Custom Agent dialog, handed off from a card's bridge flow. */ + onAddCustomAgent: () => void +} + /** Container for the bridgeable-agent catalogue: reads the live registry hook * (snapshot-seeded, so always non-empty) and hands the entries to the * presentational view. */ -export const AgentCatalog = () => { +export const AgentCatalog = ({ onAddCustomAgent }: AgentCatalogProps) => { const entries = useAgentRegistry() - return + return } diff --git a/src/components/settings/agents/bridge-connect-dialog.tsx b/src/components/settings/agents/bridge-connect-dialog.tsx new file mode 100644 index 000000000..14283ea6d --- /dev/null +++ b/src/components/settings/agents/bridge-connect-dialog.tsx @@ -0,0 +1,98 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { ArrowRight, ExternalLink } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Dialog } from '@/components/ui/dialog' +import { + ResponsiveModalContentComposable, + ResponsiveModalDescription, + ResponsiveModalHeader, + ResponsiveModalTitle, +} from '@/components/ui/responsive-modal' +import { composeBridgeCommand, composeInstallCommand } from '@/lib/agent-bridge-command' +import type { RegistryEntry } from '@/types/registry' +import { CopyableCommand } from './copyable-command' + +type BridgeConnectDialogProps = { + entry: RegistryEntry + open: boolean + onOpenChange: (open: boolean) => void + /** Hands off to the existing Add Custom Agent flow so the user can paste the + * `ws://127.0.0.1:PORT` URL the bridge prints. */ + onAddCustomAgent: () => void +} + +/** Walks the user through running a CLI agent locally and bridging it into + * Thunderbolt: install the agent, run `acp-bridge`, then add the printed + * localhost URL as a custom agent. All commands are derived from the registry + * distribution at render — no effects, no local state. */ +export const BridgeConnectDialog = ({ entry, open, onOpenChange, onAddCustomAgent }: BridgeConnectDialogProps) => { + const installCommand = composeInstallCommand(entry) + const bridgeCommand = composeBridgeCommand(entry) + // `binary` distributions have no portable launch line — both commands are + // null together, so we fall back to pointing the user at the agent's site. + const siteUrl = entry.website ?? entry.repository ?? null + + const handleAddCustomAgent = () => { + onOpenChange(false) + onAddCustomAgent() + } + + return ( + + + + Connect {entry.name} via bridge + + Run this CLI agent on your machine and bridge it into Thunderbolt over a local WebSocket. + + +
+ {installCommand && bridgeCommand ? ( + <> +
+

1. Install the agent

+ +
+
+

2. Run the bridge

+ +

+ The bridge prints a ws://127.0.0.1:PORT URL once it's running. +

+
+
+

3. Add the agent

+

+ Paste the printed URL into Add Custom Agent to connect. +

+
+ + ) : ( +

+ {entry.name} ships as a platform binary. Follow its install instructions, then run it under{' '} + acp-bridge and add the printed{' '} + ws://127.0.0.1:PORT URL. +

+ )} +
+
+ {siteUrl && ( + + )} + +
+ + + ) +} diff --git a/src/components/settings/agents/copyable-command.tsx b/src/components/settings/agents/copyable-command.tsx new file mode 100644 index 000000000..12ed35b82 --- /dev/null +++ b/src/components/settings/agents/copyable-command.tsx @@ -0,0 +1,36 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { Check, Copy } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard' + +type CopyableCommandProps = { + /** The shell command to display and copy. */ + command: string +} + +/** A monospace command line with a copy-to-clipboard affordance. The copied + * state is owned by `useCopyToClipboard` (timer-with-cleanup), so the button + * flips to a check for a couple of seconds after a successful copy. */ +export const CopyableCommand = ({ command }: CopyableCommandProps) => { + const { copy, isCopied } = useCopyToClipboard() + + return ( +
+ + {command} + + +
+ ) +} diff --git a/src/lib/agent-bridge-command.test.ts b/src/lib/agent-bridge-command.test.ts new file mode 100644 index 000000000..97a40be52 --- /dev/null +++ b/src/lib/agent-bridge-command.test.ts @@ -0,0 +1,77 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { describe, expect, it } from 'bun:test' +import type { RegistryDistribution, RegistryEntry } from '@/types/registry' +import { composeBridgeCommand, composeInstallCommand, composeLaunchCommand } from './agent-bridge-command' + +const entry = (distribution: RegistryDistribution): RegistryEntry => ({ + id: 'test-agent', + name: 'Test Agent', + version: '1.0.0', + description: '', + authors: [], + license: '', + distribution, +}) + +describe('composeLaunchCommand', () => { + it('builds an npx launch command from package only', () => { + expect(composeLaunchCommand(entry({ npx: { package: '@agentclientprotocol/claude-agent-acp' } }))).toBe( + 'npx @agentclientprotocol/claude-agent-acp', + ) + }) + + it('appends npx args after the package', () => { + expect(composeLaunchCommand(entry({ npx: { package: 'some-agent', args: ['--flag', 'value'] } }))).toBe( + 'npx some-agent --flag value', + ) + }) + + it('builds a uvx launch command', () => { + expect(composeLaunchCommand(entry({ uvx: { package: 'py-agent', args: ['serve'] } }))).toBe('uvx py-agent serve') + }) + + it('prefers npx over uvx when both are present', () => { + expect(composeLaunchCommand(entry({ npx: { package: 'node-agent' }, uvx: { package: 'py-agent' } }))).toBe( + 'npx node-agent', + ) + }) + + it('returns null for a binary-only distribution', () => { + expect(composeLaunchCommand(entry({ binary: { 'darwin-arm64': 'https://example.com/agent' } }))).toBeNull() + }) + + it('returns null for an empty distribution', () => { + expect(composeLaunchCommand(entry({}))).toBeNull() + }) +}) + +describe('composeInstallCommand', () => { + it('mirrors the launch command for npx', () => { + expect(composeInstallCommand(entry({ npx: { package: 'node-agent' } }))).toBe('npx node-agent') + }) + + it('returns null for a binary-only distribution', () => { + expect(composeInstallCommand(entry({ binary: {} }))).toBeNull() + }) +}) + +describe('composeBridgeCommand', () => { + it('wraps the npx launch command in acp-bridge', () => { + expect(composeBridgeCommand(entry({ npx: { package: '@agentclientprotocol/claude-agent-acp' } }))).toBe( + 'npx acp-bridge -- npx @agentclientprotocol/claude-agent-acp', + ) + }) + + it('wraps a uvx launch command with its args', () => { + expect(composeBridgeCommand(entry({ uvx: { package: 'py-agent', args: ['serve', '--port', '0'] } }))).toBe( + 'npx acp-bridge -- uvx py-agent serve --port 0', + ) + }) + + it('returns null for a binary-only distribution', () => { + expect(composeBridgeCommand(entry({ binary: { 'linux-x64': {} } }))).toBeNull() + }) +}) diff --git a/src/lib/agent-bridge-command.ts b/src/lib/agent-bridge-command.ts new file mode 100644 index 000000000..dd87e219c --- /dev/null +++ b/src/lib/agent-bridge-command.ts @@ -0,0 +1,60 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import type { RegistryEntry } from '@/types/registry' + +/** + * Derives the shell commands shown in the catalogue's "Connect via bridge" + * dialog, composed purely from a registry entry's `distribution`. These are + * display strings the user copies into their own terminal — nothing executes + * them here, so there's no shell-injection surface to guard against. + * + * Two flavours are produced from the same launch command: + * - the install/launch command (`npx ` / `uvx `), + * - the bridge command that wraps it (`npx acp-bridge -- `). + * + * `binary` distributions have no portable launch line (the registry leaves the + * shape opaque per platform), so both helpers return `null` and the UI points + * the user at the agent's own site/repo instead. + */ + +/** Build the ` ` fragment for an npx/uvx distribution. */ +const launchArgs = (pkg: string, args: ReadonlyArray | undefined): string => [pkg, ...(args ?? [])].join(' ') + +/** + * The bare command a user runs to launch the agent on their own machine, e.g. + * `npx @agentclientprotocol/claude-agent-acp`. Returns `null` for `binary` + * distributions, which the registry leaves opaque per platform. + */ +export const composeLaunchCommand = (entry: RegistryEntry): string | null => { + const { npx, uvx } = entry.distribution + if (npx) { + return `npx ${launchArgs(npx.package, npx.args)}` + } + if (uvx) { + return `uvx ${launchArgs(uvx.package, uvx.args)}` + } + return null +} + +/** + * The command to install the agent — identical to the launch command (npx/uvx + * fetch-and-run on first use), surfaced separately so the dialog can label the + * "install" and "run the bridge" steps independently. Returns `null` for + * `binary` distributions. + */ +export const composeInstallCommand = (entry: RegistryEntry): string | null => composeLaunchCommand(entry) + +/** + * The `acp-bridge` invocation that relays the local stdio agent to a localhost + * WebSocket: `npx acp-bridge -- `. Everything after `--` is the + * agent's own launch argv. Returns `null` for `binary` distributions. + */ +export const composeBridgeCommand = (entry: RegistryEntry): string | null => { + const launch = composeLaunchCommand(entry) + if (!launch) { + return null + } + return `npx acp-bridge -- ${launch}` +} diff --git a/src/routes/settings/agents/index.tsx b/src/routes/settings/agents/index.tsx index e2b9d944f..24c8e4897 100644 --- a/src/routes/settings/agents/index.tsx +++ b/src/routes/settings/agents/index.tsx @@ -107,7 +107,7 @@ export default function AgentsSettingsPage({ isStandalone }: AgentsSettingsPageP - + setDialogOpen(true)} /> Date: Tue, 16 Jun 2026 12:33:06 -0300 Subject: [PATCH 07/23] docs: add acp-bridge usage guide --- acp-bridge/README.md | 190 +++++++++++++++++++++++++------------------ 1 file changed, 109 insertions(+), 81 deletions(-) diff --git a/acp-bridge/README.md b/acp-bridge/README.md index 6829ecbb7..fcce02eb6 100644 --- a/acp-bridge/README.md +++ b/acp-bridge/README.md @@ -1,32 +1,37 @@ # acp-bridge -A tiny CLI that lets [Thunderbolt](https://thunderbird.net) talk to a **local stdio -ACP agent** (Claude Code, Gemini, any [Agent Client Protocol](https://agentclientprotocol.com) -agent) over a localhost WebSocket. +A tiny local helper that bridges a **stdio ACP agent** (Claude Code, Gemini CLI, +Goose, any [Agent Client Protocol](https://agentclientprotocol.com) agent) to a +localhost WebSocket so [Thunderbolt](https://thunderbird.net) — web or desktop — +can talk to it. -Thunderbolt runs in the browser and can only reach an agent over a WebSocket. -Most ACP agents speak over **stdio** (newline-delimited JSON-RPC). `acp-bridge` -spawns the agent and relays its stdio to a `ws://127.0.0.1:PORT` socket — one -JSON object per WebSocket message, exactly what Thunderbolt expects. +Thunderbolt reaches agents over a WebSocket. Most ACP agents speak **stdio** +(newline-delimited JSON-RPC). `acp-bridge` spawns your agent and relays its stdio +to a `ws://127.0.0.1:PORT` socket — one JSON object per WebSocket message, +exactly what Thunderbolt expects. ``` -Thunderbolt (browser) ⇄ ws://127.0.0.1:PORT ⇄ acp-bridge ⇄ stdio ⇄ your agent +Thunderbolt ⇄ ws://127.0.0.1:PORT ⇄ acp-bridge ⇄ stdio ⇄ your agent ``` -## Usage +No package manager to install. One dependency (`ws`); everything else is a Node +built-in. Requires **Node.js ≥ 18**. + +## Quick start + +Run the bridge, putting your agent command after `--`: ```bash -npx acp-bridge -- [agent-args...] +npx acp-bridge -- ``` -Everything after `--` is the agent command, passed **straight to the OS with no -shell** (no quoting bugs, no injection). For example: +A real example (the Claude Code ACP adapter): ```bash npx acp-bridge -- npx -y @zed-industries/claude-code-acp ``` -It prints a banner like: +The bridge prints a banner with a copyable URL: ``` acp-bridge ready @@ -39,87 +44,106 @@ Paste this URL into Thunderbolt → Add Custom Agent: Ctrl-C to stop. ``` -Copy the `ws://127.0.0.1:PORT` URL and paste it into Thunderbolt under -**Add Custom Agent**. Press **Ctrl-C** to stop the bridge (it cleanly shuts the -agent down too). +Then, three steps: -### Options +1. **Run** the bridge (the command above). +2. **Copy** the printed `ws://127.0.0.1:PORT` URL. +3. **Paste** it into Thunderbolt under **Add Custom Agent**. -| Flag | Default | Meaning | -| -------------------- | ----------- | ------------------------------------------------------------- | -| `--port ` | ephemeral | WebSocket port. Omit it to let the OS pick a free one. | -| `--host ` | `127.0.0.1` | Bind address. Loopback only by default — keep it that way. A non-loopback host prints a prominent warning (other machines on your network could then reach the agent). | -| `--allow-origin ` | — | Extra WebSocket `Origin` to accept (repeatable). The Thunderbolt app origins are allowed by default; use this for dev/self-host. | -| `--allow-any-origin` | off | Disable the `Origin` check entirely. Loud escape hatch — see [Origin allowlist](#origin-allowlist). | -| `--verbose` | off | Per-frame logging (method + size, **redacted** — never content). | -| `--json` | off | Emit logs as raw JSON instead of pretty one-liners. | -| `--help` | | Show help. | -| `--version` | | Print the version. | - -### Origin allowlist - -Browser WebSocket connections are **not** same-origin-protected: without a guard, -any web page open in a browser on your machine could connect to -`ws://127.0.0.1:PORT` and drive your local agent (read/write files, run terminal -commands). To close that, the bridge only accepts connections whose `Origin` -header is a known Thunderbolt app origin: +On the web app your browser may prompt for **Local Network Access** (Chrome's +prompt) — click **Allow**. The connection goes browser → your own machine; +nothing leaves your computer. Press **Ctrl-C** to stop the bridge; it shuts the +agent down cleanly too. -- `https://app.thunderbolt.io` (production web app) -- `tauri://localhost` and `http://tauri.localhost` (Tauri desktop/mobile webview) -- `http://localhost:1420` (Vite dev server — web + Tauri dev) -- a **missing/empty** `Origin` (native and Tauri webviews routinely send none) +## Usage -A connection with any other `Origin` is rejected (WebSocket close code `1008`). -Extend the list with `--allow-origin ` (repeatable) for dev or self-host. -`--allow-any-origin` turns the check off entirely and prints a startup warning — -only use it on a trusted machine. +```bash +npx acp-bridge [options] -- [agent-args...] +``` -## Desktop vs web +Everything **after `--`** is your agent command. It's passed **straight to the OS +with no shell** — no quoting bugs, no injection. The `--` separator is required; +without it (or with nothing after it) the bridge tells you so and exits. -- **Thunderbolt desktop (Tauri):** the app can open the localhost WebSocket - directly. -- **Thunderbolt web (browser):** the browser may ask permission to reach your - local network (Chrome's Local Network Access prompt) — click **Allow**. The - connection still goes browser → your own machine; nothing leaves your computer. +### Options -The bridge binds to `127.0.0.1` only by default, so it is reachable solely from -your own machine. +| Flag | Default | Meaning | +| -------------------- | ----------- | ------------------------------------------------------------- | +| `--port ` | ephemeral | WebSocket port (0–65535). Omit to let the OS auto-pick a free one. | +| `--host ` | `127.0.0.1` | Bind address. Loopback only by default. A non-loopback host prints a prominent warning (other machines on your network could then reach the agent). | +| `--allow-origin ` | — | Extra WebSocket `Origin` to accept. **Repeatable.** The Thunderbolt app origins are allowed by default. See [Security](#security). | +| `--allow-any-origin` | off | Accept **any** `Origin`, disabling the cross-origin guard. Escape hatch for dev/self-host only — prints a startup warning. See [Security](#security). | +| `--verbose` | off | Per-frame logging (direction, method, byte size — **redacted**, never content). | +| `--json` | off | Emit logs as raw JSON instead of pretty one-liners. | +| `--help` / `-h` | | Show help and exit. | +| `--version` / `-v` | | Print the version and exit. | -## Privacy +`--port`, `--host`, and `--allow-origin` accept either form: `--port 51847` or +`--port=51847`. -`acp-bridge` is a **dumb relay** — it forwards bytes between the agent and -Thunderbolt and never inspects, stores, or transmits your prompts or the agent's -output anywhere else. +## How it works -Logging is **allowlist-based**: log lines only ever contain structural scalars -(timestamp, direction, message kind, a fixed set of known method names, JSON-RPC -id, byte size, status, integer error codes, lifecycle events). Prompt text, tool -output, file paths, tokens, and your full command line are **never** logged — -even with `--verbose`. The agent's own stderr passes through to your terminal +`acp-bridge` is a pure byte relay — it links no ACP SDK and never interprets the +protocol. It spawns your agent once and reuses that single child process across +WebSocket reconnects (so session state survives). Agent stdout is split into +lines and each non-empty JSON object is sent as exactly one WebSocket frame; +each inbound WebSocket message is written to the agent's stdin with a trailing +newline. Non-JSON stdout lines are dropped (Thunderbolt does an unguarded +`JSON.parse` per message). On Ctrl-C / `SIGTERM` it closes the socket and +`SIGTERM`s the agent, escalating to `SIGKILL` after 2 s so the agent is never +orphaned; if the agent exits on its own, the bridge tears down with it. + +## Security + +The WebSocket server binds **`127.0.0.1` only** by default, so it's reachable +solely from your own machine. + +That's not enough on its own: browser WebSocket connections are **not** +same-origin-protected, and this server fronts a privileged local agent that can +read/write files and run terminal commands. Without a guard, any web page open +in a browser on your machine could connect to `ws://127.0.0.1:PORT` and drive +your agent. So the bridge accepts a connection only when its `Origin` header is a +known Thunderbolt app origin: + +- `https://app.thunderbolt.io` — production web app +- `tauri://localhost` and `http://tauri.localhost` — Tauri desktop/mobile webview +- `http://localhost:1420` — Vite dev server (web + Tauri dev) +- a **missing/empty** `Origin` — native and Tauri webviews routinely send none + +A disallowed `Origin` is rejected during the WebSocket handshake (HTTP `403`, so +a hostile page never even briefly connects); a defense-in-depth check also closes +any such socket with code `1008`. + +- **Add an origin:** `--allow-origin ` (repeatable) for dev or self-host. +- **Turn the check off:** `--allow-any-origin`. This lets **any** browser page on + the machine drive your agent — only use it on a trusted dev/self-host machine. + It prints a loud startup warning. + +## Logging & privacy + +`acp-bridge` never logs ACP message content. Log records are built from an +**allowlist of scalars** — there is no code path that copies a frame body into a +log line. Logged fields are limited to: direction, message kind, a fixed set of +known method names (anything else collapses to `other`), a scalar JSON-RPC id +(long string ids are truncated), byte size, status, integer error codes, and +lifecycle events. The `Origin` header is sanitized to scheme + host before +logging. + +Prompt text, tool output, file paths, tokens, and your agent's argv are **never** +logged — even with `--verbose`. Dropped or malformed stdout lines are logged by +**byte size only**. The agent's own stderr passes through to your terminal untouched. -## How it works +## Troubleshooting + +The bridge prints an actionable message to stderr and exits with a specific code: -- **Framing.** ACP stdio is newline-delimited JSON-RPC; Thunderbolt's WebSocket - expects one JSON object per message. The bridge splits the agent's stdout into - lines and sends each non-empty line as exactly one WebSocket frame, and writes - each inbound WebSocket message to the agent's stdin with a trailing newline. - Non-JSON stdout lines are dropped (and warned about) so Thunderbolt's - `JSON.parse` never chokes. -- **One persistent agent.** A single child process is spawned and reused across - WebSocket reconnects, so session state survives Thunderbolt's reconnect - attempts. -- **Clean shutdown.** Ctrl-C (or `SIGTERM`) closes the WebSocket and sends the - agent `SIGTERM`, then waits for the agent to actually exit before exiting - itself. If a stubborn agent ignores `SIGTERM`, a 2-second fallback escalates to - `SIGKILL` and then exits — so the agent is never orphaned. If the agent exits - on its own, the bridge tears down with it. - -## Requirements - -- Node.js ≥ 18 -- One dependency: [`ws`](https://github.com/websockets/ws). Everything else is a - Node built-in. +| Exit | When | Fix | +| ---- | ---- | --- | +| `0` | Clean shutdown (agent exited normally, or Ctrl-C with the agent gone). | Nothing — normal exit. | +| `64` | **Bad invocation.** Missing `--` separator, no agent command, an unknown option, or an invalid `--port`. | Re-check the command. The agent command goes after `--`, e.g. `npx acp-bridge -- npx -y @zed-industries/claude-code-acp`. | +| `69` | **Agent or server problem.** `command not found` (agent not on PATH), `permission denied` (agent not executable), the agent **exited before speaking ACP**, port already in use, or the agent exited non-zero while running. | For "command not found", install the agent / check your PATH. For "exited before speaking ACP", run the agent command directly to see its error (its stderr also prints above the message). For "port already in use", omit `--port` to auto-pick or choose another. | +| `130`| **Ctrl-C / `SIGTERM`.** You stopped the bridge. | Nothing — expected interrupt. | ## Development @@ -127,3 +151,7 @@ untouched. bun install bun test ``` + +## License + +MPL-2.0 From f093a434088b5ed236e89bc890b88b88d2dda213 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=8Dtalo=20Menezes?= Date: Tue, 16 Jun 2026 12:48:19 -0300 Subject: [PATCH 08/23] fix: correct Thunderbolt link to thunderbolt.io --- acp-bridge/README.md | 2 +- acp-bridge/src/log.test.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/acp-bridge/README.md b/acp-bridge/README.md index fcce02eb6..9fc53ce48 100644 --- a/acp-bridge/README.md +++ b/acp-bridge/README.md @@ -2,7 +2,7 @@ A tiny local helper that bridges a **stdio ACP agent** (Claude Code, Gemini CLI, Goose, any [Agent Client Protocol](https://agentclientprotocol.com) agent) to a -localhost WebSocket so [Thunderbolt](https://thunderbird.net) — web or desktop — +localhost WebSocket so [Thunderbolt](https://thunderbolt.io) — web or desktop — can talk to it. Thunderbolt reaches agents over a WebSocket. Most ACP agents speak **stdio** diff --git a/acp-bridge/src/log.test.js b/acp-bridge/src/log.test.js index 1c08da6b4..4d9d0b005 100644 --- a/acp-bridge/src/log.test.js +++ b/acp-bridge/src/log.test.js @@ -140,7 +140,7 @@ describe('extractLogEvent — PII safety (the whole point)', () => { describe('sanitizeOrigin', () => { it('keeps only scheme + host of a browser origin', () => { - expect(sanitizeOrigin('https://app.thunderbird.net')).toBe('https://app.thunderbird.net') + expect(sanitizeOrigin('https://app.thunderbolt.io')).toBe('https://app.thunderbolt.io') }) it('strips any path/query', () => { From 1208c9faa32c52fd3db907e2881e7b06ce5f6f3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=8Dtalo=20Menezes?= Date: Tue, 16 Jun 2026 13:02:34 -0300 Subject: [PATCH 09/23] fix: kill spawned agent on fatal bind error to prevent orphan --- acp-bridge/src/server.js | 4 ++++ acp-bridge/src/server.test.js | 26 ++++++++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/acp-bridge/src/server.js b/acp-bridge/src/server.js index 66d1c7401..a39b25d05 100644 --- a/acp-bridge/src/server.js +++ b/acp-bridge/src/server.js @@ -97,6 +97,10 @@ export const startBridge = async (cfg, deps) => { const safeExit = (code) => { if (shuttingDown) return shuttingDown = true + // Never orphan the agent: if the child outlived a fatal error (e.g. the ws + // server failed to bind), kill it before we exit. safeExit is the only fatal + // chokepoint; the signal path uses stop(), so this never double-kills. + if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL') finalExit(code) } diff --git a/acp-bridge/src/server.test.js b/acp-bridge/src/server.test.js index c68884423..9fdb3f834 100644 --- a/acp-bridge/src/server.test.js +++ b/acp-bridge/src/server.test.js @@ -254,6 +254,32 @@ describe('startBridge lifecycle', () => { await expect(promise).rejects.toMatchObject({ exitCode: 69 }) expect(exited).toBe(69) }) + + it('SIGKILLs a still-alive child on a fatal server bind error (never orphaned)', async () => { + const child = makeFakeChild() // alive: exitCode === null, signalCode === null + const wss = makeFakeWss(6003) + let exited = null + + const promise = startBridge( + { agentCmd: ['agent'], host: '127.0.0.1', port: 8080, logger: quietLogger() }, + { + spawn: () => child, + WebSocketServer: function () { + return wss + }, + createLineReader: () => new EventEmitter(), + exit: (code) => { + exited = code + }, + }, + ) + + wss.emit('error', Object.assign(new Error('listen EADDRINUSE'), { code: 'EADDRINUSE' })) + + await expect(promise).rejects.toMatchObject({ exitCode: 69 }) + expect(child.killed).toContain('SIGKILL') // child reaped before exit, not orphaned + expect(exited).toBe(69) + }) }) describe('startBridge — Origin allowlist (cross-origin hijack guard)', () => { From a674446b838e985c5f5327b971724e43798d4112 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=8Dtalo=20Menezes?= Date: Tue, 16 Jun 2026 13:05:33 -0300 Subject: [PATCH 10/23] fix: drop stale ws connections to stop superseded stdin injection --- acp-bridge/src/server.js | 16 +++++++++++++--- acp-bridge/src/server.test.js | 25 +++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/acp-bridge/src/server.js b/acp-bridge/src/server.js index a39b25d05..9ed4a2634 100644 --- a/acp-bridge/src/server.js +++ b/acp-bridge/src/server.js @@ -241,16 +241,26 @@ export const startBridge = async (cfg, deps) => { } logger.info({ lifecycle: 'connected', origin }) + // Single-client bridge: a new connection supersedes any previous one. Assign + // the new socket first (so the old socket's 'close' handler won't null it), + // then close the old one so a superseded client can't keep injecting into the + // shared agent stdin while only the newest receives output. + const previous = activeSocket activeSocket = socket + if (previous && previous !== socket && previous.readyState === WS_OPEN) previous.close(1000) - socket.on('message', (data) => + socket.on('message', (data) => { + // Drop messages from a socket that's been superseded by a newer connection: + // close() doesn't synchronously stop buffered 'message' events, so guard on + // identity to keep a stale client out of the shared agent stdin. + if (activeSocket !== socket) return handleWsMessage({ data, write: (chunk) => child.stdin.write(chunk), onWrite: (chunk) => logger.debug(extractLogEvent({ direction: 'ws->agent', line: chunk.replace(/\n$/, '') })), - }), - ) + }) + }) socket.on('error', (err) => logger.warn({ lifecycle: 'socket-error', errorCode: err.code })) socket.on('close', (closeCode) => { if (activeSocket === socket) activeSocket = null diff --git a/acp-bridge/src/server.test.js b/acp-bridge/src/server.test.js index 9fdb3f834..7537c5903 100644 --- a/acp-bridge/src/server.test.js +++ b/acp-bridge/src/server.test.js @@ -150,6 +150,31 @@ describe('startBridge lifecycle', () => { expect(first.sent).toEqual([]) // old socket no longer receives }) + it('supersedes a previous connection: closes the old socket 1000, new becomes active', async () => { + const { child, wss } = await startReady() + const first = connect(wss) + const second = connect(wss) + + // Newest-wins: the old socket is closed, only the new one receives output. + expect(first.closedWith).toBe(1000) + expect(second.closedWith).toBeNull() + child.stdout.emit('data', '{"id":4}\n') + expect(second.sent).toEqual(['{"id":4}']) + expect(first.sent).toEqual([]) + }) + + it('a superseded socket can no longer inject into agent stdin, only the newest can', async () => { + const { child, wss } = await startReady() + const first = connect(wss) + const second = connect(wss) + + // close() doesn't synchronously stop buffered events — a stale socket emitting + // 'message' must be dropped, while the active socket still reaches stdin. + first.emit('message', '{"stale":true}') + second.emit('message', '{"id":7}') + expect(child.stdin.written).toEqual(['{"id":7}\n']) + }) + it('stop() closes ws with 1000, SIGTERMs the child, and exits 130 once it dies', async () => { const { child, wss, getStop, getExit } = await startReady() const socket = connect(wss) From 4bdb67a7463a39865fe1c9125513c1d50cdb9a24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=8Dtalo=20Menezes?= Date: Tue, 16 Jun 2026 14:10:32 -0300 Subject: [PATCH 11/23] docs: document grace-timer early-return safety invariant --- acp-bridge/src/server.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/acp-bridge/src/server.js b/acp-bridge/src/server.js index 9ed4a2634..b921314a6 100644 --- a/acp-bridge/src/server.js +++ b/acp-bridge/src/server.js @@ -182,6 +182,12 @@ export const startBridge = async (cfg, deps) => { safeExit(exitCode) }) + // Registered synchronously in the same tick as spawn() above (nothing awaits + // before this Promise) and a child 'exit' is always delivered asynchronously, + // so this listener can never miss it. That invariant is what makes the grace + // timer's `exitCode !== null` early-return safe — by the time exitCode is set, + // this handler has already settled the Promise. Do NOT add an `await` before + // this Promise: it would open a window where the child exits unobserved. child.on('exit', (code, signal) => { // A signal-driven stop is in progress: the child has now died, so clear the // SIGKILL fallback and drive the deferred final exit. From cb6dbb4ea0150e80fe1243516627ef23f9ea5e44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=8Dtalo=20Menezes?= Date: Tue, 16 Jun 2026 14:23:05 -0300 Subject: [PATCH 12/23] fix: accept 127.0.0.1 and [::1] dev origins on bridge ws allowlist --- acp-bridge/src/log.js | 6 +++++- acp-bridge/src/log.test.js | 11 +++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/acp-bridge/src/log.js b/acp-bridge/src/log.js index 508be63f8..83fbe71ad 100644 --- a/acp-bridge/src/log.js +++ b/acp-bridge/src/log.js @@ -175,7 +175,7 @@ export const sanitizeOrigin = (origin) => { * + the hardcoded prod web origin in isOAuthRedirectUriAllowed): * - https://app.thunderbolt.io — production web app * - tauri://localhost / http://tauri.localhost — Tauri desktop/mobile webview - * - http://localhost:1420 — Vite dev server (web + Tauri dev) + * - http://localhost:1420 (+ http://127.0.0.1:1420, http://[::1]:1420) — Vite dev server (web + Tauri dev) * A missing/empty Origin is allowed separately (native/Tauri webviews often send * none); see isOriginAllowed. */ @@ -183,7 +183,11 @@ export const defaultAllowedOrigins = Object.freeze([ 'https://app.thunderbolt.io', 'tauri://localhost', 'http://tauri.localhost', + // Vite dev server (web + Tauri dev). It binds loopback and is reachable by + // every loopback spelling, so accept all three — same local origin. 'http://localhost:1420', + 'http://127.0.0.1:1420', + 'http://[::1]:1420', ]) /** diff --git a/acp-bridge/src/log.test.js b/acp-bridge/src/log.test.js index 4d9d0b005..662b7a99a 100644 --- a/acp-bridge/src/log.test.js +++ b/acp-bridge/src/log.test.js @@ -168,6 +168,17 @@ describe('isOriginAllowed', () => { } }) + it('accepts every loopback spelling of the Vite dev origin (same local origin)', () => { + // The dev server binds loopback and is reachable as localhost, 127.0.0.1, + // and [::1] — all the same origin, so all three must be allowed. + expect(isOriginAllowed('http://localhost:1420', defaultAllowedOrigins)).toBe(true) + expect(isOriginAllowed('http://127.0.0.1:1420', defaultAllowedOrigins)).toBe(true) + expect(isOriginAllowed('http://[::1]:1420', defaultAllowedOrigins)).toBe(true) + // It's the dev origin specifically, not blanket-loopback: a different port + // on the same loopback host is still rejected. + expect(isOriginAllowed('http://127.0.0.1:9999', defaultAllowedOrigins)).toBe(false) + }) + it('allows a missing/empty origin (native + Tauri webviews send none)', () => { expect(isOriginAllowed(undefined, defaultAllowedOrigins)).toBe(true) expect(isOriginAllowed('', defaultAllowedOrigins)).toBe(true) From 5832c1dda798ffe63414ddc27647c63cde64e578 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=8Dtalo=20Menezes?= Date: Tue, 16 Jun 2026 14:37:36 -0300 Subject: [PATCH 13/23] fix: exit 69 when the agent dies by signal after startup --- acp-bridge/src/server.js | 2 +- acp-bridge/src/server.test.js | 26 ++++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/acp-bridge/src/server.js b/acp-bridge/src/server.js index b921314a6..477701295 100644 --- a/acp-bridge/src/server.js +++ b/acp-bridge/src/server.js @@ -211,7 +211,7 @@ export const startBridge = async (cfg, deps) => { logger.info({ lifecycle: 'agent-exited', exitCode: code ?? undefined, signal: signal ?? undefined }) process.stderr.write('\nAgent exited. Stopping bridge.\n') closeWebSocket(1011) - safeExit(code === 0 || code == null ? exitCodes.ok : exitCodes.unavailable) + safeExit(code === 0 ? exitCodes.ok : exitCodes.unavailable) }) // --- WebSocket server ----------------------------------------------------- diff --git a/acp-bridge/src/server.test.js b/acp-bridge/src/server.test.js index 7537c5903..0fb16a8e3 100644 --- a/acp-bridge/src/server.test.js +++ b/acp-bridge/src/server.test.js @@ -305,6 +305,32 @@ describe('startBridge lifecycle', () => { expect(child.killed).toContain('SIGKILL') // child reaped before exit, not orphaned expect(exited).toBe(69) }) + + it('agent clean-exits (0, null) after ready → exit 0', async () => { + const { child, getExit } = await startReady() + + child.emit('exit', 0, null) + await new Promise((r) => setTimeout(r, 0)) + expect(getExit()).toBe(0) + }) + + it('agent dies by signal (null, SIGKILL) after ready → exit 69, not 0', async () => { + const { child, getExit } = await startReady() + + // A signal death surfaces as code === null + signal set — an abnormal exit + // that must map to unavailable (69), never to ok (0). + child.emit('exit', null, 'SIGKILL') + await new Promise((r) => setTimeout(r, 0)) + expect(getExit()).toBe(69) + }) + + it('agent exits non-zero (1, null) after ready → exit 69', async () => { + const { child, getExit } = await startReady() + + child.emit('exit', 1, null) + await new Promise((r) => setTimeout(r, 0)) + expect(getExit()).toBe(69) + }) }) describe('startBridge — Origin allowlist (cross-origin hijack guard)', () => { From 10f8b690831afa3e1ab17a5d8925265d1bbc5b83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=8Dtalo=20Menezes?= Date: Tue, 16 Jun 2026 14:44:05 -0300 Subject: [PATCH 14/23] fix: bracket IPv6 literal host in the bridge banner URL --- acp-bridge/src/server.js | 5 ++++- acp-bridge/src/server.test.js | 9 +++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/acp-bridge/src/server.js b/acp-bridge/src/server.js index 477701295..b9e5c7e2a 100644 --- a/acp-bridge/src/server.js +++ b/acp-bridge/src/server.js @@ -282,7 +282,10 @@ export const startBridge = async (cfg, deps) => { if (shuttingDown) return if (child.exitCode !== null || child.signalCode !== null) return // exit handler already fired ready = true - onBanner?.(`ws://${host}:${resolvedPort}`) + // IPv6 literals (the only host form containing a colon) must be bracketed + // in a URL per RFC 3986: ws://::1:PORT is malformed; ws://[::1]:PORT is valid. + const hostForUrl = host.includes(':') ? `[${host}]` : host + onBanner?.(`ws://${hostForUrl}:${resolvedPort}`) resolve({ stop }) }, GRACE_MS) }) diff --git a/acp-bridge/src/server.test.js b/acp-bridge/src/server.test.js index 0fb16a8e3..64712fd4c 100644 --- a/acp-bridge/src/server.test.js +++ b/acp-bridge/src/server.test.js @@ -121,6 +121,15 @@ describe('startBridge lifecycle', () => { expect(getBanner()).toBe('ws://127.0.0.1:54321') }) + it('brackets an IPv6 literal host in the banner URL (RFC 3986), unbracketed for IPv4', async () => { + const ipv6 = await startReady({ port: 54321, cfg: { host: '::1' } }) + // Without brackets this would be the malformed ws://::1:54321. + expect(ipv6.getBanner()).toBe('ws://[::1]:54321') + + const ipv4 = await startReady({ port: 54321, cfg: { host: '127.0.0.1' } }) + expect(ipv4.getBanner()).toBe('ws://127.0.0.1:54321') // no brackets, regression guard + }) + it('relays agent stdout lines to the connected socket', async () => { const { child, wss } = await startReady() const socket = connect(wss) From 7b1df2179761b27f4ce6da85751613c6c049d865 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=8Dtalo=20Menezes?= Date: Tue, 16 Jun 2026 14:59:23 -0300 Subject: [PATCH 15/23] fix: pause agent relay while no client connected to avoid dropped output --- acp-bridge/src/server.js | 24 +++++++++++- acp-bridge/src/server.test.js | 69 ++++++++++++++++++++++++++++++++--- 2 files changed, 86 insertions(+), 7 deletions(-) diff --git a/acp-bridge/src/server.js b/acp-bridge/src/server.js index b9e5c7e2a..577c0618f 100644 --- a/acp-bridge/src/server.js +++ b/acp-bridge/src/server.js @@ -82,6 +82,7 @@ export const startBridge = async (cfg, deps) => { let wss = null /** @type {import('ws').WebSocket | null} */ let activeSocket = null + let readerPaused = false let shuttingDown = false let ready = false let exited = false @@ -135,6 +136,18 @@ export const startBridge = async (cfg, deps) => { wss?.close() } + // While no client is connected, pause the agent→ws relay so the agent's output + // (e.g. an in-flight response during a client reconnect) is held by OS pipe + // backpressure instead of dropped. Resumed on the next connection. + const clearActiveSocket = (socket) => { + if (activeSocket !== socket) return + activeSocket = null + if (!readerPaused) { + lines.pause() + readerPaused = true + } + } + // The exit code a signal-driven stop should ultimately exit with. The child's // 'exit' handler reads it so the actual process.exit happens only once the // child has died (or the SIGKILL fallback fires). @@ -254,6 +267,10 @@ export const startBridge = async (cfg, deps) => { const previous = activeSocket activeSocket = socket if (previous && previous !== socket && previous.readyState === WS_OPEN) previous.close(1000) + if (readerPaused) { + lines.resume() + readerPaused = false + } socket.on('message', (data) => { // Drop messages from a socket that's been superseded by a newer connection: @@ -267,9 +284,12 @@ export const startBridge = async (cfg, deps) => { logger.debug(extractLogEvent({ direction: 'ws->agent', line: chunk.replace(/\n$/, '') })), }) }) - socket.on('error', (err) => logger.warn({ lifecycle: 'socket-error', errorCode: err.code })) + socket.on('error', (err) => { + logger.warn({ lifecycle: 'socket-error', errorCode: err.code }) + clearActiveSocket(socket) + }) socket.on('close', (closeCode) => { - if (activeSocket === socket) activeSocket = null + clearActiveSocket(socket) logger.info({ lifecycle: 'disconnected', closeCode }) }) }) diff --git a/acp-bridge/src/server.test.js b/acp-bridge/src/server.test.js index 64712fd4c..3bb2b3f32 100644 --- a/acp-bridge/src/server.test.js +++ b/acp-bridge/src/server.test.js @@ -52,6 +52,41 @@ const makeFakeWss = (port) => { return wss } +/** + * A fake readline interface over a stream. Models real readline+pipe + * backpressure: pause()/resume() record being called AND gate 'line' emission — + * while paused, any emitted line is queued and replayed in order on resume. + * Unpaused, lines emit immediately, so existing tests are unaffected. + */ +const makeFakeLineReader = (stream) => { + const lines = new EventEmitter() + lines.paused = false + lines.pauseCalls = 0 + lines.resumeCalls = 0 + const queue = [] + const rawEmit = lines.emit.bind(lines) + lines.emit = (event, ...args) => { + if (event === 'line' && lines.paused) { + queue.push(args) + return true + } + return rawEmit(event, ...args) + } + lines.pause = () => { + lines.paused = true + lines.pauseCalls += 1 + } + lines.resume = () => { + lines.paused = false + lines.resumeCalls += 1 + while (queue.length > 0) rawEmit('line', ...queue.shift()) + } + stream.on('data', (chunk) => { + for (const line of String(chunk).split('\n')) lines.emit('line', line) + }) + return lines +} + const makeFakeSocket = () => { const socket = new EventEmitter() socket.readyState = 1 // OPEN @@ -72,6 +107,7 @@ const startReady = async ({ port = 5000, grace = 800, child = makeFakeChild(), c let exited = null let stopFn = null let banner = null + let lines = null const promise = startBridge( { agentCmd: ['my-agent', '--flag'], host: '127.0.0.1', port: 0, logger: quietLogger(), ...cfg }, @@ -81,10 +117,7 @@ const startReady = async ({ port = 5000, grace = 800, child = makeFakeChild(), c return wss }, createLineReader: (stream) => { - const lines = new EventEmitter() - stream.on('data', (chunk) => { - for (const line of String(chunk).split('\n')) lines.emit('line', line) - }) + lines = makeFakeLineReader(stream) return lines }, onBanner: (url) => { @@ -104,7 +137,14 @@ const startReady = async ({ port = 5000, grace = 800, child = makeFakeChild(), c await new Promise((r) => setTimeout(r, grace)) await promise - return { child, wss, getExit: () => exited, getStop: () => stopFn, getBanner: () => banner } + return { + child, + wss, + getExit: () => exited, + getStop: () => stopFn, + getBanner: () => banner, + getLines: () => lines, + } } /** Open an allowed connection (default Thunderbolt origin) on a ready bridge. */ @@ -159,6 +199,25 @@ describe('startBridge lifecycle', () => { expect(first.sent).toEqual([]) // old socket no longer receives }) + it('holds agent output across a reconnect instead of dropping it (pause/resume backpressure)', async () => { + const { child, wss, getLines } = await startReady() + const first = connect(wss) + const lines = getLines() + + // Client briefly disconnects (e.g. Thunderbolt's reconnect backoff). + first.emit('close', 1000) + expect(lines.pauseCalls).toBe(1) // reader paused so output isn't dropped + + // The agent emits an in-flight response WHILE no client is connected. + child.stdout.emit('data', '{"id":42}\n') + expect(first.sent).toEqual([]) // not delivered to the gone socket — held, not dropped + + // The client reconnects: the reader resumes and drains the held line in order. + const second = connect(wss) + expect(lines.resumeCalls).toBe(1) + expect(second.sent).toEqual(['{"id":42}']) // the in-flight response survived the disconnect + }) + it('supersedes a previous connection: closes the old socket 1000, new becomes active', async () => { const { child, wss } = await startReady() const first = connect(wss) From 5318b458ba59cac0c280cfe32aa2188f2652ca8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=8Dtalo=20Menezes?= Date: Tue, 16 Jun 2026 15:31:38 -0300 Subject: [PATCH 16/23] chore: rename package to thunderbolt-acp-bridge --- src/acp/transports/index.test.ts | 2 +- src/acp/transports/index.ts | 4 ++-- .../agents/add-custom-agent-dialog.tsx | 2 +- .../agents/agent-catalog-view.test.tsx | 2 +- .../settings/agents/bridge-connect-dialog.tsx | 4 ++-- src/lib/agent-bridge-command.test.ts | 6 +++--- src/lib/agent-bridge-command.ts | 8 ++++---- .../README.md | 20 +++++++++---------- .../bin/cli.js | 4 ++-- .../bun.lock | 2 +- .../package.json | 4 ++-- .../src/args.js | 10 +++++----- .../src/args.test.js | 0 .../src/errors.js | 2 +- .../src/errors.test.js | 2 +- .../src/log.js | 2 +- .../src/log.test.js | 0 .../src/relay.js | 0 .../src/relay.test.js | 0 .../src/server.js | 2 +- .../src/server.test.js | 0 21 files changed, 38 insertions(+), 38 deletions(-) rename {acp-bridge => thunderbolt-acp-bridge}/README.md (90%) rename {acp-bridge => thunderbolt-acp-bridge}/bin/cli.js (97%) rename {acp-bridge => thunderbolt-acp-bridge}/bun.lock (91%) rename {acp-bridge => thunderbolt-acp-bridge}/package.json (82%) rename {acp-bridge => thunderbolt-acp-bridge}/src/args.js (91%) rename {acp-bridge => thunderbolt-acp-bridge}/src/args.test.js (100%) rename {acp-bridge => thunderbolt-acp-bridge}/src/errors.js (98%) rename {acp-bridge => thunderbolt-acp-bridge}/src/errors.test.js (97%) rename {acp-bridge => thunderbolt-acp-bridge}/src/log.js (99%) rename {acp-bridge => thunderbolt-acp-bridge}/src/log.test.js (100%) rename {acp-bridge => thunderbolt-acp-bridge}/src/relay.js (100%) rename {acp-bridge => thunderbolt-acp-bridge}/src/relay.test.js (100%) rename {acp-bridge => thunderbolt-acp-bridge}/src/server.js (99%) rename {acp-bridge => thunderbolt-acp-bridge}/src/server.test.js (100%) diff --git a/src/acp/transports/index.test.ts b/src/acp/transports/index.test.ts index d6eb15484..59a9e9ba1 100644 --- a/src/acp/transports/index.test.ts +++ b/src/acp/transports/index.test.ts @@ -227,7 +227,7 @@ describe('openTransport — agent-type routing', () => { it.each(['ws://127.0.0.1:7777/acp', 'ws://localhost:7777/acp', 'ws://[::1]:7777/acp', 'ws://sub.localhost:7777/acp'])( 'remote-acp loopback target %s connects directly on Web (no proxy)', async (url) => { - // The acp-bridge carve-out: a loopback remote-acp target is the local + // The thunderbolt-acp-bridge carve-out: a loopback remote-acp target is the local // bridge socket. On Web (Connected) it must skip the cloud proxy — the // proxy can't reach localhost — and connect natively to the URL as-is. const transport = await openTransport({ diff --git a/src/acp/transports/index.ts b/src/acp/transports/index.ts index 57a4a5429..488e98fd9 100644 --- a/src/acp/transports/index.ts +++ b/src/acp/transports/index.ts @@ -24,7 +24,7 @@ * its own machine has no SSRF surface — the proxy's localhost rejection * protects the *cloud backend*, which is irrelevant here — and the proxy * would reject the `ws://`/private-host target anyway. This is the - * `acp-bridge` path: a local stdio agent bridged to a localhost socket. + * `thunderbolt-acp-bridge` path: a local stdio agent bridged to a localhost socket. * - Web (always Connected): proxied WebSocket via `createProxyWebSocket`. * - Tauri + proxy toggle ON (Connected): proxied WebSocket. * - Tauri + proxy toggle OFF (Standalone): native `new WebSocket()`. @@ -115,7 +115,7 @@ export const resolveWebSocketFactory = (inputs: OpenTransportInputs): WebSocketF if (inputs.agentType === 'managed-acp') { return resolveManagedAcpFactory(inputs) } - // A loopback remote-acp target is the local `acp-bridge` socket — connect + // A loopback remote-acp target is the local `thunderbolt-acp-bridge` socket — connect // directly, skipping the cloud proxy, on every platform (web included). The // proxy can't reach localhost and would reject the target regardless. if (isLoopbackUrl(inputs.url)) { diff --git a/src/components/settings/agents/add-custom-agent-dialog.tsx b/src/components/settings/agents/add-custom-agent-dialog.tsx index e08910372..d7778006e 100644 --- a/src/components/settings/agents/add-custom-agent-dialog.tsx +++ b/src/components/settings/agents/add-custom-agent-dialog.tsx @@ -159,7 +159,7 @@ export const AddCustomAgentDialog = ({ trimmedName.length > 0 && trimmedUrl.length > 0 && state.connectionStatus === 'success' && !state.submitting // The probe is only meaningful once the URL is a valid WebSocket endpoint. const canTestConnection = trimmedUrl.length > 0 && !urlError - // Loopback targets (the local acp-bridge socket) trip the browser's Local + // Loopback targets (the local thunderbolt-acp-bridge socket) trip the browser's Local // Network Access prompt — hint the user so the Allow dialog isn't a surprise. const showLoopbackHint = !urlError && isLoopbackUrl(trimmedUrl) diff --git a/src/components/settings/agents/agent-catalog-view.test.tsx b/src/components/settings/agents/agent-catalog-view.test.tsx index 39648d1aa..adca08234 100644 --- a/src/components/settings/agents/agent-catalog-view.test.tsx +++ b/src/components/settings/agents/agent-catalog-view.test.tsx @@ -147,7 +147,7 @@ describe('AgentCatalogView', () => { fireEvent.click(screen.getByRole('button', { name: /connect via bridge/i })) // The bridge command is composed from the npx distribution and shown copyable. - expect(screen.getByText('npx acp-bridge -- npx goose@1.2.3')).toBeInTheDocument() + expect(screen.getByText('npx thunderbolt-acp-bridge -- npx goose@1.2.3')).toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: /add the agent/i })) expect(onAddCustomAgent).toHaveBeenCalledTimes(1) diff --git a/src/components/settings/agents/bridge-connect-dialog.tsx b/src/components/settings/agents/bridge-connect-dialog.tsx index 14283ea6d..da0907dd6 100644 --- a/src/components/settings/agents/bridge-connect-dialog.tsx +++ b/src/components/settings/agents/bridge-connect-dialog.tsx @@ -25,7 +25,7 @@ type BridgeConnectDialogProps = { } /** Walks the user through running a CLI agent locally and bridging it into - * Thunderbolt: install the agent, run `acp-bridge`, then add the printed + * Thunderbolt: install the agent, run `thunderbolt-acp-bridge`, then add the printed * localhost URL as a custom agent. All commands are derived from the registry * distribution at render — no effects, no local state. */ export const BridgeConnectDialog = ({ entry, open, onOpenChange, onAddCustomAgent }: BridgeConnectDialogProps) => { @@ -73,7 +73,7 @@ export const BridgeConnectDialog = ({ entry, open, onOpenChange, onAddCustomAgen ) : (

{entry.name} ships as a platform binary. Follow its install instructions, then run it under{' '} - acp-bridge and add the printed{' '} + thunderbolt-acp-bridge and add the printed{' '} ws://127.0.0.1:PORT URL.

)} diff --git a/src/lib/agent-bridge-command.test.ts b/src/lib/agent-bridge-command.test.ts index 97a40be52..cab425dea 100644 --- a/src/lib/agent-bridge-command.test.ts +++ b/src/lib/agent-bridge-command.test.ts @@ -59,15 +59,15 @@ describe('composeInstallCommand', () => { }) describe('composeBridgeCommand', () => { - it('wraps the npx launch command in acp-bridge', () => { + it('wraps the npx launch command in thunderbolt-acp-bridge', () => { expect(composeBridgeCommand(entry({ npx: { package: '@agentclientprotocol/claude-agent-acp' } }))).toBe( - 'npx acp-bridge -- npx @agentclientprotocol/claude-agent-acp', + 'npx thunderbolt-acp-bridge -- npx @agentclientprotocol/claude-agent-acp', ) }) it('wraps a uvx launch command with its args', () => { expect(composeBridgeCommand(entry({ uvx: { package: 'py-agent', args: ['serve', '--port', '0'] } }))).toBe( - 'npx acp-bridge -- uvx py-agent serve --port 0', + 'npx thunderbolt-acp-bridge -- uvx py-agent serve --port 0', ) }) diff --git a/src/lib/agent-bridge-command.ts b/src/lib/agent-bridge-command.ts index dd87e219c..4cba29ce8 100644 --- a/src/lib/agent-bridge-command.ts +++ b/src/lib/agent-bridge-command.ts @@ -12,7 +12,7 @@ import type { RegistryEntry } from '@/types/registry' * * Two flavours are produced from the same launch command: * - the install/launch command (`npx ` / `uvx `), - * - the bridge command that wraps it (`npx acp-bridge -- `). + * - the bridge command that wraps it (`npx thunderbolt-acp-bridge -- `). * * `binary` distributions have no portable launch line (the registry leaves the * shape opaque per platform), so both helpers return `null` and the UI points @@ -47,8 +47,8 @@ export const composeLaunchCommand = (entry: RegistryEntry): string | null => { export const composeInstallCommand = (entry: RegistryEntry): string | null => composeLaunchCommand(entry) /** - * The `acp-bridge` invocation that relays the local stdio agent to a localhost - * WebSocket: `npx acp-bridge -- `. Everything after `--` is the + * The `thunderbolt-acp-bridge` invocation that relays the local stdio agent to a localhost + * WebSocket: `npx thunderbolt-acp-bridge -- `. Everything after `--` is the * agent's own launch argv. Returns `null` for `binary` distributions. */ export const composeBridgeCommand = (entry: RegistryEntry): string | null => { @@ -56,5 +56,5 @@ export const composeBridgeCommand = (entry: RegistryEntry): string | null => { if (!launch) { return null } - return `npx acp-bridge -- ${launch}` + return `npx thunderbolt-acp-bridge -- ${launch}` } diff --git a/acp-bridge/README.md b/thunderbolt-acp-bridge/README.md similarity index 90% rename from acp-bridge/README.md rename to thunderbolt-acp-bridge/README.md index 9fc53ce48..3ff4588be 100644 --- a/acp-bridge/README.md +++ b/thunderbolt-acp-bridge/README.md @@ -1,4 +1,4 @@ -# acp-bridge +# thunderbolt-acp-bridge A tiny local helper that bridges a **stdio ACP agent** (Claude Code, Gemini CLI, Goose, any [Agent Client Protocol](https://agentclientprotocol.com) agent) to a @@ -6,12 +6,12 @@ localhost WebSocket so [Thunderbolt](https://thunderbolt.io) — web or desktop can talk to it. Thunderbolt reaches agents over a WebSocket. Most ACP agents speak **stdio** -(newline-delimited JSON-RPC). `acp-bridge` spawns your agent and relays its stdio +(newline-delimited JSON-RPC). `thunderbolt-acp-bridge` spawns your agent and relays its stdio to a `ws://127.0.0.1:PORT` socket — one JSON object per WebSocket message, exactly what Thunderbolt expects. ``` -Thunderbolt ⇄ ws://127.0.0.1:PORT ⇄ acp-bridge ⇄ stdio ⇄ your agent +Thunderbolt ⇄ ws://127.0.0.1:PORT ⇄ thunderbolt-acp-bridge ⇄ stdio ⇄ your agent ``` No package manager to install. One dependency (`ws`); everything else is a Node @@ -22,19 +22,19 @@ built-in. Requires **Node.js ≥ 18**. Run the bridge, putting your agent command after `--`: ```bash -npx acp-bridge -- +npx thunderbolt-acp-bridge -- ``` A real example (the Claude Code ACP adapter): ```bash -npx acp-bridge -- npx -y @zed-industries/claude-code-acp +npx thunderbolt-acp-bridge -- npx -y @zed-industries/claude-code-acp ``` The bridge prints a banner with a copyable URL: ``` -acp-bridge ready +thunderbolt-acp-bridge ready Agent: npx Listening: ws://127.0.0.1:51847 @@ -58,7 +58,7 @@ agent down cleanly too. ## Usage ```bash -npx acp-bridge [options] -- [agent-args...] +npx thunderbolt-acp-bridge [options] -- [agent-args...] ``` Everything **after `--`** is your agent command. It's passed **straight to the OS @@ -83,7 +83,7 @@ without it (or with nothing after it) the bridge tells you so and exits. ## How it works -`acp-bridge` is a pure byte relay — it links no ACP SDK and never interprets the +`thunderbolt-acp-bridge` is a pure byte relay — it links no ACP SDK and never interprets the protocol. It spawns your agent once and reuses that single child process across WebSocket reconnects (so session state survives). Agent stdout is split into lines and each non-empty JSON object is sent as exactly one WebSocket frame; @@ -121,7 +121,7 @@ any such socket with code `1008`. ## Logging & privacy -`acp-bridge` never logs ACP message content. Log records are built from an +`thunderbolt-acp-bridge` never logs ACP message content. Log records are built from an **allowlist of scalars** — there is no code path that copies a frame body into a log line. Logged fields are limited to: direction, message kind, a fixed set of known method names (anything else collapses to `other`), a scalar JSON-RPC id @@ -141,7 +141,7 @@ The bridge prints an actionable message to stderr and exits with a specific code | Exit | When | Fix | | ---- | ---- | --- | | `0` | Clean shutdown (agent exited normally, or Ctrl-C with the agent gone). | Nothing — normal exit. | -| `64` | **Bad invocation.** Missing `--` separator, no agent command, an unknown option, or an invalid `--port`. | Re-check the command. The agent command goes after `--`, e.g. `npx acp-bridge -- npx -y @zed-industries/claude-code-acp`. | +| `64` | **Bad invocation.** Missing `--` separator, no agent command, an unknown option, or an invalid `--port`. | Re-check the command. The agent command goes after `--`, e.g. `npx thunderbolt-acp-bridge -- npx -y @zed-industries/claude-code-acp`. | | `69` | **Agent or server problem.** `command not found` (agent not on PATH), `permission denied` (agent not executable), the agent **exited before speaking ACP**, port already in use, or the agent exited non-zero while running. | For "command not found", install the agent / check your PATH. For "exited before speaking ACP", run the agent command directly to see its error (its stderr also prints above the message). For "port already in use", omit `--port` to auto-pick or choose another. | | `130`| **Ctrl-C / `SIGTERM`.** You stopped the bridge. | Nothing — expected interrupt. | diff --git a/acp-bridge/bin/cli.js b/thunderbolt-acp-bridge/bin/cli.js similarity index 97% rename from acp-bridge/bin/cli.js rename to thunderbolt-acp-bridge/bin/cli.js index 133671fca..625ce952e 100644 --- a/acp-bridge/bin/cli.js +++ b/thunderbolt-acp-bridge/bin/cli.js @@ -5,7 +5,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ /** - * acp-bridge CLI entry point. + * thunderbolt-acp-bridge CLI entry point. * * Thin wiring only: parse argv, build the injectable deps (spawn, ws server, * line reader, logger), start the bridge, and translate signals into a graceful @@ -42,7 +42,7 @@ const printBanner = (wsUrl, cmd0) => { process.stderr.write( [ '', - 'acp-bridge ready', + 'thunderbolt-acp-bridge ready', ` Agent: ${cmd0}`, ` Listening: ${wsUrl}`, '', diff --git a/acp-bridge/bun.lock b/thunderbolt-acp-bridge/bun.lock similarity index 91% rename from acp-bridge/bun.lock rename to thunderbolt-acp-bridge/bun.lock index da1a76230..2003cd386 100644 --- a/acp-bridge/bun.lock +++ b/thunderbolt-acp-bridge/bun.lock @@ -3,7 +3,7 @@ "configVersion": 1, "workspaces": { "": { - "name": "acp-bridge", + "name": "thunderbolt-acp-bridge", "dependencies": { "ws": "^8.18.0", }, diff --git a/acp-bridge/package.json b/thunderbolt-acp-bridge/package.json similarity index 82% rename from acp-bridge/package.json rename to thunderbolt-acp-bridge/package.json index dd3ac9d51..bf73e3cd0 100644 --- a/acp-bridge/package.json +++ b/thunderbolt-acp-bridge/package.json @@ -1,10 +1,10 @@ { - "name": "acp-bridge", + "name": "thunderbolt-acp-bridge", "version": "0.1.0", "description": "Tiny CLI that relays a local stdio ACP agent to a localhost WebSocket for Thunderbolt.", "type": "module", "bin": { - "acp-bridge": "bin/cli.js" + "thunderbolt-acp-bridge": "bin/cli.js" }, "files": [ "bin", diff --git a/acp-bridge/src/args.js b/thunderbolt-acp-bridge/src/args.js similarity index 91% rename from acp-bridge/src/args.js rename to thunderbolt-acp-bridge/src/args.js index 486f2ecb5..b1a639a96 100644 --- a/acp-bridge/src/args.js +++ b/thunderbolt-acp-bridge/src/args.js @@ -3,17 +3,17 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ /** - * Pure CLI argument parser for acp-bridge. + * Pure CLI argument parser for thunderbolt-acp-bridge. * * Everything BEFORE the `--` separator is a bridge flag. Everything AFTER it is * the agent command + argv, passed verbatim to `spawn` (no shell, no quoting). * A standalone `--` is mandatory to separate bridge flags from the agent argv. */ -const HELP_TEXT = `acp-bridge — relay a local stdio ACP agent to a localhost WebSocket for Thunderbolt. +const HELP_TEXT = `thunderbolt-acp-bridge — relay a local stdio ACP agent to a localhost WebSocket for Thunderbolt. Usage: - npx acp-bridge [options] -- [agent-args...] + npx thunderbolt-acp-bridge [options] -- [agent-args...] Everything after \`--\` is the agent command, passed straight to the OS (no shell). @@ -30,7 +30,7 @@ Options: --version Print the version and exit Example: - npx acp-bridge -- npx -y @zed-industries/claude-code-acp + npx thunderbolt-acp-bridge -- npx -y @zed-industries/claude-code-acp Paste the printed ws://127.0.0.1:PORT URL into Thunderbolt → Add Custom Agent.` @@ -125,7 +125,7 @@ export const parseArgs = (argv) => { } if (!flag.startsWith('-')) { // A bare token before `--` almost always means the user forgot the - // separator (e.g. `acp-bridge my-agent` instead of `acp-bridge -- my-agent`). + // separator (e.g. `thunderbolt-acp-bridge my-agent` instead of `thunderbolt-acp-bridge -- my-agent`). return { ...result, error: 'no agent command given (did you forget the `--` before the agent command?)' } } return { ...result, error: `unknown option: ${flag}` } diff --git a/acp-bridge/src/args.test.js b/thunderbolt-acp-bridge/src/args.test.js similarity index 100% rename from acp-bridge/src/args.test.js rename to thunderbolt-acp-bridge/src/args.test.js diff --git a/acp-bridge/src/errors.js b/thunderbolt-acp-bridge/src/errors.js similarity index 98% rename from acp-bridge/src/errors.js rename to thunderbolt-acp-bridge/src/errors.js index 5741b312b..196c51c91 100644 --- a/acp-bridge/src/errors.js +++ b/thunderbolt-acp-bridge/src/errors.js @@ -27,7 +27,7 @@ export const exitCodes = { * @returns {{ message: string, exitCode: number }} */ export const usageError = (reason) => ({ - message: `acp-bridge: ${reason}`, + message: `thunderbolt-acp-bridge: ${reason}`, exitCode: exitCodes.usage, }) diff --git a/acp-bridge/src/errors.test.js b/thunderbolt-acp-bridge/src/errors.test.js similarity index 97% rename from acp-bridge/src/errors.test.js rename to thunderbolt-acp-bridge/src/errors.test.js index cbb7ac051..fcf9a110a 100644 --- a/acp-bridge/src/errors.test.js +++ b/thunderbolt-acp-bridge/src/errors.test.js @@ -21,7 +21,7 @@ describe('usageError', () => { it('maps to exit 64 and prefixes the reason', () => { const r = usageError('no agent command given') expect(r.exitCode).toBe(64) - expect(r.message).toBe('acp-bridge: no agent command given') + expect(r.message).toBe('thunderbolt-acp-bridge: no agent command given') }) }) diff --git a/acp-bridge/src/log.js b/thunderbolt-acp-bridge/src/log.js similarity index 99% rename from acp-bridge/src/log.js rename to thunderbolt-acp-bridge/src/log.js index 83fbe71ad..1985b01b5 100644 --- a/acp-bridge/src/log.js +++ b/thunderbolt-acp-bridge/src/log.js @@ -3,7 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ /** - * PII-safe logging for acp-bridge. + * PII-safe logging for thunderbolt-acp-bridge. * * The cardinal rule: log objects are built from an ALLOWLIST of extracted * scalars. The raw ACP frame is NEVER handed to the logger, so prompt text, diff --git a/acp-bridge/src/log.test.js b/thunderbolt-acp-bridge/src/log.test.js similarity index 100% rename from acp-bridge/src/log.test.js rename to thunderbolt-acp-bridge/src/log.test.js diff --git a/acp-bridge/src/relay.js b/thunderbolt-acp-bridge/src/relay.js similarity index 100% rename from acp-bridge/src/relay.js rename to thunderbolt-acp-bridge/src/relay.js diff --git a/acp-bridge/src/relay.test.js b/thunderbolt-acp-bridge/src/relay.test.js similarity index 100% rename from acp-bridge/src/relay.test.js rename to thunderbolt-acp-bridge/src/relay.test.js diff --git a/acp-bridge/src/server.js b/thunderbolt-acp-bridge/src/server.js similarity index 99% rename from acp-bridge/src/server.js rename to thunderbolt-acp-bridge/src/server.js index 577c0618f..4a28698b5 100644 --- a/acp-bridge/src/server.js +++ b/thunderbolt-acp-bridge/src/server.js @@ -3,7 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ /** - * Lifecycle wiring for acp-bridge: spawn the agent, stand up a localhost + * Lifecycle wiring for thunderbolt-acp-bridge: spawn the agent, stand up a localhost * WebSocket server, connect them through the pure relay, and manage shutdown. * * Design constraints honored here: diff --git a/acp-bridge/src/server.test.js b/thunderbolt-acp-bridge/src/server.test.js similarity index 100% rename from acp-bridge/src/server.test.js rename to thunderbolt-acp-bridge/src/server.test.js From 96d2eb768900b5ce2aa710a84d964a4f4470c64f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=8Dtalo=20Menezes?= Date: Tue, 16 Jun 2026 15:48:56 -0300 Subject: [PATCH 17/23] fix: avoid double-bracketing an already-bracketed IPv6 host in banner --- thunderbolt-acp-bridge/src/server.js | 6 +++--- thunderbolt-acp-bridge/src/server.test.js | 4 ++++ 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/thunderbolt-acp-bridge/src/server.js b/thunderbolt-acp-bridge/src/server.js index 4a28698b5..8522c6f27 100644 --- a/thunderbolt-acp-bridge/src/server.js +++ b/thunderbolt-acp-bridge/src/server.js @@ -302,9 +302,9 @@ export const startBridge = async (cfg, deps) => { if (shuttingDown) return if (child.exitCode !== null || child.signalCode !== null) return // exit handler already fired ready = true - // IPv6 literals (the only host form containing a colon) must be bracketed - // in a URL per RFC 3986: ws://::1:PORT is malformed; ws://[::1]:PORT is valid. - const hostForUrl = host.includes(':') ? `[${host}]` : host + // Bracket an IPv6 literal host (the only host form with a colon) per RFC 3986, + // unless the user already passed it bracketed — avoid ws://[[::1]]:PORT. + const hostForUrl = host.includes(':') && !host.startsWith('[') ? `[${host}]` : host onBanner?.(`ws://${hostForUrl}:${resolvedPort}`) resolve({ stop }) }, GRACE_MS) diff --git a/thunderbolt-acp-bridge/src/server.test.js b/thunderbolt-acp-bridge/src/server.test.js index 3bb2b3f32..3e9b01b95 100644 --- a/thunderbolt-acp-bridge/src/server.test.js +++ b/thunderbolt-acp-bridge/src/server.test.js @@ -168,6 +168,10 @@ describe('startBridge lifecycle', () => { const ipv4 = await startReady({ port: 54321, cfg: { host: '127.0.0.1' } }) expect(ipv4.getBanner()).toBe('ws://127.0.0.1:54321') // no brackets, regression guard + + // An already-bracketed IPv6 literal must NOT be wrapped again (no ws://[[::1]]:PORT). + const bracketed = await startReady({ port: 54321, cfg: { host: '[::1]' } }) + expect(bracketed.getBanner()).toBe('ws://[::1]:54321') }) it('relays agent stdout lines to the connected socket', async () => { From 199d4a6957f1f1347f2a0a87fba9e891ecf33915 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=8Dtalo=20Menezes?= Date: Thu, 18 Jun 2026 18:12:02 -0300 Subject: [PATCH 18/23] feat: bridge both MCP and ACP local stdio agents via thunderbolt-stdio-bridge --- thunderbolt-acp-bridge/README.md | 157 ------- thunderbolt-acp-bridge/bin/cli.js | 114 ----- thunderbolt-acp-bridge/bun.lock | 15 - thunderbolt-acp-bridge/package.json | 24 -- thunderbolt-acp-bridge/src/server.js | 339 --------------- thunderbolt-stdio-bridge/README.md | 241 +++++++++++ thunderbolt-stdio-bridge/bin/cli.js | 297 +++++++++++++ thunderbolt-stdio-bridge/bun.lock | 202 +++++++++ thunderbolt-stdio-bridge/package.json | 25 ++ .../src/args.js | 65 ++- .../src/args.test.js | 71 ++- thunderbolt-stdio-bridge/src/child.js | 194 +++++++++ thunderbolt-stdio-bridge/src/child.test.js | 175 ++++++++ .../src/errors.js | 32 +- .../src/errors.test.js | 26 +- .../src/log.js | 45 +- .../src/log.test.js | 0 .../src/mcp-server.integration.test.js | 104 +++++ thunderbolt-stdio-bridge/src/mcp-server.js | 405 ++++++++++++++++++ .../src/mcp-server.test.js | 403 +++++++++++++++++ .../src/relay.js | 27 +- .../src/relay.test.js | 0 thunderbolt-stdio-bridge/src/server.js | 211 +++++++++ .../src/server.test.js | 0 thunderbolt-stdio-bridge/src/tunnel.js | 121 ++++++ thunderbolt-stdio-bridge/src/tunnel.test.js | 150 +++++++ thunderbolt-stdio-bridge/src/util.js | 37 ++ 27 files changed, 2788 insertions(+), 692 deletions(-) delete mode 100644 thunderbolt-acp-bridge/README.md delete mode 100644 thunderbolt-acp-bridge/bin/cli.js delete mode 100644 thunderbolt-acp-bridge/bun.lock delete mode 100644 thunderbolt-acp-bridge/package.json delete mode 100644 thunderbolt-acp-bridge/src/server.js create mode 100644 thunderbolt-stdio-bridge/README.md create mode 100644 thunderbolt-stdio-bridge/bin/cli.js create mode 100644 thunderbolt-stdio-bridge/bun.lock create mode 100644 thunderbolt-stdio-bridge/package.json rename {thunderbolt-acp-bridge => thunderbolt-stdio-bridge}/src/args.js (56%) rename {thunderbolt-acp-bridge => thunderbolt-stdio-bridge}/src/args.test.js (56%) create mode 100644 thunderbolt-stdio-bridge/src/child.js create mode 100644 thunderbolt-stdio-bridge/src/child.test.js rename {thunderbolt-acp-bridge => thunderbolt-stdio-bridge}/src/errors.js (70%) rename {thunderbolt-acp-bridge => thunderbolt-stdio-bridge}/src/errors.test.js (76%) rename {thunderbolt-acp-bridge => thunderbolt-stdio-bridge}/src/log.js (85%) rename {thunderbolt-acp-bridge => thunderbolt-stdio-bridge}/src/log.test.js (100%) create mode 100644 thunderbolt-stdio-bridge/src/mcp-server.integration.test.js create mode 100644 thunderbolt-stdio-bridge/src/mcp-server.js create mode 100644 thunderbolt-stdio-bridge/src/mcp-server.test.js rename {thunderbolt-acp-bridge => thunderbolt-stdio-bridge}/src/relay.js (85%) rename {thunderbolt-acp-bridge => thunderbolt-stdio-bridge}/src/relay.test.js (100%) create mode 100644 thunderbolt-stdio-bridge/src/server.js rename {thunderbolt-acp-bridge => thunderbolt-stdio-bridge}/src/server.test.js (100%) create mode 100644 thunderbolt-stdio-bridge/src/tunnel.js create mode 100644 thunderbolt-stdio-bridge/src/tunnel.test.js create mode 100644 thunderbolt-stdio-bridge/src/util.js diff --git a/thunderbolt-acp-bridge/README.md b/thunderbolt-acp-bridge/README.md deleted file mode 100644 index 3ff4588be..000000000 --- a/thunderbolt-acp-bridge/README.md +++ /dev/null @@ -1,157 +0,0 @@ -# thunderbolt-acp-bridge - -A tiny local helper that bridges a **stdio ACP agent** (Claude Code, Gemini CLI, -Goose, any [Agent Client Protocol](https://agentclientprotocol.com) agent) to a -localhost WebSocket so [Thunderbolt](https://thunderbolt.io) — web or desktop — -can talk to it. - -Thunderbolt reaches agents over a WebSocket. Most ACP agents speak **stdio** -(newline-delimited JSON-RPC). `thunderbolt-acp-bridge` spawns your agent and relays its stdio -to a `ws://127.0.0.1:PORT` socket — one JSON object per WebSocket message, -exactly what Thunderbolt expects. - -``` -Thunderbolt ⇄ ws://127.0.0.1:PORT ⇄ thunderbolt-acp-bridge ⇄ stdio ⇄ your agent -``` - -No package manager to install. One dependency (`ws`); everything else is a Node -built-in. Requires **Node.js ≥ 18**. - -## Quick start - -Run the bridge, putting your agent command after `--`: - -```bash -npx thunderbolt-acp-bridge -- -``` - -A real example (the Claude Code ACP adapter): - -```bash -npx thunderbolt-acp-bridge -- npx -y @zed-industries/claude-code-acp -``` - -The bridge prints a banner with a copyable URL: - -``` -thunderbolt-acp-bridge ready - Agent: npx - Listening: ws://127.0.0.1:51847 - -Paste this URL into Thunderbolt → Add Custom Agent: - ws://127.0.0.1:51847 - -Ctrl-C to stop. -``` - -Then, three steps: - -1. **Run** the bridge (the command above). -2. **Copy** the printed `ws://127.0.0.1:PORT` URL. -3. **Paste** it into Thunderbolt under **Add Custom Agent**. - -On the web app your browser may prompt for **Local Network Access** (Chrome's -prompt) — click **Allow**. The connection goes browser → your own machine; -nothing leaves your computer. Press **Ctrl-C** to stop the bridge; it shuts the -agent down cleanly too. - -## Usage - -```bash -npx thunderbolt-acp-bridge [options] -- [agent-args...] -``` - -Everything **after `--`** is your agent command. It's passed **straight to the OS -with no shell** — no quoting bugs, no injection. The `--` separator is required; -without it (or with nothing after it) the bridge tells you so and exits. - -### Options - -| Flag | Default | Meaning | -| -------------------- | ----------- | ------------------------------------------------------------- | -| `--port ` | ephemeral | WebSocket port (0–65535). Omit to let the OS auto-pick a free one. | -| `--host ` | `127.0.0.1` | Bind address. Loopback only by default. A non-loopback host prints a prominent warning (other machines on your network could then reach the agent). | -| `--allow-origin ` | — | Extra WebSocket `Origin` to accept. **Repeatable.** The Thunderbolt app origins are allowed by default. See [Security](#security). | -| `--allow-any-origin` | off | Accept **any** `Origin`, disabling the cross-origin guard. Escape hatch for dev/self-host only — prints a startup warning. See [Security](#security). | -| `--verbose` | off | Per-frame logging (direction, method, byte size — **redacted**, never content). | -| `--json` | off | Emit logs as raw JSON instead of pretty one-liners. | -| `--help` / `-h` | | Show help and exit. | -| `--version` / `-v` | | Print the version and exit. | - -`--port`, `--host`, and `--allow-origin` accept either form: `--port 51847` or -`--port=51847`. - -## How it works - -`thunderbolt-acp-bridge` is a pure byte relay — it links no ACP SDK and never interprets the -protocol. It spawns your agent once and reuses that single child process across -WebSocket reconnects (so session state survives). Agent stdout is split into -lines and each non-empty JSON object is sent as exactly one WebSocket frame; -each inbound WebSocket message is written to the agent's stdin with a trailing -newline. Non-JSON stdout lines are dropped (Thunderbolt does an unguarded -`JSON.parse` per message). On Ctrl-C / `SIGTERM` it closes the socket and -`SIGTERM`s the agent, escalating to `SIGKILL` after 2 s so the agent is never -orphaned; if the agent exits on its own, the bridge tears down with it. - -## Security - -The WebSocket server binds **`127.0.0.1` only** by default, so it's reachable -solely from your own machine. - -That's not enough on its own: browser WebSocket connections are **not** -same-origin-protected, and this server fronts a privileged local agent that can -read/write files and run terminal commands. Without a guard, any web page open -in a browser on your machine could connect to `ws://127.0.0.1:PORT` and drive -your agent. So the bridge accepts a connection only when its `Origin` header is a -known Thunderbolt app origin: - -- `https://app.thunderbolt.io` — production web app -- `tauri://localhost` and `http://tauri.localhost` — Tauri desktop/mobile webview -- `http://localhost:1420` — Vite dev server (web + Tauri dev) -- a **missing/empty** `Origin` — native and Tauri webviews routinely send none - -A disallowed `Origin` is rejected during the WebSocket handshake (HTTP `403`, so -a hostile page never even briefly connects); a defense-in-depth check also closes -any such socket with code `1008`. - -- **Add an origin:** `--allow-origin ` (repeatable) for dev or self-host. -- **Turn the check off:** `--allow-any-origin`. This lets **any** browser page on - the machine drive your agent — only use it on a trusted dev/self-host machine. - It prints a loud startup warning. - -## Logging & privacy - -`thunderbolt-acp-bridge` never logs ACP message content. Log records are built from an -**allowlist of scalars** — there is no code path that copies a frame body into a -log line. Logged fields are limited to: direction, message kind, a fixed set of -known method names (anything else collapses to `other`), a scalar JSON-RPC id -(long string ids are truncated), byte size, status, integer error codes, and -lifecycle events. The `Origin` header is sanitized to scheme + host before -logging. - -Prompt text, tool output, file paths, tokens, and your agent's argv are **never** -logged — even with `--verbose`. Dropped or malformed stdout lines are logged by -**byte size only**. The agent's own stderr passes through to your terminal -untouched. - -## Troubleshooting - -The bridge prints an actionable message to stderr and exits with a specific code: - -| Exit | When | Fix | -| ---- | ---- | --- | -| `0` | Clean shutdown (agent exited normally, or Ctrl-C with the agent gone). | Nothing — normal exit. | -| `64` | **Bad invocation.** Missing `--` separator, no agent command, an unknown option, or an invalid `--port`. | Re-check the command. The agent command goes after `--`, e.g. `npx thunderbolt-acp-bridge -- npx -y @zed-industries/claude-code-acp`. | -| `69` | **Agent or server problem.** `command not found` (agent not on PATH), `permission denied` (agent not executable), the agent **exited before speaking ACP**, port already in use, or the agent exited non-zero while running. | For "command not found", install the agent / check your PATH. For "exited before speaking ACP", run the agent command directly to see its error (its stderr also prints above the message). For "port already in use", omit `--port` to auto-pick or choose another. | -| `130`| **Ctrl-C / `SIGTERM`.** You stopped the bridge. | Nothing — expected interrupt. | - -## Development - -```bash -bun install -bun test -``` - -## License - -MPL-2.0 diff --git a/thunderbolt-acp-bridge/bin/cli.js b/thunderbolt-acp-bridge/bin/cli.js deleted file mode 100644 index 625ce952e..000000000 --- a/thunderbolt-acp-bridge/bin/cli.js +++ /dev/null @@ -1,114 +0,0 @@ -#!/usr/bin/env node - -/* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - -/** - * thunderbolt-acp-bridge CLI entry point. - * - * Thin wiring only: parse argv, build the injectable deps (spawn, ws server, - * line reader, logger), start the bridge, and translate signals into a graceful - * stop. All testable logic lives in ./src/*. - */ - -import { spawn } from 'node:child_process' -import { createInterface } from 'node:readline' -import { readFileSync } from 'node:fs' -import { fileURLToPath } from 'node:url' -import { dirname, join } from 'node:path' -import { WebSocketServer } from 'ws' - -import { parseArgs } from '../src/args.js' -import { usageError, exitCodes } from '../src/errors.js' -import { createLogger } from '../src/log.js' -import { startBridge } from '../src/server.js' - -const here = dirname(fileURLToPath(import.meta.url)) - -/** Read the package version without importing JSON (Node version-portable). */ -const readVersion = () => { - const pkg = JSON.parse(readFileSync(join(here, '..', 'package.json'), 'utf8')) - return pkg.version -} - -/** - * Print the prominent, copyable ready banner to stderr (so it never mixes with - * the agent's stdout/ACP frames). - * @param {string} wsUrl - * @param {string} cmd0 - */ -const printBanner = (wsUrl, cmd0) => { - process.stderr.write( - [ - '', - 'thunderbolt-acp-bridge ready', - ` Agent: ${cmd0}`, - ` Listening: ${wsUrl}`, - '', - `Paste this URL into Thunderbolt → Add Custom Agent:`, - ` ${wsUrl}`, - '', - 'Ctrl-C to stop.', - '', - ].join('\n'), - ) -} - -const main = async () => { - const args = parseArgs(process.argv.slice(2)) - - if (args.help) { - process.stdout.write(`${args.helpText}\n`) - process.exit(exitCodes.ok) - } - if (args.version) { - process.stdout.write(`${readVersion()}\n`) - process.exit(exitCodes.ok) - } - if (args.error) { - const { message, exitCode } = usageError(args.error) - process.stderr.write(`${message}\n\n${args.helpText}\n`) - process.exit(exitCode) - } - - const logger = createLogger({ json: args.json, verbose: args.verbose }) - const cmd0 = args.agentCmd[0] - - /** @type {((reason: string, code: number) => void) | null} */ - let stopFn = null - const installSignalHandlers = () => { - const onSignal = () => stopFn?.('signal', exitCodes.interrupted) - process.on('SIGINT', onSignal) - process.on('SIGTERM', onSignal) - } - installSignalHandlers() - - await startBridge( - { - agentCmd: args.agentCmd, - host: args.host, - port: args.port, - allowOrigins: args.allowOrigins, - allowAnyOrigin: args.allowAnyOrigin, - logger, - }, - { - spawn, - WebSocketServer, - createLineReader: (stream) => createInterface({ input: stream }), - onBanner: (wsUrl) => printBanner(wsUrl, cmd0), - // Capture `stop` immediately (before the grace window resolves) so a - // Ctrl-C during startup still tears the child + ws down cleanly. - onStop: (stop) => { - stopFn = stop - }, - }, - ) -} - -main().catch((err) => { - // startBridge already printed an actionable message + set the exit code. - const exitCode = typeof err?.exitCode === 'number' ? err.exitCode : exitCodes.unavailable - process.exit(exitCode) -}) diff --git a/thunderbolt-acp-bridge/bun.lock b/thunderbolt-acp-bridge/bun.lock deleted file mode 100644 index 2003cd386..000000000 --- a/thunderbolt-acp-bridge/bun.lock +++ /dev/null @@ -1,15 +0,0 @@ -{ - "lockfileVersion": 1, - "configVersion": 1, - "workspaces": { - "": { - "name": "thunderbolt-acp-bridge", - "dependencies": { - "ws": "^8.18.0", - }, - }, - }, - "packages": { - "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], - } -} diff --git a/thunderbolt-acp-bridge/package.json b/thunderbolt-acp-bridge/package.json deleted file mode 100644 index bf73e3cd0..000000000 --- a/thunderbolt-acp-bridge/package.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "name": "thunderbolt-acp-bridge", - "version": "0.1.0", - "description": "Tiny CLI that relays a local stdio ACP agent to a localhost WebSocket for Thunderbolt.", - "type": "module", - "bin": { - "thunderbolt-acp-bridge": "bin/cli.js" - }, - "files": [ - "bin", - "src", - "README.md" - ], - "scripts": { - "test": "bun test" - }, - "engines": { - "node": ">=18" - }, - "license": "MPL-2.0", - "dependencies": { - "ws": "^8.18.0" - } -} diff --git a/thunderbolt-acp-bridge/src/server.js b/thunderbolt-acp-bridge/src/server.js deleted file mode 100644 index 8522c6f27..000000000 --- a/thunderbolt-acp-bridge/src/server.js +++ /dev/null @@ -1,339 +0,0 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - -/** - * Lifecycle wiring for thunderbolt-acp-bridge: spawn the agent, stand up a localhost - * WebSocket server, connect them through the pure relay, and manage shutdown. - * - * Design constraints honored here: - * - Single persistent child reused across ws reconnects (Thunderbolt - * reconnects up to 3x; a per-connection child would lose session state). - * - stdio ['pipe','pipe','inherit'] — the agent's own stderr passes through - * untouched to the user's terminal (PII-safe: we never parse or log it). - * - error handlers everywhere (ENOENT, EADDRINUSE, EPIPE) so Node never - * crashes with an unhandled 'error'. - * - Ready banner only after the server is listening AND the child has - * survived the grace window. - * - SIGINT/SIGTERM → close ws 1000 + SIGTERM the child, escalating to SIGKILL. - * - child exit → close ws 1011 + exit. - * - * Dependencies (spawn, WebSocketServer, readline factory, clock) are injected so - * the lifecycle can be exercised with fakes. - */ - -import { exitCodes, spawnError, serverError, earlyExitError } from './errors.js' -import { wireAgentToWs, handleWsMessage } from './relay.js' -import { extractLogEvent, sanitizeOrigin, isOriginAllowed, defaultAllowedOrigins } from './log.js' - -const GRACE_MS = 750 -const KILL_ESCALATION_MS = 2000 -const WS_OPEN = 1 -const WS_CLOSE_POLICY_VIOLATION = 1008 - -/** - * Start the bridge. Resolves once the ready banner has been emitted (server - * listening + child survived grace). Rejects on a fatal startup error after - * printing an actionable message and setting the exit code. - * - * @param {object} cfg - * @param {string[]} cfg.agentCmd - [command, ...args] - * @param {string} cfg.host - * @param {number} cfg.port - 0 = ephemeral - * @param {string[]} [cfg.allowOrigins] - extra Origins to accept (beyond the Thunderbolt defaults) - * @param {boolean} [cfg.allowAnyOrigin] - disable the Origin check entirely (loud escape hatch) - * @param {ReturnType} cfg.logger - * @param {object} deps - * @param {typeof import('node:child_process').spawn} deps.spawn - * @param {new (opts: object) => import('ws').WebSocketServer} deps.WebSocketServer - * @param {(stream: NodeJS.ReadableStream) => import('node:events').EventEmitter} deps.createLineReader - * @param {(label: string) => void} [deps.onBanner] - prints the ready banner - * @param {(stop: (reason: string, code: number) => void) => void} [deps.onStop] - receives the stop fn synchronously (before grace resolves) - * @param {(code: number) => void} [deps.exit] - process.exit (injectable) - * @returns {Promise<{ stop: (reason: string, code: number) => void }>} - */ -export const startBridge = async (cfg, deps) => { - const { agentCmd, host, port, logger, allowOrigins = [], allowAnyOrigin = false } = cfg - const { spawn, WebSocketServer, createLineReader, onBanner, exit = process.exit } = deps - - const cmd0 = agentCmd[0] - const allowlist = [...defaultAllowedOrigins, ...allowOrigins] - - if (allowAnyOrigin) { - logger.warn({ lifecycle: 'origin-check-disabled' }) - process.stderr.write( - '\nWARNING: --allow-any-origin is set — the Origin check is OFF.\n' + - 'Any web page open in a browser on this machine can connect to the bridge\n' + - 'and drive your agent. Use this only for trusted dev/self-host setups.\n', - ) - } - - if (!isLoopbackHost(host)) { - process.stderr.write( - `\nWARNING: --host ${host} is not a loopback address — the bridge (and your\n` + - 'agent) is now reachable by other hosts on the network, not just this\n' + - 'machine. Keep the default 127.0.0.1 unless you really need remote access.\n', - ) - } - - const child = spawn(cmd0, agentCmd.slice(1), { stdio: ['pipe', 'pipe', 'inherit'] }) - - /** @type {import('ws').WebSocketServer | null} */ - let wss = null - /** @type {import('ws').WebSocket | null} */ - let activeSocket = null - let readerPaused = false - let shuttingDown = false - let ready = false - let exited = false - - // One-shot final exit. After a signal-driven stop, the actual exit is deferred - // to the child's 'exit' event (or the SIGKILL fallback timer), so guard it. - const finalExit = (code) => { - if (exited) return - exited = true - exit(code) - } - - const safeExit = (code) => { - if (shuttingDown) return - shuttingDown = true - // Never orphan the agent: if the child outlived a fatal error (e.g. the ws - // server failed to bind), kill it before we exit. safeExit is the only fatal - // chokepoint; the signal path uses stop(), so this never double-kills. - if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL') - finalExit(code) - } - - // --- agent stdout → ws (single persistent reader, reused across reconnects) --- - const lines = createLineReader(child.stdout) - wireAgentToWs({ - lines, - send: (line) => { - if (activeSocket && activeSocket.readyState === WS_OPEN) activeSocket.send(line) - }, - onForward: (line) => - logger.debug(extractLogEvent({ direction: 'agent->ws', line })), - // A dropped line is a raw, non-JSON stdout line that may contain content. - // Extract ONLY its byte length here — the line text is never logged. - onDrop: (line) => - logger.warn({ lifecycle: 'dropped-non-json', byteSize: Buffer.byteLength(line) }), - }) - - child.stdin.on('error', (err) => { - // EPIPE when the agent closed stdin — log lifecycle, don't crash. - logger.warn({ lifecycle: 'stdin-error', errorCode: err.code }) - }) - - child.stdout.on('error', (err) => { - // An unhandled stdout 'error' would crash Node — log the code only (PII-safe). - logger.warn({ lifecycle: 'stdout-error', errorCode: err.code }) - }) - - return new Promise((resolve, reject) => { - const closeWebSocket = (code) => { - if (activeSocket && activeSocket.readyState === WS_OPEN) activeSocket.close(code) - wss?.close() - } - - // While no client is connected, pause the agent→ws relay so the agent's output - // (e.g. an in-flight response during a client reconnect) is held by OS pipe - // backpressure instead of dropped. Resumed on the next connection. - const clearActiveSocket = (socket) => { - if (activeSocket !== socket) return - activeSocket = null - if (!readerPaused) { - lines.pause() - readerPaused = true - } - } - - // The exit code a signal-driven stop should ultimately exit with. The child's - // 'exit' handler reads it so the actual process.exit happens only once the - // child has died (or the SIGKILL fallback fires). - let stopCode = null - /** @type {ReturnType | null} */ - let killTimer = null - - /** - * Stop the bridge on a signal: close the ws, SIGTERM the child, and DEFER the - * final exit — let the child's 'exit' handler drive it once the agent dies. - * A REF'd fallback timer escalates to SIGKILL (and forces exit) if a stubborn - * agent ignores SIGTERM, so it can never be orphaned. - * @param {string} reason - * @param {number} code - */ - const stop = (reason, code) => { - if (shuttingDown) return - shuttingDown = true - stopCode = code - logger.info({ lifecycle: 'stopping', reason }) - closeWebSocket(1000) - process.stderr.write('\nStopping…\n') - - // Already dead? Exit straight away. - if (child.exitCode !== null || child.signalCode !== null) { - finalExit(code) - return - } - - child.kill('SIGTERM') - killTimer = setTimeout(() => { - logger.warn({ lifecycle: 'kill-escalation' }) - child.kill('SIGKILL') - finalExit(code) - }, KILL_ESCALATION_MS) - } - - // --- child error / early-exit handling ----------------------------------- - child.on('error', (err) => { - const { message, exitCode } = spawnError(err, { cmd0 }) - logger.error({ lifecycle: 'spawn-failed', errorCode: err.code }) - process.stderr.write(`\n${message}\n`) - closeWebSocket(1011) - reject(Object.assign(new Error(message), { exitCode })) - safeExit(exitCode) - }) - - // Registered synchronously in the same tick as spawn() above (nothing awaits - // before this Promise) and a child 'exit' is always delivered asynchronously, - // so this listener can never miss it. That invariant is what makes the grace - // timer's `exitCode !== null` early-return safe — by the time exitCode is set, - // this handler has already settled the Promise. Do NOT add an `await` before - // this Promise: it would open a window where the child exits unobserved. - child.on('exit', (code, signal) => { - // A signal-driven stop is in progress: the child has now died, so clear the - // SIGKILL fallback and drive the deferred final exit. - if (shuttingDown) { - if (killTimer) clearTimeout(killTimer) - logger.info({ lifecycle: 'agent-exited', exitCode: code ?? undefined, signal: signal ?? undefined }) - process.stderr.write('\nStopped.\n') - finalExit(stopCode ?? exitCodes.ok) - return - } - if (!ready) { - const { message, exitCode } = earlyExitError({ code, signal, cmd0 }) - logger.error({ lifecycle: 'agent-early-exit', exitCode: code ?? undefined, signal: signal ?? undefined }) - process.stderr.write(`\n${message}\n`) - process.stderr.write('(the agent\'s own output above may say why)\n') - closeWebSocket(1011) - reject(Object.assign(new Error(message), { exitCode })) - safeExit(exitCode) - return - } - logger.info({ lifecycle: 'agent-exited', exitCode: code ?? undefined, signal: signal ?? undefined }) - process.stderr.write('\nAgent exited. Stopping bridge.\n') - closeWebSocket(1011) - safeExit(code === 0 ? exitCodes.ok : exitCodes.unavailable) - }) - - // --- WebSocket server ----------------------------------------------------- - // verifyClient runs DURING the upgrade handshake: a disallowed Origin is - // rejected with HTTP 403 and the WebSocket is never established, so a hostile - // web page can't even briefly connect. The 'connection' handler below repeats - // the check as deterministic defense-in-depth (closing with 1008) for any - // path that bypasses verifyClient. - const verifyClient = ({ origin }) => - allowAnyOrigin || isOriginAllowed(origin, allowlist) - wss = new WebSocketServer({ host, port, verifyClient }) - - wss.on('error', (err) => { - const { message, exitCode } = serverError(err, { host, port }) - logger.error({ lifecycle: 'server-error', errorCode: err.code }) - process.stderr.write(`\n${message}\n`) - reject(Object.assign(new Error(message), { exitCode })) - safeExit(exitCode) - }) - - wss.on('connection', (socket, request) => { - const rawOrigin = request?.headers?.origin - const origin = sanitizeOrigin(rawOrigin) - - // Browser WebSocket connections aren't same-origin-protected: reject any - // Origin that isn't a known Thunderbolt app origin so a random web page on - // this machine can't hijack the local agent. The origin string is PII-safe - // to log (sanitized to scheme + host). - if (!allowAnyOrigin && !isOriginAllowed(rawOrigin, allowlist)) { - logger.warn({ lifecycle: 'origin-rejected', origin }) - socket.close(WS_CLOSE_POLICY_VIOLATION) - return - } - - logger.info({ lifecycle: 'connected', origin }) - // Single-client bridge: a new connection supersedes any previous one. Assign - // the new socket first (so the old socket's 'close' handler won't null it), - // then close the old one so a superseded client can't keep injecting into the - // shared agent stdin while only the newest receives output. - const previous = activeSocket - activeSocket = socket - if (previous && previous !== socket && previous.readyState === WS_OPEN) previous.close(1000) - if (readerPaused) { - lines.resume() - readerPaused = false - } - - socket.on('message', (data) => { - // Drop messages from a socket that's been superseded by a newer connection: - // close() doesn't synchronously stop buffered 'message' events, so guard on - // identity to keep a stale client out of the shared agent stdin. - if (activeSocket !== socket) return - handleWsMessage({ - data, - write: (chunk) => child.stdin.write(chunk), - onWrite: (chunk) => - logger.debug(extractLogEvent({ direction: 'ws->agent', line: chunk.replace(/\n$/, '') })), - }) - }) - socket.on('error', (err) => { - logger.warn({ lifecycle: 'socket-error', errorCode: err.code }) - clearActiveSocket(socket) - }) - socket.on('close', (closeCode) => { - clearActiveSocket(socket) - logger.info({ lifecycle: 'disconnected', closeCode }) - }) - }) - - wss.on('listening', () => { - const resolvedPort = resolvePort(wss, port) - logger.info({ lifecycle: 'listening', port: resolvedPort }) - // Banner only after the child also survives the grace window. - setTimeout(() => { - if (shuttingDown) return - if (child.exitCode !== null || child.signalCode !== null) return // exit handler already fired - ready = true - // Bracket an IPv6 literal host (the only host form with a colon) per RFC 3986, - // unless the user already passed it bracketed — avoid ws://[[::1]]:PORT. - const hostForUrl = host.includes(':') && !host.startsWith('[') ? `[${host}]` : host - onBanner?.(`ws://${hostForUrl}:${resolvedPort}`) - resolve({ stop }) - }, GRACE_MS) - }) - - deps.onStop?.(stop) - }) -} - -/** - * Whether a bind host is a loopback address (only reachable from this machine). - * A non-loopback host exposes the agent to other hosts on the network, which - * warrants a prominent startup warning. - * @param {string} host - * @returns {boolean} - */ -const isLoopbackHost = (host) => - host === '127.0.0.1' || host === 'localhost' || host === '::1' - -/** - * Resolve the actual listening port (ephemeral 0 → OS-assigned). - * @param {import('ws').WebSocketServer | null} wss - * @param {number} requested - * @returns {number} - */ -const resolvePort = (wss, requested) => { - const address = wss?.address?.() - if (address && typeof address === 'object' && typeof address.port === 'number') { - return address.port - } - return requested -} diff --git a/thunderbolt-stdio-bridge/README.md b/thunderbolt-stdio-bridge/README.md new file mode 100644 index 000000000..db23fe619 --- /dev/null +++ b/thunderbolt-stdio-bridge/README.md @@ -0,0 +1,241 @@ +# thunderbolt-stdio-bridge + +A tiny local helper that bridges a **local stdio agent or MCP server** to +[Thunderbolt](https://thunderbolt.io) — web or desktop — over localhost. + +It has two protocol faces, picked with a required `--mode` flag: + +- **`--mode acp`** — bridges a **stdio ACP agent** (Claude Code, Gemini CLI, Goose, + any [Agent Client Protocol](https://agentclientprotocol.com) agent) to a localhost + WebSocket. Thunderbolt reaches ACP agents over a WebSocket; most speak **stdio** + (newline-delimited JSON-RPC), so the bridge relays one JSON object per WebSocket + message — exactly what Thunderbolt expects. +- **`--mode mcp`** — serves a **stdio MCP server** over **Streamable HTTP** at + `http://127.0.0.1:PORT/mcp`, the transport Thunderbolt's _Add MCP Server_ flow + speaks. Optionally exposes it over a public [cloudflared](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) + tunnel (`--tunnel`) with a mandatory bearer secret. + +``` +Thunderbolt ⇄ ws://127.0.0.1:PORT ⇄ thunderbolt-stdio-bridge --mode acp ⇄ stdio ⇄ your ACP agent +Thunderbolt ⇄ http://127.0.0.1:PORT/mcp ⇄ thunderbolt-stdio-bridge --mode mcp ⇄ stdio ⇄ your MCP server +``` + +No package manager to install. Two dependencies (`ws` and the official +`@modelcontextprotocol/sdk`); everything else is a Node built-in. Requires +**Node.js ≥ 18**. + +## Quick start + +Pick a mode and put the command to run after `--`: + +```bash +# Bridge an ACP agent over a WebSocket: +npx thunderbolt-stdio-bridge --mode acp -- npx -y @zed-industries/claude-code-acp + +# Bridge an MCP server over Streamable HTTP: +npx thunderbolt-stdio-bridge --mode mcp -- npx -y @modelcontextprotocol/server-everything +``` + +`--mode` is **required** — there is no default. The stdio child is either an ACP +agent or an MCP server, and the bridge won't guess. + +The bridge prints a banner with a copyable URL. In ACP mode: + +``` +thunderbolt-stdio-bridge ready + Agent: npx + Listening: ws://127.0.0.1:51847 + +Paste this URL into Thunderbolt → Add Custom Agent: + ws://127.0.0.1:51847 + +Ctrl-C to stop. +``` + +In MCP mode: + +``` +thunderbolt-stdio-bridge ready (MCP) + Server: npx + Listening: http://127.0.0.1:51847/mcp + +Paste this URL into Thunderbolt → Add MCP Server: + http://127.0.0.1:51847/mcp + +Ctrl-C to stop. +``` + +Then, three steps: + +1. **Run** the bridge (a command above). +2. **Copy** the printed URL. +3. **Paste** it into Thunderbolt — under **Add Custom Agent** (acp) or **Add MCP + Server** (mcp). + +On the web app your browser may prompt for **Local Network Access** (Chrome's +prompt) — click **Allow**. The connection goes browser → your own machine; +nothing leaves your computer. Press **Ctrl-C** to stop the bridge; it shuts the +agent down cleanly too. + +## Usage + +```bash +npx thunderbolt-stdio-bridge --mode [options] -- [args...] +``` + +Everything **after `--`** is your command. It's passed **straight to the OS with +no shell** — no quoting bugs, no injection. The `--` separator is required; +without it (or with nothing after it) the bridge tells you so and exits. + +### Options + +| Flag | Default | Meaning | +| -------------------- | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--mode ` | **required** | Protocol face: `acp` = WebSocket relay for an ACP agent, `mcp` = MCP Streamable HTTP server at `/mcp`. | +| `--tunnel` | off | **(mcp only)** Expose the MCP face over a public cloudflared tunnel with a mandatory auto-generated bearer. Rejected with `--mode acp`. See [Tunnel](#tunnel-mcp-only). | +| `--port ` | ephemeral | WebSocket/HTTP port (0–65535). Omit to let the OS auto-pick a free one. | +| `--host ` | `127.0.0.1` | Bind address. Loopback only by default. A non-loopback host prints a prominent warning (other machines on your network could then reach the agent). | +| `--allow-origin ` | — | Extra `Origin` to accept (applies to both faces). **Repeatable.** The Thunderbolt app origins are allowed by default. See [Security](#security). | +| `--allow-any-origin` | off | Accept **any** `Origin`, disabling the cross-origin guard. Escape hatch for dev/self-host only — prints a startup warning. See [Security](#security). | +| `--verbose` | off | Per-frame logging (direction, method, byte size — **redacted**, never content). | +| `--json` | off | Emit logs as raw JSON instead of pretty one-liners. | +| `--help` / `-h` | | Show help and exit. | +| `--version` / `-v` | | Print the version and exit. | + +`--port`, `--host`, and `--allow-origin` accept either form: `--port 51847` or +`--port=51847`. + +## How it works + +Both faces wrap a **single persistent stdio child**, spawned once and reused +across reconnects so session state survives. The bridge spawns your command with +`['pipe','pipe','inherit']`, so the agent's own **stderr passes through to your +terminal untouched** (the bridge never parses or logs it). On Ctrl-C / `SIGTERM` +it tears down the face and `SIGTERM`s the child, escalating to `SIGKILL` after a +grace window so the child is never orphaned; if the child exits on its own, the +bridge tears down with it. + +- **ACP mode** is a pure byte relay — it links no ACP SDK and never interprets the + protocol. Agent stdout is split into lines and each non-empty JSON object is sent + as exactly one WebSocket frame; each inbound WebSocket message is written to the + agent's stdin with a trailing newline. Non-JSON stdout lines are dropped + (Thunderbolt does an unguarded `JSON.parse` per message). A new connection + supersedes the previous one (newest-wins), and while no client is connected the + relay is paused so an in-flight response is held by pipe backpressure, not lost. +- **MCP mode** is **stateful**, not a byte relay. It drives the official MCP SDK's + `StreamableHTTPServerTransport` as a bare adapter: a POST is correlated to the + child's stdout response by JSON-RPC `id`; notifications/responses with no `id` + return `202`; server-initiated messages flow out over the GET SSE stream. The SDK + mints the `Mcp-Session-Id`. There is one MCP session per child (a loopback bridge + is a 1:1 user→agent pipe). + +## MCP mode details + +Add the printed `http://127.0.0.1:PORT/mcp` URL under Thunderbolt → **Add MCP +Server**. The face enforces the same Origin allowlist as ACP (the MCP spec +requires Origin validation to defend against DNS-rebinding), answers CORS +preflight, caps request body size, and binds `127.0.0.1` by default. + +Server-initiated requests and notifications use the GET **SSE** stream, which is +full-fidelity on localhost. Plain request/response tool calls (the common case) +work everywhere; only server-push degrades over a cloudflared quick tunnel (see +below). + +## Tunnel (mcp only) + +`--tunnel` exposes the MCP face over a public +`https://.trycloudflare.com/mcp` URL by spawning +[`cloudflared`](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/) +(it must be on your `PATH`). + +Because a public URL fronts a privileged local server, the tunnel makes a +**bearer secret mandatory**: the bridge auto-generates a strong secret, requires +`Authorization: Bearer ` on **every** request, and prints the secret to +**stderr only** — never in the URL or a query string. Paste both the URL and the +bearer into Thunderbolt → Add MCP Server: + +``` +thunderbolt-stdio-bridge ready (MCP over cloudflared tunnel) + Server: npx + Public URL: https://random-words-1234.trycloudflare.com/mcp + +In Thunderbolt → Add MCP Server, paste: + URL: https://random-words-1234.trycloudflare.com/mcp + Authorization: Bearer +``` + +Notes: + +- **`--tunnel` requires `--mode mcp`.** It is **rejected with `--mode acp`**: ACP + carries no client auth, so a public tunnel would be an unauthenticated + remote-code primitive. ACP stays localhost-only. +- **Server-push/SSE degrades over quick tunnels.** Request/response tool calls work + normally; server-initiated streaming may not arrive. Use localhost for + full-fidelity SSE. + +## Security + +The server binds **`127.0.0.1` only** by default, so it's reachable solely from +your own machine. + +That's not enough on its own: browser connections are **not** +same-origin-protected, and the bridge fronts a privileged local agent that can +read/write files and run commands (ACP) or invoke tools (MCP). Without a guard, +any web page open in a browser on your machine could connect and drive it. So +both faces accept a connection only when its `Origin` header is a known +Thunderbolt app origin: + +- `https://app.thunderbolt.io` — production web app +- `tauri://localhost` and `http://tauri.localhost` — Tauri desktop/mobile webview +- `http://localhost:1420` — Vite dev server (web + Tauri dev) +- a **missing/empty** `Origin` — native and Tauri webviews routinely send none + +In ACP mode a disallowed `Origin` is rejected during the WebSocket handshake +(HTTP `403`); a defense-in-depth check also closes any such socket with code +`1008`. In MCP mode a disallowed `Origin` gets `403` and the request is never +delegated to the transport. + +- **Add an origin:** `--allow-origin ` (repeatable) for dev or self-host. +- **Turn the check off:** `--allow-any-origin`. This lets **any** browser page on + the machine drive your agent — only use it on a trusted dev/self-host machine. + It prints a loud startup warning. +- **Tunnel auth:** with `--tunnel`, a mandatory bearer gates every request on top + of the Origin check (see [Tunnel](#tunnel-mcp-only)). + +## Logging & privacy + +`thunderbolt-stdio-bridge` never logs message content. Log records are built from +an **allowlist of scalars** — there is no code path that copies a frame body into +a log line. Logged fields are limited to: direction, message kind, a fixed set of +known ACP/MCP method names (anything else collapses to `other`), a scalar +JSON-RPC id (long string ids are truncated), byte size, status, integer error +codes, and lifecycle events. The `Origin` header is sanitized to scheme + host +before logging. + +Prompt text, tool arguments/results, file paths, tokens, and your command's argv +are **never** logged — even with `--verbose`. Dropped or malformed stdout lines +are logged by **byte size only**. The agent's own stderr passes through to your +terminal untouched. + +## Troubleshooting + +The bridge prints an actionable message to stderr and exits with a specific code: + +| Exit | When | Fix | +| ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | Clean shutdown (agent exited normally, or Ctrl-C with the agent gone). | Nothing — normal exit. | +| `64` | **Bad invocation.** Missing `--mode`, missing `--` separator, no command, an unknown option, an invalid `--port`, or `--tunnel` with `--mode acp`. | Re-check the command. `--mode ` is required and the command goes after `--`, e.g. `npx thunderbolt-stdio-bridge --mode acp -- npx -y @zed-industries/claude-code-acp`. | +| `69` | **Agent or server problem.** `command not found` / `permission denied`, the agent **exited before it was ready**, port already in use, or a non-zero exit. | For "command not found", install the command / check your PATH. For early exit, run the command directly to see its error (its stderr also prints above). For port in use, omit `--port`. | +| `70` | **Missing dependency.** `--tunnel` was set but `cloudflared` isn't on your PATH. | Install cloudflared, or drop `--tunnel` to stay on localhost. | +| `130` | **Ctrl-C / `SIGTERM`.** You stopped the bridge. | Nothing — expected interrupt. | + +## Development + +```bash +bun install +bun test +``` + +## License + +MPL-2.0 diff --git a/thunderbolt-stdio-bridge/bin/cli.js b/thunderbolt-stdio-bridge/bin/cli.js new file mode 100644 index 000000000..8f5e7b522 --- /dev/null +++ b/thunderbolt-stdio-bridge/bin/cli.js @@ -0,0 +1,297 @@ +#!/usr/bin/env node + +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * thunderbolt-stdio-bridge CLI entry point. + * + * Thin wiring only: parse argv, build the injectable deps (spawn, ws server, + * line reader, logger), start the bridge, and translate signals into a graceful + * stop. All testable logic lives in ./src/*. + */ + +import { spawn } from 'node:child_process' +import { createInterface } from 'node:readline' +import { createServer } from 'node:http' +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { dirname, join } from 'node:path' +import { WebSocketServer } from 'ws' +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js' + +import { parseArgs } from '../src/args.js' +import { usageError, tunnelError, exitCodes } from '../src/errors.js' +import { createLogger } from '../src/log.js' +import { superviseChild } from '../src/child.js' +import { startBridge } from '../src/server.js' +import { startMcpFace, newSessionId } from '../src/mcp-server.js' +import { startTunnel, generateBearer } from '../src/tunnel.js' +import { formatHostForUrl } from '../src/util.js' + +const here = dirname(fileURLToPath(import.meta.url)) + +/** + * Wire SIGINT/SIGTERM to the supervisor's graceful stop. `getStop` is read + * lazily on each signal so the ACP path (whose stop is captured only once the + * startBridge promise wires it) and the MCP path (stop known synchronously) can + * share one installation. Signal handling lives in the CLI composition root, not + * the reusable supervisor. + * @param {() => ((reason: string, code: number) => void) | null | undefined} getStop + */ +const installSignalHandlers = (getStop) => { + const onSignal = () => getStop()?.('signal', exitCodes.interrupted) + process.on('SIGINT', onSignal) + process.on('SIGTERM', onSignal) +} + +/** Read the package version without importing JSON (Node version-portable). */ +const readVersion = () => { + const pkg = JSON.parse(readFileSync(join(here, '..', 'package.json'), 'utf8')) + return pkg.version +} + +/** + * Print the prominent, copyable ready banner to stderr (so it never mixes with + * the agent's stdout/ACP frames). + * @param {string} wsUrl + * @param {string} cmd0 + */ +const printBanner = (wsUrl, cmd0) => { + process.stderr.write( + [ + '', + 'thunderbolt-stdio-bridge ready', + ` Agent: ${cmd0}`, + ` Listening: ${wsUrl}`, + '', + `Paste this URL into Thunderbolt → Add Custom Agent:`, + ` ${wsUrl}`, + '', + 'Ctrl-C to stop.', + '', + ].join('\n'), + ) +} + +/** + * Print the MCP-mode ready banner to stderr (kept off stdout so it never mixes + * with the agent's MCP frames). + * @param {string} httpUrl - e.g. http://127.0.0.1:PORT/mcp + * @param {string} cmd0 + */ +const printMcpBanner = (httpUrl, cmd0) => { + process.stderr.write( + [ + '', + 'thunderbolt-stdio-bridge ready (MCP)', + ` Server: ${cmd0}`, + ` Listening: ${httpUrl}`, + '', + 'Paste this URL into Thunderbolt → Add MCP Server:', + ` ${httpUrl}`, + '', + 'Ctrl-C to stop.', + '', + ].join('\n'), + ) +} + +/** + * Print the cloudflared tunnel banner to stderr. The public MCP URL and the + * bearer secret are deliberately on their own lines for clean copy/paste; the + * secret NEVER appears in a URL or query string. + * @param {{ mcpUrl: string, bearer: string, cmd0: string }} info + */ +const printTunnelBanner = ({ mcpUrl, bearer, cmd0 }) => { + process.stderr.write( + [ + '', + 'thunderbolt-stdio-bridge ready (MCP over cloudflared tunnel)', + ` Server: ${cmd0}`, + ` Public URL: ${mcpUrl}`, + '', + 'In Thunderbolt → Add MCP Server, paste:', + ` URL: ${mcpUrl}`, + ` Authorization: Bearer ${bearer}`, + '', + 'The bearer is REQUIRED on every request — keep it secret.', + 'Note: server-push/SSE degrades over quick tunnels; request/response tool', + 'calls work normally.', + '', + 'Ctrl-C to stop.', + '', + ].join('\n'), + ) +} + +/** + * Run the MCP Streamable HTTP face: supervise the shared stdio child, drive the + * SDK transport as a bare adapter, optionally open a cloudflared tunnel, and + * translate Ctrl-C into the supervisor's graceful stop (which also tears the + * tunnel + http server down via closeFace). + * @param {ReturnType} args + * @param {ReturnType} logger + */ +const runMcp = async (args, logger) => { + const cmd0 = args.agentCmd[0] + + // A tunnel makes the MCP face publicly reachable, so it MUST require a bearer. + const requiredBearer = args.tunnel ? generateBearer() : null + + /** @type {(() => void) | null} */ + let closeHttp = null + /** @type {(() => void) | null} */ + let stopTunnel = null + + // The ready banner prints once BOTH conditions hold: the child survived the + // grace window (onReady) AND the face — plus the cloudflared tunnel, which can + // take longer than the grace window — is up. Either may finish first. + let graceSurvived = false + /** @type {(() => void) | null} */ + let printReadyBanner = null + const maybePrintBanner = () => { + if (!graceSurvived || printReadyBanner === null) return + const print = printReadyBanner + printReadyBanner = null + print() + } + + const { child, lines, stop, safeExit } = superviseChild( + { agentCmd: args.agentCmd, logger }, + { + spawn, + createLineReader: (stream) => createInterface({ input: stream }), + onReady: () => { + graceSurvived = true + maybePrintBanner() + }, + // Shutdown teardown: stop the cloudflared tunnel and close the http server + // + SDK transport (wired in once the face/tunnel are up). + closeFace: () => { + stopTunnel?.() + closeHttp?.() + }, + // The supervisor already printed the actionable message and exited; main + // awaits runMcp only for setup, so there is no start promise to reject. + onFatalRejection: () => {}, + }, + ) + + installSignalHandlers(() => stop) + + const face = await startMcpFace( + { + child, + lines, + host: args.host, + port: args.port, + allowOrigins: args.allowOrigins, + allowAnyOrigin: args.allowAnyOrigin, + requiredBearer, + logger, + }, + { + createHttpServer: (handler) => createServer(handler), + createTransport: () => + new StreamableHTTPServerTransport({ sessionIdGenerator: newSessionId, enableJsonResponse: true }), + }, + ).catch((err) => { + // A bind failure (e.g. EADDRINUSE) must never orphan the child — reap it. + const exitCode = typeof err?.exitCode === 'number' ? err.exitCode : exitCodes.unavailable + process.stderr.write(`\n${err?.message ?? 'MCP server failed to start'}\n`) + safeExit(exitCode) + return null + }) + if (face === null) return + closeHttp = face.close + const { port } = face + + if (args.tunnel) { + // Capture the teardown SYNCHRONOUSLY (onStop fires when cloudflared spawns, + // before it announces a URL) so a Ctrl-C mid-startup still kills cloudflared. + const tunnel = await startTunnel( + { host: args.host, port, logger }, + { + spawn, + onStop: (s) => { + stopTunnel = s + }, + }, + ).catch((err) => { + // cloudflared missing / early exit: tear the stdio child down and exit with + // the actionable code from tunnelError (70 for missing, 69 otherwise). + const exitCode = typeof err?.exitCode === 'number' ? err.exitCode : tunnelError(err).exitCode + process.stderr.write(`\n${err?.message ?? 'cloudflared tunnel failed'}\n`) + safeExit(exitCode) + return null + }) + if (tunnel === null) return + stopTunnel = tunnel.stop + printReadyBanner = () => printTunnelBanner({ mcpUrl: tunnel.mcpUrl, bearer: requiredBearer, cmd0 }) + maybePrintBanner() + return + } + + printReadyBanner = () => printMcpBanner(`http://${formatHostForUrl(args.host)}:${port}/mcp`, cmd0) + maybePrintBanner() +} + +const main = async () => { + const args = parseArgs(process.argv.slice(2)) + + if (args.help) { + process.stdout.write(`${args.helpText}\n`) + process.exit(exitCodes.ok) + } + if (args.version) { + process.stdout.write(`${readVersion()}\n`) + process.exit(exitCodes.ok) + } + if (args.error) { + const { message, exitCode } = usageError(args.error) + process.stderr.write(`${message}\n\n${args.helpText}\n`) + process.exit(exitCode) + } + + const logger = createLogger({ json: args.json, verbose: args.verbose }) + const cmd0 = args.agentCmd[0] + + if (args.mode === 'mcp') { + await runMcp(args, logger) + return + } + + /** @type {((reason: string, code: number) => void) | null} */ + let stopFn = null + installSignalHandlers(() => stopFn) + + await startBridge( + { + agentCmd: args.agentCmd, + host: args.host, + port: args.port, + allowOrigins: args.allowOrigins, + allowAnyOrigin: args.allowAnyOrigin, + logger, + }, + { + spawn, + WebSocketServer, + createLineReader: (stream) => createInterface({ input: stream }), + onBanner: (wsUrl) => printBanner(wsUrl, cmd0), + // Capture `stop` immediately (before the grace window resolves) so a + // Ctrl-C during startup still tears the child + ws down cleanly. + onStop: (stop) => { + stopFn = stop + }, + }, + ) +} + +main().catch((err) => { + // startBridge already printed an actionable message + set the exit code. + const exitCode = typeof err?.exitCode === 'number' ? err.exitCode : exitCodes.unavailable + process.exit(exitCode) +}) diff --git a/thunderbolt-stdio-bridge/bun.lock b/thunderbolt-stdio-bridge/bun.lock new file mode 100644 index 000000000..b6b6c96df --- /dev/null +++ b/thunderbolt-stdio-bridge/bun.lock @@ -0,0 +1,202 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "thunderbolt-stdio-bridge", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", + "ws": "^8.18.0", + }, + }, + }, + "packages": { + "@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="], + + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], + + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], + + "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + + "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], + + "body-parser": ["body-parser@2.3.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="], + + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + + "content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="], + + "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], + + "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + + "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], + + "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], + + "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="], + + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], + + "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], + + "eventsource-parser": ["eventsource-parser@3.1.0", "", {}, "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg=="], + + "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], + + "express-rate-limit": ["express-rate-limit@8.5.2", "", { "dependencies": { "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-uri": ["fast-uri@3.1.2", "", {}, "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ=="], + + "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], + + "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], + + "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], + + "hono": ["hono@4.12.26", "", {}, "sha512-uyZtpnYxM9CmQ7QsQknM4zN8EftNqhON1qYeIKM0Se67CCEe2c44xyGURwB0axX2fBDu1dqHrHAc1hmNT8ITkw=="], + + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + + "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "ip-address": ["ip-address@10.2.0", "", {}, "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA=="], + + "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], + + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], + + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], + + "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], + + "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + + "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], + + "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], + + "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + + "qs": ["qs@6.15.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw=="], + + "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], + + "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], + + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + + "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], + + "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], + + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "side-channel": ["side-channel@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4", "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ=="], + + "side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + + "type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="], + + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + + "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], + + "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + + "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], + + "body-parser/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], + + "type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], + } +} diff --git a/thunderbolt-stdio-bridge/package.json b/thunderbolt-stdio-bridge/package.json new file mode 100644 index 000000000..db60bf229 --- /dev/null +++ b/thunderbolt-stdio-bridge/package.json @@ -0,0 +1,25 @@ +{ + "name": "thunderbolt-stdio-bridge", + "version": "0.1.0", + "description": "Tiny CLI that bridges a local stdio agent to Thunderbolt over localhost (today: ACP agents via WebSocket; MCP support via --mode is coming).", + "type": "module", + "bin": { + "thunderbolt-stdio-bridge": "bin/cli.js" + }, + "files": [ + "bin", + "src", + "README.md" + ], + "scripts": { + "test": "bun test" + }, + "engines": { + "node": ">=18" + }, + "license": "MPL-2.0", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", + "ws": "^8.18.0" + } +} diff --git a/thunderbolt-acp-bridge/src/args.js b/thunderbolt-stdio-bridge/src/args.js similarity index 56% rename from thunderbolt-acp-bridge/src/args.js rename to thunderbolt-stdio-bridge/src/args.js index b1a639a96..5641745ab 100644 --- a/thunderbolt-acp-bridge/src/args.js +++ b/thunderbolt-stdio-bridge/src/args.js @@ -3,25 +3,33 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ /** - * Pure CLI argument parser for thunderbolt-acp-bridge. + * Pure CLI argument parser for thunderbolt-stdio-bridge. * * Everything BEFORE the `--` separator is a bridge flag. Everything AFTER it is * the agent command + argv, passed verbatim to `spawn` (no shell, no quoting). * A standalone `--` is mandatory to separate bridge flags from the agent argv. */ -const HELP_TEXT = `thunderbolt-acp-bridge — relay a local stdio ACP agent to a localhost WebSocket for Thunderbolt. +const HELP_TEXT = `thunderbolt-stdio-bridge — bridge a local stdio agent or MCP server to Thunderbolt over localhost. + +In ACP mode it relays a stdio ACP agent over a WebSocket; in MCP mode it serves a +local stdio MCP server over Streamable HTTP at /mcp. Usage: - npx thunderbolt-acp-bridge [options] -- [agent-args...] + npx thunderbolt-stdio-bridge --mode [options] -- [args...] -Everything after \`--\` is the agent command, passed straight to the OS (no shell). +Everything after \`--\` is the agent/server command, passed straight to the OS (no shell). Options: - --port WebSocket port (default: ephemeral, auto-picked) + --mode REQUIRED. Protocol face: acp = WebSocket relay for an ACP + agent, mcp = MCP Streamable HTTP server at /mcp + --tunnel (mcp only) Expose the MCP face over a public cloudflared + tunnel with a mandatory auto-generated bearer secret. + Rejected with --mode acp (ACP has no client auth). + --port WebSocket/HTTP port (default: ephemeral, auto-picked) --host Bind address (default: 127.0.0.1, loopback only) - --allow-origin Extra WebSocket Origin to accept (repeatable). The - Thunderbolt app origins are allowed by default. + --allow-origin Extra Origin to accept (repeatable). The Thunderbolt app + origins are allowed by default. --allow-any-origin Accept ANY Origin (disables the cross-origin guard). Escape hatch for dev/self-host only — not recommended. --verbose Per-frame logging (method + size, redacted; never content) @@ -29,10 +37,12 @@ Options: --help Show this help and exit --version Print the version and exit -Example: - npx thunderbolt-acp-bridge -- npx -y @zed-industries/claude-code-acp +Examples: + npx thunderbolt-stdio-bridge --mode acp -- npx -y @zed-industries/claude-code-acp + npx thunderbolt-stdio-bridge --mode mcp -- npx -y @modelcontextprotocol/server-everything -Paste the printed ws://127.0.0.1:PORT URL into Thunderbolt → Add Custom Agent.` +Paste the printed URL into Thunderbolt — the ws://127.0.0.1:PORT URL goes under +Add Custom Agent (acp); the http://127.0.0.1:PORT/mcp URL goes under Add MCP Server (mcp).` /** * Parse process argv (the slice AFTER node + script path) into a structured @@ -44,6 +54,8 @@ Paste the printed ws://127.0.0.1:PORT URL into Thunderbolt → Add Custom Agent. * version: boolean, * verbose: boolean, * json: boolean, + * mode: 'acp' | 'mcp' | null, + * tunnel: boolean, * host: string, * port: number, * allowOrigins: string[], @@ -59,6 +71,8 @@ export const parseArgs = (argv) => { version: false, verbose: false, json: false, + mode: null, + tunnel: false, host: '127.0.0.1', port: 0, allowOrigins: [], @@ -98,6 +112,20 @@ export const parseArgs = (argv) => { i += 1 continue } + if (flag === '--tunnel') { + result.tunnel = true + i += 1 + continue + } + if (flag === '--mode' || flag.startsWith('--mode=')) { + const value = flag.includes('=') ? flag.slice('--mode='.length) : flags[i + 1] + if (!value) return { ...result, error: '--mode requires a value (acp or mcp)' } + if (value !== 'acp' && value !== 'mcp') + return { ...result, error: `invalid --mode: ${value} (expected acp or mcp)` } + result.mode = value + i += flag.includes('=') ? 1 : 2 + continue + } if (flag === '--allow-origin' || flag.startsWith('--allow-origin=')) { const value = flag.includes('=') ? flag.slice('--allow-origin='.length) : flags[i + 1] if (!value) return { ...result, error: '--allow-origin requires a value' } @@ -125,7 +153,7 @@ export const parseArgs = (argv) => { } if (!flag.startsWith('-')) { // A bare token before `--` almost always means the user forgot the - // separator (e.g. `thunderbolt-acp-bridge my-agent` instead of `thunderbolt-acp-bridge -- my-agent`). + // separator (e.g. `thunderbolt-stdio-bridge my-agent` instead of `thunderbolt-stdio-bridge -- my-agent`). return { ...result, error: 'no agent command given (did you forget the `--` before the agent command?)' } } return { ...result, error: `unknown option: ${flag}` } @@ -135,5 +163,20 @@ export const parseArgs = (argv) => { return { ...result, error: 'no agent command given' } } + // --mode is part of the interface, not a silent default: a stdio child is + // either an ACP agent (ws relay) or an MCP server (http face), and guessing + // wrong would relay the wrong protocol. Require the caller to say which. + if (result.mode === null) { + return { ...result, error: '--mode is required (acp or mcp)' } + } + + // A public cloudflared tunnel over ACP would be an unauthenticated + // remote-code primitive: ACP carries no client auth, so anyone who learns the + // tunnel URL could drive the agent. MCP gates the tunnel behind a mandatory + // bearer; ACP has no such gate and stays localhost-only. + if (result.tunnel && result.mode === 'acp') { + return { ...result, error: '--tunnel is not allowed with --mode acp (ACP has no client auth; a public tunnel would expose an unauthenticated agent — ACP is localhost-only). Use --mode mcp to tunnel.' } + } + return result } diff --git a/thunderbolt-acp-bridge/src/args.test.js b/thunderbolt-stdio-bridge/src/args.test.js similarity index 56% rename from thunderbolt-acp-bridge/src/args.test.js rename to thunderbolt-stdio-bridge/src/args.test.js index 0be349853..49afac620 100644 --- a/thunderbolt-acp-bridge/src/args.test.js +++ b/thunderbolt-stdio-bridge/src/args.test.js @@ -7,7 +7,7 @@ import { parseArgs } from './args.js' describe('parseArgs', () => { it('parses agent command after -- verbatim (no shell)', () => { - const r = parseArgs(['--', 'npx', '-y', '@zed-industries/claude-code-acp']) + const r = parseArgs(['--mode', 'acp', '--', 'npx', '-y', '@zed-industries/claude-code-acp']) expect(r.error).toBeNull() expect(r.agentCmd).toEqual(['npx', '-y', '@zed-industries/claude-code-acp']) expect(r.host).toBe('127.0.0.1') @@ -15,44 +15,73 @@ describe('parseArgs', () => { }) it('keeps agent flags that look like bridge flags (they are after --)', () => { - const r = parseArgs(['--', 'my-agent', '--port', '9999', '--verbose']) + const r = parseArgs(['--mode', 'acp', '--', 'my-agent', '--port', '9999', '--verbose']) expect(r.agentCmd).toEqual(['my-agent', '--port', '9999', '--verbose']) expect(r.port).toBe(0) // bridge port untouched expect(r.verbose).toBe(false) }) it('parses --port before --', () => { - const r = parseArgs(['--port', '8123', '--', 'agent']) + const r = parseArgs(['--mode', 'acp', '--port', '8123', '--', 'agent']) expect(r.error).toBeNull() expect(r.port).toBe(8123) expect(r.agentCmd).toEqual(['agent']) }) it('supports --port=NNNN form', () => { - const r = parseArgs(['--port=8123', '--', 'agent']) + const r = parseArgs(['--mode', 'acp', '--port=8123', '--', 'agent']) expect(r.port).toBe(8123) }) it('parses --host before --', () => { - const r = parseArgs(['--host', '0.0.0.0', '--', 'agent']) + const r = parseArgs(['--mode', 'acp', '--host', '0.0.0.0', '--', 'agent']) expect(r.host).toBe('0.0.0.0') expect(r.error).toBeNull() }) + it('requires --mode (errors when omitted)', () => { + const r = parseArgs(['--', 'agent']) + expect(r.mode).toBeNull() + expect(r.error).toBe('--mode is required (acp or mcp)') + }) + + it('parses --mode acp and --mode mcp (and the --mode=value form)', () => { + expect(parseArgs(['--mode', 'acp', '--', 'agent']).mode).toBe('acp') + expect(parseArgs(['--mode=acp', '--', 'agent']).mode).toBe('acp') + expect(parseArgs(['--mode', 'mcp', '--', 'agent']).mode).toBe('mcp') + expect(parseArgs(['--mode=mcp', '--', 'agent']).mode).toBe('mcp') + }) + + it('errors on an unknown --mode value', () => { + expect(parseArgs(['--mode', 'grpc', '--', 'agent']).error).toBe('invalid --mode: grpc (expected acp or mcp)') + }) + + it('errors when --mode is missing a value', () => { + expect(parseArgs(['--mode']).error).toBe('--mode requires a value (acp or mcp)') + }) + it('parses --verbose and --json', () => { - const r = parseArgs(['--verbose', '--json', '--', 'agent']) + const r = parseArgs(['--mode', 'acp', '--verbose', '--json', '--', 'agent']) expect(r.verbose).toBe(true) expect(r.json).toBe(true) }) it('defaults the origin allowlist to empty extras and check enabled', () => { - const r = parseArgs(['--', 'agent']) + const r = parseArgs(['--mode', 'acp', '--', 'agent']) expect(r.allowOrigins).toEqual([]) expect(r.allowAnyOrigin).toBe(false) }) it('collects repeatable --allow-origin values', () => { - const r = parseArgs(['--allow-origin', 'http://localhost:3000', '--allow-origin=https://dev.test', '--', 'agent']) + const r = parseArgs([ + '--mode', + 'acp', + '--allow-origin', + 'http://localhost:3000', + '--allow-origin=https://dev.test', + '--', + 'agent', + ]) expect(r.allowOrigins).toEqual(['http://localhost:3000', 'https://dev.test']) expect(r.error).toBeNull() }) @@ -62,7 +91,7 @@ describe('parseArgs', () => { }) it('parses --allow-any-origin', () => { - const r = parseArgs(['--allow-any-origin', '--', 'agent']) + const r = parseArgs(['--mode', 'acp', '--allow-any-origin', '--', 'agent']) expect(r.allowAnyOrigin).toBe(true) }) @@ -108,4 +137,28 @@ describe('parseArgs', () => { expect(parseArgs([]).helpText).toContain('Usage:') expect(parseArgs([]).helpText).toContain('Add Custom Agent') }) + + it('defaults --tunnel to false', () => { + expect(parseArgs(['--mode', 'acp', '--', 'agent']).tunnel).toBe(false) + }) + + it('parses --tunnel with --mode mcp', () => { + const r = parseArgs(['--mode', 'mcp', '--tunnel', '--', 'agent']) + expect(r.error).toBeNull() + expect(r.tunnel).toBe(true) + expect(r.mode).toBe('mcp') + }) + + it('requires --mode even with --tunnel (mode check precedes the tunnel gate)', () => { + const r = parseArgs(['--tunnel', '--', 'agent']) + expect(r.tunnel).toBe(true) + expect(r.mode).toBeNull() + expect(r.error).toBe('--mode is required (acp or mcp)') + }) + + it('rejects --tunnel with explicit --mode acp', () => { + const r = parseArgs(['--mode', 'acp', '--tunnel', '--', 'agent']) + expect(r.error).toContain('--tunnel is not allowed with --mode acp') + expect(r.error).toContain('localhost-only') + }) }) diff --git a/thunderbolt-stdio-bridge/src/child.js b/thunderbolt-stdio-bridge/src/child.js new file mode 100644 index 000000000..679c90871 --- /dev/null +++ b/thunderbolt-stdio-bridge/src/child.js @@ -0,0 +1,194 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Shared stdio-child supervisor for thunderbolt-stdio-bridge. + * + * Both protocol faces — the ACP WebSocket relay (server.js) and the MCP + * Streamable HTTP face (mcp-server.js) — wrap the SAME single persistent stdio + * child. This module owns every concern that is about the CHILD rather than the + * protocol: + * - spawn ['pipe','pipe','inherit'] (the agent's stderr passes through, PII-safe); + * - an ndjson line reader over stdout; + * - stdin/stdout 'error' handlers (log the errorCode only — never content); + * - child 'error' → spawnError; child 'exit' → earlyExit (before ready) vs + * agent-exited (after ready); + * - a grace window the child must survive before the face declares readiness; + * - never-orphan SIGKILL on a fatal error (safeExit); + * - signal-driven stop() with SIGTERM → SIGKILL escalation and a single, + * deferred final exit. + * + * The face supplies only the protocol-specific seams: onReady (emit the banner + + * resolve its start promise), closeFace (tear down ws sockets/server or the http + * server + SDK transport), and onFatalRejection (reject its start promise). + * + * Dependencies (spawn, line-reader factory, exit) are injected so the whole + * lifecycle is exercisable with fakes — no real processes in unit tests. + */ + +import { exitCodes, spawnError, earlyExitError } from './errors.js' + +const GRACE_MS = 750 +const KILL_ESCALATION_MS = 2000 + +/** Protocol-agnostic close reason handed to the face's closeFace seam. Each face + * maps it to its own teardown (the ws face → a close code; the MCP face closes + * the http server + SDK transport and ignores the reason). */ +const FACE_CLOSE_NORMAL = 'normal' +const FACE_CLOSE_GOING_AWAY = 'going-away' + +/** + * Spawn and supervise the persistent stdio child shared by both faces. + * + * @param {object} cfg + * @param {string[]} cfg.agentCmd - [command, ...args] + * @param {ReturnType} cfg.logger + * @param {number} [cfg.graceMs] - window the child must survive before onReady (default 750) + * @param {number} [cfg.killEscalationMs] - SIGTERM→SIGKILL window on stop (default 2000) + * @param {object} deps + * @param {typeof import('node:child_process').spawn} deps.spawn + * @param {(stream: NodeJS.ReadableStream) => import('node:events').EventEmitter} deps.createLineReader + * @param {() => void} deps.onReady - fired ONCE after the child survives grace (face: banner + resolve) + * @param {(reason: 'normal' | 'going-away') => void} deps.closeFace - face teardown (ws maps reason→close code; http closes server + transport) + * @param {(err: Error & { exitCode: number }) => void} deps.onFatalRejection - reject the face's start promise + * @param {(code: number) => void} [deps.exit] - process.exit (injectable) + * @returns {{ + * child: import('node:child_process').ChildProcess, + * lines: import('node:events').EventEmitter, + * stop: (reason: string, code: number) => void, + * safeExit: (code: number) => void, + * }} + */ +export const superviseChild = (cfg, deps) => { + const { agentCmd, logger, graceMs = GRACE_MS, killEscalationMs = KILL_ESCALATION_MS } = cfg + const { spawn, createLineReader, onReady, closeFace, onFatalRejection, exit = process.exit } = deps + + const cmd0 = agentCmd[0] + const child = spawn(cmd0, agentCmd.slice(1), { stdio: ['pipe', 'pipe', 'inherit'] }) + const lines = createLineReader(child.stdout) + + let ready = false + let exited = false + let shuttingDown = false + // The exit code a signal-driven stop should ultimately exit with. The child's + // 'exit' handler reads it so the actual exit happens only once the child dies. + let stopCode = null + /** @type {ReturnType | null} */ + let killTimer = null + /** @type {ReturnType | null} */ + let graceTimer = null + + // One-shot final exit. After a signal-driven stop the actual exit is deferred + // to the child's 'exit' event (or the SIGKILL fallback timer), so guard it. + const finalExit = (code) => { + if (exited) return + exited = true + if (graceTimer) clearTimeout(graceTimer) + exit(code) + } + + // Never orphan the agent: if the child outlived a fatal error (e.g. the face's + // server failed to bind), SIGKILL it before exiting. safeExit is the fatal + // chokepoint; the signal path uses stop(), so this never double-kills. + const safeExit = (code) => { + if (shuttingDown) return + shuttingDown = true + if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL') + finalExit(code) + } + + /** + * Stop the bridge on a signal: close the face, SIGTERM the child, and DEFER the + * final exit — let the child's 'exit' handler drive it once the agent dies. A + * REF'd fallback timer escalates to SIGKILL (and forces exit) if a stubborn + * agent ignores SIGTERM, so it can never be orphaned. + * @param {string} reason + * @param {number} code + */ + const stop = (reason, code) => { + if (shuttingDown) return + shuttingDown = true + stopCode = code + logger.info({ lifecycle: 'stopping', reason }) + closeFace(FACE_CLOSE_NORMAL) + process.stderr.write('\nStopping…\n') + + // Already dead? Exit straight away. + if (child.exitCode !== null || child.signalCode !== null) { + finalExit(code) + return + } + + child.kill('SIGTERM') + killTimer = setTimeout(() => { + logger.warn({ lifecycle: 'kill-escalation' }) + child.kill('SIGKILL') + finalExit(code) + }, killEscalationMs) + } + + child.stdin.on('error', (err) => { + // EPIPE when the agent closed stdin — log lifecycle, don't crash. + logger.warn({ lifecycle: 'stdin-error', errorCode: err.code }) + }) + + child.stdout.on('error', (err) => { + // An unhandled stdout 'error' would crash Node — log the code only (PII-safe). + logger.warn({ lifecycle: 'stdout-error', errorCode: err.code }) + }) + + child.on('error', (err) => { + const { message, exitCode } = spawnError(err, { cmd0 }) + logger.error({ lifecycle: 'spawn-failed', errorCode: err.code }) + process.stderr.write(`\n${message}\n`) + closeFace(FACE_CLOSE_GOING_AWAY) + onFatalRejection(Object.assign(new Error(message), { exitCode })) + safeExit(exitCode) + }) + + // Registered synchronously in the same tick as spawn() above (nothing awaits + // before this) and a child 'exit' is always delivered asynchronously, so this + // listener can never miss it. That invariant is what makes the grace timer's + // `exitCode !== null` early-return safe — by the time exitCode is set, this + // handler has already settled the face's promise. Do NOT introduce an `await` + // before this registration: it would open a window where the child exits + // unobserved. + child.on('exit', (code, signal) => { + // A signal-driven stop is in progress: the child has now died, so clear the + // SIGKILL fallback and drive the deferred final exit. + if (shuttingDown) { + if (killTimer) clearTimeout(killTimer) + logger.info({ lifecycle: 'agent-exited', exitCode: code ?? undefined, signal: signal ?? undefined }) + process.stderr.write('\nStopped.\n') + finalExit(stopCode ?? exitCodes.ok) + return + } + if (!ready) { + const { message, exitCode } = earlyExitError({ code, signal, cmd0 }) + logger.error({ lifecycle: 'agent-early-exit', exitCode: code ?? undefined, signal: signal ?? undefined }) + process.stderr.write(`\n${message}\n`) + process.stderr.write("(the agent's own output above may say why)\n") + closeFace(FACE_CLOSE_GOING_AWAY) + onFatalRejection(Object.assign(new Error(message), { exitCode })) + safeExit(exitCode) + return + } + logger.info({ lifecycle: 'agent-exited', exitCode: code ?? undefined, signal: signal ?? undefined }) + process.stderr.write('\nAgent exited. Stopping bridge.\n') + closeFace(FACE_CLOSE_GOING_AWAY) + safeExit(code === 0 ? exitCodes.ok : exitCodes.unavailable) + }) + + // Grace window: the child must survive graceMs (and the face must not already + // be tearing down) before we declare readiness. A child that dies inside the + // window takes the early-exit path above instead. + graceTimer = setTimeout(() => { + if (shuttingDown) return + if (child.exitCode !== null || child.signalCode !== null) return // exit handler already fired + ready = true + onReady() + }, graceMs) + + return { child, lines, stop, safeExit } +} diff --git a/thunderbolt-stdio-bridge/src/child.test.js b/thunderbolt-stdio-bridge/src/child.test.js new file mode 100644 index 000000000..fc6d87af9 --- /dev/null +++ b/thunderbolt-stdio-bridge/src/child.test.js @@ -0,0 +1,175 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { describe, it, expect } from 'bun:test' +import { EventEmitter } from 'node:events' +import { superviseChild } from './child.js' +import { createLogger } from './log.js' + +/** + * A fake child process: pipes for stdin/stdout, emits exit/error. Mirrors the + * server.test.js fake so the supervisor sees the same shapes the faces do. + * + * @param {{ ignoreSigterm?: boolean }} [opts] - when ignoreSigterm is set, the + * child records the signal but does NOT die on SIGTERM (only on SIGKILL), + * modeling a stubborn agent so the escalation path can be tested. + */ +const makeFakeChild = ({ ignoreSigterm = false } = {}) => { + const child = new EventEmitter() + child.exitCode = null + child.signalCode = null + child.stdin = Object.assign(new EventEmitter(), { + written: [], + write(chunk) { + this.written.push(chunk) + return true + }, + }) + child.stdout = new EventEmitter() + child.killed = [] + child.kill = (sig) => { + child.killed.push(sig) + if (sig === 'SIGTERM' && ignoreSigterm) return true + child.exitCode = 0 + child.signalCode = sig + queueMicrotask(() => child.emit('exit', 0, sig)) + return true + } + return child +} + +const quietLogger = () => createLogger({ stream: { write: () => {} } }) + +/** + * Drive a supervisor with fakes and capture every seam the face would receive. + * Tiny grace/escalation windows keep the tests fast. + */ +const supervise = ({ child = makeFakeChild(), graceMs = 20, killEscalationMs = 20 } = {}) => { + const calls = { ready: 0, closeFace: [], fatal: [], exit: [] } + const lines = new EventEmitter() + const result = superviseChild( + { agentCmd: ['my-agent', '--flag'], logger: quietLogger(), graceMs, killEscalationMs }, + { + spawn: () => child, + createLineReader: () => lines, + onReady: () => { + calls.ready += 1 + }, + closeFace: (reason) => calls.closeFace.push(reason), + onFatalRejection: (err) => calls.fatal.push(err), + exit: (code) => calls.exit.push(code), + }, + ) + return { ...result, calls, lines } +} + +const tick = (ms) => new Promise((r) => setTimeout(r, ms)) + +describe('superviseChild — spawn + line reader', () => { + it('spawns with inherited stderr and returns the line reader', () => { + const child = makeFakeChild() + const { child: returned, lines } = supervise({ child }) + expect(returned).toBe(child) + expect(lines).toBeInstanceOf(EventEmitter) + }) +}) + +describe('superviseChild — grace window', () => { + it('fires onReady once after the child survives the grace window', async () => { + const { calls } = supervise({ graceMs: 20 }) + expect(calls.ready).toBe(0) // not yet — still inside the window + await tick(40) + expect(calls.ready).toBe(1) + await tick(40) + expect(calls.ready).toBe(1) // strictly once + }) + + it('does NOT fire onReady if the child dies inside the grace window', async () => { + const { child, calls } = supervise({ graceMs: 40 }) + child.emit('exit', 1, null) // dies before grace elapses + await tick(60) + expect(calls.ready).toBe(0) + }) +}) + +describe('superviseChild — early child exit (before ready)', () => { + it('maps an early exit to earlyExitError/unavailable and rejects + exits 69', async () => { + const { child, calls } = supervise({ graceMs: 60 }) + child.emit('exit', 1, null) + await tick(0) + expect(calls.fatal).toHaveLength(1) + expect(calls.fatal[0].exitCode).toBe(69) + expect(calls.fatal[0].message).toContain('before it was ready') + expect(calls.closeFace).toContain('going-away') + expect(calls.exit).toEqual([69]) + }) +}) + +describe('superviseChild — spawn error', () => { + it('maps spawn ENOENT to spawnError/unavailable, rejects, closes the face, and never orphans', async () => { + const child = makeFakeChild() + const { calls } = supervise({ child, graceMs: 60 }) + child.emit('error', Object.assign(new Error('spawn my-agent ENOENT'), { code: 'ENOENT' })) + await tick(0) + expect(calls.fatal).toHaveLength(1) + expect(calls.fatal[0].exitCode).toBe(69) + expect(calls.fatal[0].message).toContain('command not found') + expect(calls.closeFace).toContain('going-away') + // Never-orphan: a live child is SIGKILLed before exit. + expect(child.killed).toContain('SIGKILL') + expect(calls.exit).toEqual([69]) + }) +}) + +describe('superviseChild — never-orphan safeExit', () => { + it('SIGKILLs a still-alive child on a fatal face error and exits once', () => { + const child = makeFakeChild() // alive: exitCode/signalCode null + const { safeExit, calls } = supervise({ child, graceMs: 60 }) + safeExit(69) // the face hit a fatal error (e.g. server bind failure) + expect(child.killed).toContain('SIGKILL') + expect(calls.exit).toEqual([69]) + }) +}) + +describe('superviseChild — signal stop', () => { + it("SIGTERMs the child, closes the face with 'normal', and exits with the stop code once it dies", async () => { + const { child, stop, calls } = supervise() + stop('signal', 130) + expect(calls.closeFace).toContain('normal') + expect(child.killed).toContain('SIGTERM') + await tick(0) + expect(calls.exit).toEqual([130]) + }) + + it('escalates SIGTERM → SIGKILL after the window for a stubborn child, then exits', async () => { + const stubborn = makeFakeChild({ ignoreSigterm: true }) + const { stop, calls } = supervise({ child: stubborn, killEscalationMs: 20 }) + stop('signal', 130) + expect(stubborn.killed).toEqual(['SIGTERM']) + await tick(0) + expect(calls.exit).toEqual([]) // SIGTERM ignored → not dead yet + await tick(40) + expect(stubborn.killed).toEqual(['SIGTERM', 'SIGKILL']) + expect(calls.exit).toEqual([130]) + }) +}) + +describe('superviseChild — agent exit after ready', () => { + it("closes the face with 'going-away' and exits 0 on a clean agent exit", async () => { + const { child, calls } = supervise({ graceMs: 20 }) + await tick(40) // pass grace → ready + child.emit('exit', 0, null) + await tick(0) + expect(calls.closeFace).toContain('going-away') + expect(calls.exit).toEqual([0]) + }) + + it('exits 69 when a ready agent dies by signal (code null) or non-zero', async () => { + const { child, calls } = supervise({ graceMs: 20 }) + await tick(40) + child.emit('exit', null, 'SIGKILL') + await tick(0) + expect(calls.exit).toEqual([69]) + }) +}) diff --git a/thunderbolt-acp-bridge/src/errors.js b/thunderbolt-stdio-bridge/src/errors.js similarity index 70% rename from thunderbolt-acp-bridge/src/errors.js rename to thunderbolt-stdio-bridge/src/errors.js index 196c51c91..0b03e2679 100644 --- a/thunderbolt-acp-bridge/src/errors.js +++ b/thunderbolt-stdio-bridge/src/errors.js @@ -8,6 +8,7 @@ * Exit codes follow sysexits.h conventions: * 64 (EX_USAGE) — bad CLI invocation (no/invalid args) * 69 (EX_UNAVAILABLE) — agent/runtime problem (ENOENT, EADDRINUSE, early exit) + * 70 (EX_SOFTWARE) — a required external dependency is missing (cloudflared) * 130 (128+SIGINT) — clean Ctrl-C stop * 0 — clean shutdown */ @@ -16,6 +17,7 @@ export const exitCodes = { ok: 0, usage: 64, unavailable: 69, + dependencyMissing: 70, interrupted: 130, } @@ -27,7 +29,7 @@ export const exitCodes = { * @returns {{ message: string, exitCode: number }} */ export const usageError = (reason) => ({ - message: `thunderbolt-acp-bridge: ${reason}`, + message: `thunderbolt-stdio-bridge: ${reason}`, exitCode: exitCodes.usage, }) @@ -84,7 +86,31 @@ export const serverError = (err, ctx = {}) => { } /** - * Map an early agent exit (before it spoke ACP) to a message + exit code. + * Map a cloudflared tunnel problem to an actionable message + exit code. + * `cloudflared` missing from PATH is a distinct, install-fixable condition, so + * it gets its own exit code (70) separate from a generic runtime failure (69). + * + * @param {{ code?: string, reason?: string }} err - a Node spawn error (ENOENT) + * or a synthetic `{ reason }` for an early/abnormal cloudflared exit + * @returns {{ message: string, exitCode: number }} + */ +export const tunnelError = (err) => { + if (err?.code === 'ENOENT') { + return { + message: + 'cloudflared not found on your PATH — install it (https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/) and re-run, or drop --tunnel to stay on localhost', + exitCode: exitCodes.dependencyMissing, + } + } + return { + message: `cloudflared tunnel failed (${err?.reason ?? err?.code ?? 'unknown error'})`, + exitCode: exitCodes.unavailable, + } +} + +/** + * Map an early agent exit (before the bridge became ready) to a message + exit + * code. Shared by both faces (ACP + MCP), so the wording is protocol-agnostic. * The caller appends redacted stderr tail separately. * * @param {{ code?: number | null, signal?: string | null, cmd0?: string }} info @@ -97,7 +123,7 @@ export const earlyExitError = (info) => { ? `signal ${info.signal}` : `code ${info.code ?? 'unknown'}` return { - message: `agent exited (${how}) before speaking ACP — try running ${cmd0} directly to see why`, + message: `agent exited (${how}) before it was ready — try running ${cmd0} directly to see why`, exitCode: exitCodes.unavailable, } } diff --git a/thunderbolt-acp-bridge/src/errors.test.js b/thunderbolt-stdio-bridge/src/errors.test.js similarity index 76% rename from thunderbolt-acp-bridge/src/errors.test.js rename to thunderbolt-stdio-bridge/src/errors.test.js index fcf9a110a..483f233cf 100644 --- a/thunderbolt-acp-bridge/src/errors.test.js +++ b/thunderbolt-stdio-bridge/src/errors.test.js @@ -8,12 +8,13 @@ import { usageError, spawnError, serverError, + tunnelError, earlyExitError, } from './errors.js' describe('exitCodes', () => { it('uses sysexits-style codes', () => { - expect(exitCodes).toEqual({ ok: 0, usage: 64, unavailable: 69, interrupted: 130 }) + expect(exitCodes).toEqual({ ok: 0, usage: 64, unavailable: 69, dependencyMissing: 70, interrupted: 130 }) }) }) @@ -21,7 +22,7 @@ describe('usageError', () => { it('maps to exit 64 and prefixes the reason', () => { const r = usageError('no agent command given') expect(r.exitCode).toBe(64) - expect(r.message).toBe('thunderbolt-acp-bridge: no agent command given') + expect(r.message).toBe('thunderbolt-stdio-bridge: no agent command given') }) }) @@ -72,6 +73,27 @@ describe('serverError', () => { }) }) +describe('tunnelError', () => { + it('maps a missing cloudflared (ENOENT) to an install hint + exit 70', () => { + const r = tunnelError({ code: 'ENOENT' }) + expect(r.exitCode).toBe(70) + expect(r.message).toContain('cloudflared not found') + expect(r.message).toContain('install it') + }) + + it('maps an abnormal cloudflared exit to exit 69 with the reason', () => { + const r = tunnelError({ reason: 'exited early (code 1)' }) + expect(r.exitCode).toBe(69) + expect(r.message).toContain('exited early (code 1)') + }) + + it('falls back for an unknown tunnel error code', () => { + const r = tunnelError({ code: 'EPIPE' }) + expect(r.exitCode).toBe(69) + expect(r.message).toContain('EPIPE') + }) +}) + describe('earlyExitError', () => { it('reports the exit code path + exit 69', () => { const r = earlyExitError({ code: 1, signal: null, cmd0: 'agent' }) diff --git a/thunderbolt-acp-bridge/src/log.js b/thunderbolt-stdio-bridge/src/log.js similarity index 85% rename from thunderbolt-acp-bridge/src/log.js rename to thunderbolt-stdio-bridge/src/log.js index 1985b01b5..93fd2593d 100644 --- a/thunderbolt-acp-bridge/src/log.js +++ b/thunderbolt-stdio-bridge/src/log.js @@ -3,7 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ /** - * PII-safe logging for thunderbolt-acp-bridge. + * PII-safe logging for thunderbolt-stdio-bridge. * * The cardinal rule: log objects are built from an ALLOWLIST of extracted * scalars. The raw ACP frame is NEVER handed to the logger, so prompt text, @@ -14,9 +14,12 @@ * id, byteSize, status, errorCode, lifecycle, closeCode, origin (sanitized). */ -/** Known ACP method names. Anything else is collapsed to 'other' so a method - * string (which is structural, not content) can't smuggle data into a log. */ +/** Known ACP + MCP method names. Anything else is collapsed to 'other' so a + * method string (which is structural, not content) can't smuggle data into a + * log. Notification methods (e.g. notifications/message) collapse to 'other' + * via the `notifications/` family below. */ const KNOWN_METHODS = new Set([ + // ACP 'initialize', 'authenticate', 'session/new', @@ -32,16 +35,39 @@ const KNOWN_METHODS = new Set([ 'terminal/release', 'terminal/wait_for_exit', 'terminal/kill', + // MCP (initialize is shared with ACP above) + 'ping', + 'tools/list', + 'tools/call', + 'resources/list', + 'resources/read', + 'resources/templates/list', + 'resources/subscribe', + 'resources/unsubscribe', + 'prompts/list', + 'prompts/get', + 'completion/complete', + 'logging/setLevel', + 'roots/list', + 'sampling/createMessage', + 'elicitation/create', ]) /** - * Coerce an arbitrary method string to the known enum or 'other'. + * Coerce an arbitrary method string to the known enum, the structural + * `notifications/*` label, or 'other' — so a method string (attacker-influenced + * on the MCP face) can never smuggle content into a log line. Shared by both faces. * @param {unknown} method * @returns {string | undefined} */ -const safeMethod = (method) => { +export const safeMethod = (method) => { if (typeof method !== 'string') return undefined - return KNOWN_METHODS.has(method) ? method : 'other' + if (KNOWN_METHODS.has(method)) return method + // The notifications/* family is structural (spec-defined) and unbounded only + // in its suffix; collapse to a single safe label rather than 'other' so logs + // stay readable without copying an arbitrary suffix into the line. + if (method.startsWith('notifications/')) return 'notifications/*' + return 'other' } /** Max length of a string id before it's truncated. A JSON-RPC id is meant to @@ -66,10 +92,11 @@ const safeId = (id) => { /** * Classify a parsed JSON-RPC object into a kind without reading its payload. + * Shared by the ACP log extractor and the MCP face's PII-safe event extractor. * @param {Record} obj * @returns {'request' | 'response' | 'notification' | 'error'} */ -const classifyKind = (obj) => { +export const classifyKind = (obj) => { if ('error' in obj) return 'error' if ('method' in obj) return 'id' in obj ? 'request' : 'notification' return 'response' @@ -121,7 +148,9 @@ export const extractLogEvent = ({ direction, line }) => { if (kind === 'error') { const errorObj = obj.error const errorCode = - errorObj && typeof errorObj === 'object' && typeof (/** @type {Record} */ (errorObj).code) === 'number' + errorObj && + typeof errorObj === 'object' && + typeof (/** @type {Record} */ (errorObj).code) === 'number' ? /** @type {number} */ (/** @type {Record} */ (errorObj).code) : undefined return { ...event, status: 'error', errorCode } diff --git a/thunderbolt-acp-bridge/src/log.test.js b/thunderbolt-stdio-bridge/src/log.test.js similarity index 100% rename from thunderbolt-acp-bridge/src/log.test.js rename to thunderbolt-stdio-bridge/src/log.test.js diff --git a/thunderbolt-stdio-bridge/src/mcp-server.integration.test.js b/thunderbolt-stdio-bridge/src/mcp-server.integration.test.js new file mode 100644 index 000000000..e35d31594 --- /dev/null +++ b/thunderbolt-stdio-bridge/src/mcp-server.integration.test.js @@ -0,0 +1,104 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * End-to-end MCP face test: spawn a REAL stdio MCP server + * (@modelcontextprotocol/server-everything), bridge it through the real + * `startMcpFace` (real node:http + real SDK transport), and drive it with the + * OFFICIAL StreamableHTTPClientTransport. This proves the bare-adapter contract + * (session minting, id-correlation, content negotiation) against a real client. + * + * Offline-tolerant: if the MCP server can't be spawned (no network, package not + * cached), the suite skips with a clear message rather than failing CI. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'bun:test' +import { spawn } from 'node:child_process' +import { createServer } from 'node:http' +import { createInterface } from 'node:readline' +import { startMcpFace, newSessionId } from './mcp-server.js' +import { createLogger } from './log.js' + +const quietLogger = () => createLogger({ stream: { write: () => {} } }) + +/** Probe whether the real MCP server can start; resolves true within `ms`. */ +const canSpawnServerEverything = (ms = 8000) => + new Promise((resolve) => { + const child = spawn('npx', ['-y', '@modelcontextprotocol/server-everything'], { + stdio: ['pipe', 'pipe', 'ignore'], + }) + const done = (ok) => { + child.removeAllListeners() + child.kill('SIGKILL') + resolve(ok) + } + child.on('error', () => done(false)) + // It prints a startup line to stderr (ignored) but stays alive; if it + // survives a beat without erroring, treat it as available. + const timer = setTimeout(() => done(true), 1500) + child.on('exit', () => { + clearTimeout(timer) + done(false) + }) + setTimeout(() => done(false), ms) + }) + +const available = await canSpawnServerEverything() + +const suite = available ? describe : describe.skip +if (!available) { + // eslint-disable-next-line no-console + console.warn('[mcp integration] skipped — @modelcontextprotocol/server-everything could not be spawned (offline?)') +} + +suite('MCP face — real server-everything via official client', () => { + let child + let close + let port + let Client + let StreamableHTTPClientTransport + let StreamableHTTPServerTransport + + beforeAll(async () => { + ;({ Client } = await import('@modelcontextprotocol/sdk/client/index.js')) + ;({ StreamableHTTPClientTransport } = await import('@modelcontextprotocol/sdk/client/streamableHttp.js')) + ;({ StreamableHTTPServerTransport } = await import('@modelcontextprotocol/sdk/server/streamableHttp.js')) + + child = spawn('npx', ['-y', '@modelcontextprotocol/server-everything'], { stdio: ['pipe', 'pipe', 'ignore'] }) + const lines = createInterface({ input: child.stdout }) + + ;({ port, close } = await startMcpFace( + { child, lines, host: '127.0.0.1', port: 0, logger: quietLogger() }, + { + createHttpServer: (handler) => createServer(handler), + createTransport: () => + new StreamableHTTPServerTransport({ sessionIdGenerator: newSessionId, enableJsonResponse: true }), + }, + )) + }) + + afterAll(() => { + close?.() + child?.kill('SIGKILL') + }) + + const connectClient = async () => { + const client = new Client({ name: 'integration-test', version: '1.0.0' }) + const transport = new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${port}/mcp`)) + await client.connect(transport) + return client + } + + it('initialize + tools/list + tools/call round-trips through the bridge', async () => { + const client = await connectClient() + const tools = await client.listTools() + expect(tools.tools.length).toBeGreaterThan(0) + expect(tools.tools.map((t) => t.name)).toContain('echo') + + const result = await client.callTool({ name: 'echo', arguments: { message: 'thunderbolt' } }) + expect(JSON.stringify(result.content)).toContain('thunderbolt') + + await client.close() + }, 20000) +}) diff --git a/thunderbolt-stdio-bridge/src/mcp-server.js b/thunderbolt-stdio-bridge/src/mcp-server.js new file mode 100644 index 000000000..eed6ac6ed --- /dev/null +++ b/thunderbolt-stdio-bridge/src/mcp-server.js @@ -0,0 +1,405 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * MCP Streamable HTTP face for thunderbolt-stdio-bridge. + * + * Unlike the ACP/WebSocket face (a stateless byte relay), MCP is a STATEFUL + * transport: sessions, id-correlation, content negotiation, and SSE routing all + * matter. We do NOT hand-roll any of that — the official MCP SDK's + * `StreamableHTTPServerTransport` is driven as a BARE TRANSPORT ADAPTER (no + * semantic McpServer): + * + * client HTTP --handleRequest--> transport --onmessage--> child stdin + * child stdout --line--> transport.send --SSE/JSON--> client HTTP + * + * The SDK owns Mcp-Session-Id minting, POST→json correlation by JSON-RPC id, + * the GET SSE stream for server-initiated traffic, and 202 for notification-only + * POSTs. We own the security envelope (Origin allowlist, CORS, body cap, bearer) + * and the deterministic teardown when the child dies with requests pending. + * + * Single MCP session per child (one transport instance). A loopback bridge is a + * 1:1 user→agent pipe, so a second session is neither needed nor offered. + * + * Dependencies (http server factory, transport factory, child, line reader, + * clock) are injected so the whole face is exercisable with fakes — no real + * sockets in unit tests. + */ + +import { randomUUID, timingSafeEqual } from 'node:crypto' + +import { isOriginAllowed, sanitizeOrigin, defaultAllowedOrigins, classifyKind, safeMethod } from './log.js' +import { parseRpcObject } from './relay.js' +import { resolvePort } from './util.js' + +/** Cap on a single request body. MCP messages are small JSON-RPC frames; a + * multi-MB POST to a localhost agent bridge is never legitimate and is a cheap + * memory-exhaustion vector, so reject it before buffering. */ +const MAX_BODY_BYTES = 4 * 1024 * 1024 + +/** JSON-RPC error code for an internal server condition (child gone). */ +const JSONRPC_INTERNAL_ERROR = -32603 + +const CORS_ALLOW_METHODS = 'POST, GET, OPTIONS, DELETE' +const CORS_ALLOW_HEADERS = 'Content-Type, Authorization, Mcp-Session-Id, MCP-Protocol-Version' + +/** + * Start the MCP Streamable HTTP face. Resolves once the HTTP server is listening + * AND the child has survived the grace window (mirroring the ACP face), with the + * resolved port; the caller prints the banner. + * + * @param {object} cfg + * @param {import('node:child_process').ChildProcess} cfg.child - the spawned stdio MCP server + * @param {import('node:events').EventEmitter} cfg.lines - line reader over child.stdout (emits 'line') + * @param {string} cfg.host + * @param {number} cfg.port - 0 = ephemeral + * @param {string[]} [cfg.allowOrigins] - extra Origins beyond the Thunderbolt defaults + * @param {boolean} [cfg.allowAnyOrigin] - disable the Origin check entirely (loud escape hatch) + * @param {string | null} [cfg.requiredBearer] - when set, every /mcp request must carry `Authorization: Bearer ` + * @param {ReturnType} cfg.logger + * @param {object} deps + * @param {() => { listen: Function, on: Function, address: Function, close: Function }} deps.createHttpServer - factory taking the request handler + * @param {() => McpTransport} deps.createTransport - bare StreamableHTTP transport factory + * @returns {Promise<{ port: number, close: () => void }>} + */ +export const startMcpFace = (cfg, deps) => { + const { child, lines, host, port, allowOrigins = [], allowAnyOrigin = false, requiredBearer = null, logger } = cfg + const { createHttpServer, createTransport } = deps + + const allowlist = [...defaultAllowedOrigins, ...allowOrigins] + const transport = createTransport() + // Streamable HTTP's start() is a no-op (connections are per-request), but the + // Transport contract requires it before handleRequest — call it for spec + // correctness and forward-compat. + transport.start?.() + + // --- bare adapter wiring: transport <-> child stdio ---------------------- + // transport message (client→server JSON-RPC) → child stdin as one ndjson line. + transport.onmessage = (message) => { + child.stdin.write(`${JSON.stringify(message)}\n`) + logger.debug(extractMcpEvent({ direction: 'client->agent', message })) + } + transport.onerror = () => { + // The transport surfaces protocol-shape errors (bad Accept, unsupported + // protocol version, etc.). The SDK's message can echo attacker-controlled + // header text, so log ONLY the fixed lifecycle label — never the raw message. + logger.warn({ lifecycle: 'mcp-transport-error' }) + } + + // child stdout line → transport.send. The SDK correlates a response to its + // pending POST by JSON-RPC id, or routes a server-initiated request/ + // notification (unmatched id / no id) to the GET SSE stream. + lines.on('line', (rawLine) => { + const line = rawLine.replace(/\r$/, '') + const message = parseRpcObject(line) + if (message === null) { + // A non-JSON / non-object stdout line (agent log noise that escaped the + // 'inherit' stderr): drop it. Log only its byte length — never the text. + logger.warn({ lifecycle: 'mcp-dropped-non-rpc', byteSize: Buffer.byteLength(line) }) + return + } + // send() rejects when the child answers an id with no open stream (a late or + // out-of-order response after the client gave up). That's expected churn, + // not a crash — swallow it to a debug lifecycle line. + transport.send(message).then( + () => logger.debug(extractMcpEvent({ direction: 'agent->client', message })), + // The rejection reason can contain a raw JSON-RPC id — log only the label. + () => logger.debug({ lifecycle: 'mcp-send-unmatched' }), + ) + }) + + // Responses delegated to the SDK transport but not yet answered. The SDK does + // NOT resolve a pending JSON-response POST when the transport is closed, so on + // child death we must end these ourselves — otherwise an in-flight client hangs + // until its own timeout instead of getting the promised deterministic error. + /** @type {Set} */ + const openResponses = new Set() + + // --- deterministic teardown on child death -------------------------------- + // If the child exits, close the transport AND fail any still-open delegated + // response with a 503 (or end an already-streaming SSE), so in-flight clients + // get a clean, immediate failure rather than a hang. The shared lifecycle + // (server.js / cli.js) owns the actual process exit. + child.on('exit', () => { + logger.info({ lifecycle: 'mcp-child-exited' }) + transport.close() + for (const res of openResponses) { + if (res.writableEnded) continue + if (res.headersSent) res.end() + else endJson(res, 503, jsonRpcError(JSONRPC_INTERNAL_ERROR, 'Agent process exited')) + } + openResponses.clear() + }) + + return new Promise((resolve, reject) => { + const server = createHttpServer((req, res) => { + const ctx = { req, res, transport, allowlist, allowAnyOrigin, requiredBearer, child, logger, openResponses } + handleRequest(ctx).catch((err) => { + // A handler-level failure must never crash the process; answer 500. + logger.error({ lifecycle: 'mcp-handler-error', detail: err?.message }) + if (!res.headersSent) endJson(res, 500, jsonRpcError(JSONRPC_INTERNAL_ERROR, 'Internal bridge error')) + }) + }) + + server.on('error', (err) => { + logger.error({ lifecycle: 'mcp-server-error', errorCode: err?.code }) + reject(Object.assign(new Error(`MCP HTTP server error (${err?.code ?? 'unknown'})`), { exitCode: 69 })) + }) + + server.listen(port, host, () => { + const resolvedPort = resolvePort(server, port) + logger.info({ lifecycle: 'mcp-listening', port: resolvedPort }) + // close() is the face teardown the supervisor calls on shutdown: drop the + // http listener AND end the SDK transport (closes every open POST/SSE + // stream). transport.close is idempotent, so the child-exit handler above + // closing it too is harmless. + resolve({ + port: resolvedPort, + close: () => { + server.close() + transport.close() + }, + }) + }) + }) +} + +/** + * Handle one inbound HTTP request to the MCP face: enforce the security envelope + * (CORS preflight, Origin, bearer, body cap), then delegate to the SDK transport. + * + * @param {object} args + * @param {import('node:http').IncomingMessage} args.req + * @param {import('node:http').ServerResponse} args.res + * @param {McpTransport} args.transport + * @param {readonly string[]} args.allowlist + * @param {boolean} args.allowAnyOrigin + * @param {string | null} args.requiredBearer + * @param {import('node:child_process').ChildProcess} args.child + * @param {ReturnType} args.logger + * @param {Set} args.openResponses - tracks delegated responses for child-exit failure + */ +const handleRequest = async ({ + req, + res, + transport, + allowlist, + allowAnyOrigin, + requiredBearer, + child, + logger, + openResponses, +}) => { + const rawOrigin = req.headers.origin + const origin = sanitizeOrigin(rawOrigin) + const originOk = allowAnyOrigin || isOriginAllowed(rawOrigin, allowlist) + + // CORS preflight: answer it ourselves (the SDK transport doesn't). A preflight + // can't carry credentials (Fetch spec), so it precedes the bearer gate; the + // actual request still passes through every check below. + if (req.method === 'OPTIONS') { + setCors(res, rawOrigin, originOk) + res.writeHead(204) + res.end() + return + } + + // Origin allowlist — MCP spec REQUIRES this (DNS-rebinding defense). A present + // Origin must match; a missing Origin is allowed (native/Tauri webviews send + // none, and over a tunnel the bearer below is the real gate). + if (!originOk) { + logger.warn({ lifecycle: 'mcp-origin-rejected', origin }) + endJson(res, 403, jsonRpcError(JSONRPC_INTERNAL_ERROR, 'Origin not allowed')) + return + } + + // Bearer gate (set by the tunnel phase; unset on plain localhost). It precedes + // EVERY route — including the health probe — so a public tunnel never exposes + // even liveness without the secret. + if (requiredBearer !== null && !hasValidBearer(req, requiredBearer)) { + logger.warn({ lifecycle: 'mcp-unauthorized' }) + setCors(res, rawOrigin, originOk) + res.setHeader('WWW-Authenticate', 'Bearer') + endJson(res, 401, jsonRpcError(JSONRPC_INTERNAL_ERROR, 'Unauthorized')) + return + } + + setCors(res, rawOrigin, originOk) + + // Health probe (now behind origin + bearer). Cheap liveness for the banner/operator. + if (req.method === 'GET' && isHealthPath(req.url)) { + endJson(res, 200, { ok: true }) + return + } + + // Only the /mcp endpoint is served; anything else is 404 (keep the surface tight). + if (!isMcpPath(req.url)) { + endJson(res, 404, jsonRpcError(JSONRPC_INTERNAL_ERROR, 'Not found')) + return + } + + const childGone = () => child.exitCode !== null || child.signalCode !== null + + // The child has already died: answer deterministically instead of letting the + // transport hang an SSE/POST against a dead pipe. + if (childGone()) { + logger.warn({ lifecycle: 'mcp-child-gone' }) + endJson(res, 503, jsonRpcError(JSONRPC_INTERNAL_ERROR, 'Agent process exited')) + return + } + + // Track this response BEFORE reading the body so a child death at ANY point — + // including during a slow upload — fails it via the child-exit flush rather + // than hanging (the SDK won't resolve a pending JSON POST on transport.close()). + openResponses.add(res) + res.on('close', () => openResponses.delete(res)) + + const body = await readBody(req) + // The child-exit flush may have already ended this response while the body was + // uploading — never write to an ended response. + if (res.writableEnded) return + if (body === OVERSIZED) { + logger.warn({ lifecycle: 'mcp-body-too-large' }) + endJson(res, 413, jsonRpcError(JSONRPC_INTERNAL_ERROR, 'Request body too large')) + return + } + // Re-check after the await: the child may have exited during readBody before + // the flush reached this res. Don't delegate to a dead child. + if (childGone()) { + logger.warn({ lifecycle: 'mcp-child-gone' }) + endJson(res, 503, jsonRpcError(JSONRPC_INTERNAL_ERROR, 'Agent process exited')) + return + } + + // Empty body on a GET/DELETE is normal; a POST body is parsed JSON-RPC. Invalid + // JSON is handed to the SDK as `undefined` so it returns the spec error. + const parsed = body.length === 0 ? undefined : parseRpcObject(body) + await transport.handleRequest(req, res, parsed ?? undefined) +} + +/** Sentinel distinguishing an oversized body from a legitimate empty body. */ +const OVERSIZED = Symbol('oversized') + +/** + * Buffer the request body with a hard size cap. Returns the body string, or the + * OVERSIZED sentinel if the declared/streamed size exceeds the cap. + * @param {import('node:http').IncomingMessage} req + * @returns {Promise} + */ +const readBody = (req) => { + const declared = Number(req.headers['content-length']) + if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) return Promise.resolve(OVERSIZED) + + return new Promise((resolve, reject) => { + const chunks = [] + let size = 0 + req.on('data', (chunk) => { + size += chunk.length + if (size > MAX_BODY_BYTES) { + req.destroy() + resolve(OVERSIZED) + return + } + chunks.push(chunk) + }) + req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))) + req.on('error', reject) + }) +} + +/** + * Set CORS headers. Echo the Origin only for an allowed cross-origin request so + * the browser can read the response (and the Mcp-Session-Id it must reuse). + * @param {import('node:http').ServerResponse} res + * @param {unknown} rawOrigin + * @param {boolean} originOk + */ +const setCors = (res, rawOrigin, originOk) => { + if (typeof rawOrigin === 'string' && rawOrigin.length > 0 && originOk) { + res.setHeader('Access-Control-Allow-Origin', rawOrigin) + res.setHeader('Vary', 'Origin') + } + res.setHeader('Access-Control-Allow-Methods', CORS_ALLOW_METHODS) + res.setHeader('Access-Control-Allow-Headers', CORS_ALLOW_HEADERS) + // The browser client must read the session id cross-origin to reuse it. + res.setHeader('Access-Control-Expose-Headers', 'Mcp-Session-Id') +} + +/** + * Constant-time bearer check against the configured secret. Uses + * `timingSafeEqual` so a network attacker can't recover the secret via the + * per-byte short-circuit of `===` (a prefix-timing oracle over the tunnel). The + * length guard avoids `timingSafeEqual`'s throw on unequal lengths — the secret + * length (base64url of 32 bytes) is fixed and not itself sensitive. + * @param {import('node:http').IncomingMessage} req + * @param {string} secret + * @returns {boolean} + */ +const hasValidBearer = (req, secret) => { + const header = req.headers.authorization + const prefix = 'Bearer ' + if (typeof header !== 'string' || !header.startsWith(prefix)) return false + const provided = Buffer.from(header.slice(prefix.length)) + const expected = Buffer.from(secret) + return provided.length === expected.length && timingSafeEqual(provided, expected) +} + +/** + * Build a JSON-RPC error envelope (id null — these are transport-level failures + * not tied to a specific client request). + * @param {number} code + * @param {string} message + * @returns {{ jsonrpc: '2.0', error: { code: number, message: string }, id: null }} + */ +const jsonRpcError = (code, message) => ({ jsonrpc: '2.0', error: { code, message }, id: null }) + +/** + * Write a JSON response and end. No-op if the response was already sent. + * @param {import('node:http').ServerResponse} res + * @param {number} status + * @param {unknown} body + */ +const endJson = (res, status, body) => { + res.writeHead(status, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify(body)) +} + +/** + * Extract a PII-safe MCP log event: only the JSON-RPC shape (kind/method/id), + * never params/result. Reuses the structural fields the ACP logger uses. + * @param {object} args + * @param {'client->agent' | 'agent->client'} args.direction + * @param {Record} args.message + * @returns {{ direction: string, kind: string, method?: string, hasId: boolean }} + */ +const extractMcpEvent = ({ direction, message }) => ({ + direction, + kind: classifyKind(message), + // safeMethod collapses unknown/attacker-supplied methods to 'other' (and the + // notifications/* family to one label) so a method string can't smuggle content. + method: safeMethod(message.method), + hasId: 'id' in message, +}) + +/** The request path without query, defaulting `/` for a missing url. */ +const pathOf = (url) => (typeof url === 'string' ? url.split('?')[0] : '/') + +/** Whether a URL path is the root health probe. */ +const isHealthPath = (url) => pathOf(url) === '/' + +/** Whether a URL path is the MCP endpoint. */ +const isMcpPath = (url) => pathOf(url) === '/mcp' + +/** Mint a fresh session id. Exported for the CLI wiring to pass into the SDK. */ +export const newSessionId = () => randomUUID() + +/** + * @typedef {object} McpTransport + * @property {(message: unknown) => void} [onmessage] + * @property {(err: Error) => void} [onerror] + * @property {(message: unknown, options?: object) => Promise} send + * @property {(req: unknown, res: unknown, body?: unknown) => Promise} handleRequest + * @property {() => Promise | void} [start] + * @property {() => Promise | void} close + */ diff --git a/thunderbolt-stdio-bridge/src/mcp-server.test.js b/thunderbolt-stdio-bridge/src/mcp-server.test.js new file mode 100644 index 000000000..37686774d --- /dev/null +++ b/thunderbolt-stdio-bridge/src/mcp-server.test.js @@ -0,0 +1,403 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { describe, it, expect } from 'bun:test' +import { EventEmitter } from 'node:events' +import { startMcpFace } from './mcp-server.js' +import { createLogger } from './log.js' + +const ALLOWED_ORIGIN = 'https://app.thunderbolt.io' + +/** A fake stdio child: records stdin writes, emits exit. */ +const makeFakeChild = () => { + const child = new EventEmitter() + child.exitCode = null + child.signalCode = null + child.stdin = { + written: [], + write(chunk) { + this.written.push(chunk) + return true + }, + } + child.kill = () => true + return child +} + +/** A fake line reader: emits 'line' on demand. */ +const makeFakeLines = () => new EventEmitter() + +/** + * A fake StreamableHTTP transport that captures the adapter wiring without any + * real HTTP. It records onmessage/send and lets the test drive handleRequest + * outcomes through a recorder. + */ +const makeFakeTransport = () => { + const transport = { + started: false, + closed: false, + sent: [], + handled: [], + sendBehavior: () => Promise.resolve(), + start() { + this.started = true + }, + send(message) { + this.sent.push(message) + return this.sendBehavior(message) + }, + handleRequest(req, res, body) { + this.handled.push({ method: req.method, body }) + // Mimic the SDK answering a POST with 200 + JSON so the test can assert + // delegation happened. Real correlation is covered by the integration test. + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end('{"jsonrpc":"2.0","id":1,"result":{}}') + return Promise.resolve() + }, + close() { + this.closed = true + }, + } + return transport +} + +/** A fake http.Server the test drives (listen → callback, capture handler). */ +const makeFakeHttpServer = (port) => { + const server = new EventEmitter() + server.handler = null + server.closed = false + server.address = () => ({ port }) + server.listen = (_port, _host, cb) => { + cb() + return server + } + server.close = () => { + server.closed = true + } + return server +} + +/** A fake ServerResponse capturing status/headers/body. Like a real + * ServerResponse it is an EventEmitter (has `on`), exposes `writableEnded`, and + * emits 'close' when ended — so the face's open-response tracking works. */ +const makeFakeRes = () => { + const res = new EventEmitter() + res.statusCode = null + res.headers = {} + res.body = '' + res.headersSent = false + res.writableEnded = false + res.setHeader = (k, v) => { + res.headers[k.toLowerCase()] = v + } + res.writeHead = (status, headers) => { + res.statusCode = status + res.headersSent = true + if (headers) for (const [k, v] of Object.entries(headers)) res.headers[k.toLowerCase()] = v + return res + } + res.end = (chunk) => { + if (chunk) res.body += chunk + res.ended = true + res.writableEnded = true + res.emit('close') + } + return res +} + +/** A fake IncomingMessage: a readable stream of one body chunk + headers. */ +const makeFakeReq = ({ method = 'POST', url = '/mcp', headers = {}, body = '' } = {}) => { + const req = new EventEmitter() + req.method = method + req.url = url + req.headers = headers + req.destroy = () => { + req.destroyed = true + } + // Emit the body asynchronously so handlers attach listeners first. + queueMicrotask(() => { + if (body.length > 0) req.emit('data', Buffer.from(body)) + req.emit('end') + }) + return req +} + +const quietLogger = () => createLogger({ stream: { write: () => {} } }) + +/** Start the face with fakes and return the moving parts + a request driver. */ +const startFace = async ({ + cfg = {}, + child = makeFakeChild(), + lines = makeFakeLines(), + transport = makeFakeTransport(), +} = {}) => { + const server = makeFakeHttpServer(7000) + const { port, close } = await startMcpFace( + { child, lines, host: '127.0.0.1', port: 0, logger: quietLogger(), ...cfg }, + { + createHttpServer: (handler) => { + server.handler = handler + return server + }, + createTransport: () => transport, + }, + ) + + /** Drive one request through the captured handler; resolves when res ends. */ + const request = async (reqOpts) => { + const req = makeFakeReq(reqOpts) + const res = makeFakeRes() + server.handler(req, res) + await waitFor(() => res.ended === true) + return res + } + + return { child, lines, transport, server, port, close, request } +} + +const waitFor = async (pred, timeoutMs = 1000) => { + const deadline = Date.now() + timeoutMs + while (!pred()) { + if (Date.now() > deadline) throw new Error('waitFor timed out') + await new Promise((r) => setTimeout(r, 1)) + } +} + +describe('startMcpFace — bare adapter wiring', () => { + it('starts the transport and resolves with the listening port', async () => { + const { transport, port } = await startFace() + expect(transport.started).toBe(true) + expect(port).toBe(7000) + }) + + it('transport.onmessage writes the JSON-RPC message to child stdin as one ndjson line', async () => { + const { child, transport } = await startFace() + transport.onmessage({ jsonrpc: '2.0', id: 1, method: 'tools/list' }) + expect(child.stdin.written).toEqual(['{"jsonrpc":"2.0","id":1,"method":"tools/list"}\n']) + }) + + it('child stdout line is parsed and forwarded to transport.send', async () => { + const { lines, transport } = await startFace() + lines.emit('line', '{"jsonrpc":"2.0","id":1,"result":{"tools":[]}}') + await waitFor(() => transport.sent.length === 1) + expect(transport.sent[0]).toEqual({ jsonrpc: '2.0', id: 1, result: { tools: [] } }) + }) + + it('handles out-of-order child responses (each forwarded independently)', async () => { + const { lines, transport } = await startFace() + lines.emit('line', '{"jsonrpc":"2.0","id":2,"result":{}}') + lines.emit('line', '{"jsonrpc":"2.0","id":1,"result":{}}') + await waitFor(() => transport.sent.length === 2) + expect(transport.sent.map((m) => m.id)).toEqual([2, 1]) + }) + + it('an unmatched-response send rejection does NOT crash (swallowed to debug)', async () => { + const transport = makeFakeTransport() + transport.sendBehavior = () => Promise.reject(new Error('No connection established for request ID: 99')) + const { lines } = await startFace({ transport }) + // Should not throw / reject anywhere observable. + lines.emit('line', '{"jsonrpc":"2.0","id":99,"result":{}}') + await new Promise((r) => setTimeout(r, 5)) + expect(true).toBe(true) + }) + + it('drops an invalid-JSON stdout line without forwarding', async () => { + const { lines, transport } = await startFace() + lines.emit('line', 'not json at all') + lines.emit('line', '42') // bare scalar — not a JSON-RPC object + await new Promise((r) => setTimeout(r, 5)) + expect(transport.sent).toEqual([]) + }) + + it('closes the transport when the child exits', async () => { + const child = makeFakeChild() + const { transport } = await startFace({ child }) + child.exitCode = 0 + child.emit('exit', 0, null) + expect(transport.closed).toBe(true) + }) +}) + +describe('startMcpFace — POST delegation + 202 path', () => { + it('delegates a POST with a parsed body to transport.handleRequest', async () => { + const { transport, request } = await startFace() + const res = await request({ + method: 'POST', + headers: { origin: ALLOWED_ORIGIN, 'content-type': 'application/json' }, + body: '{"jsonrpc":"2.0","id":1,"method":"ping"}', + }) + expect(res.statusCode).toBe(200) + expect(transport.handled).toHaveLength(1) + expect(transport.handled[0].body).toEqual({ jsonrpc: '2.0', id: 1, method: 'ping' }) + }) + + it('passes undefined body for invalid JSON so the SDK returns its own error', async () => { + const { transport, request } = await startFace() + await request({ method: 'POST', headers: { origin: ALLOWED_ORIGIN }, body: '{not json' }) + expect(transport.handled[0].body).toBeUndefined() + }) +}) + +describe('startMcpFace — child gone', () => { + it('returns a deterministic 503 + JSON-RPC error when the child already exited', async () => { + const child = makeFakeChild() + child.exitCode = 1 + const { request } = await startFace({ child }) + const res = await request({ method: 'POST', headers: { origin: ALLOWED_ORIGIN }, body: '{"id":1}' }) + expect(res.statusCode).toBe(503) + expect(JSON.parse(res.body)).toMatchObject({ jsonrpc: '2.0', error: { message: 'Agent process exited' } }) + }) + + it('fails an IN-FLIGHT delegated request with 503 when the child exits mid-flight (no hang)', async () => { + // A transport that delegates but never answers — the request is in flight. + const inflight = makeFakeTransport() + inflight.handleRequest = (req) => { + inflight.handled.push({ method: req.method }) + return Promise.resolve() // leaves res open, mimicking a pending JSON POST + } + const child = makeFakeChild() + const { server } = await startFace({ child, transport: inflight }) + const req = makeFakeReq({ method: 'POST', url: '/mcp', headers: { origin: ALLOWED_ORIGIN }, body: '{"id":1}' }) + const res = makeFakeRes() + server.handler(req, res) + await waitFor(() => inflight.handled.length === 1) // delegated; res still open + expect(res.ended).toBeUndefined() + // Child dies while the request is pending → deterministic 503, not a hang. + child.exitCode = 1 + child.emit('exit', 1, null) + await waitFor(() => res.ended === true) + expect(res.statusCode).toBe(503) + expect(JSON.parse(res.body)).toMatchObject({ jsonrpc: '2.0', error: { message: 'Agent process exited' } }) + }) + + it('fails a request whose body is STILL UPLOADING when the child exits (tracked before readBody)', async () => { + const inflight = makeFakeTransport() + const child = makeFakeChild() + const { server } = await startFace({ child, transport: inflight }) + // A request that never finishes uploading (emits no 'end'): the handler is + // parked on `await readBody`, so it has NOT delegated yet. + const req = new EventEmitter() + req.method = 'POST' + req.url = '/mcp' + req.headers = { origin: ALLOWED_ORIGIN } + req.destroy = () => {} + const res = makeFakeRes() + server.handler(req, res) + await new Promise((r) => setTimeout(r, 5)) // let the handler reach readBody (res now tracked) + expect(res.ended).toBeUndefined() + expect(inflight.handled.length).toBe(0) // never delegated + // Child dies mid-upload → the flush must 503 the already-tracked response. + child.exitCode = 1 + child.emit('exit', 1, null) + await waitFor(() => res.ended === true) + expect(res.statusCode).toBe(503) + req.emit('end') // let the parked readBody resolve; it bails on res.writableEnded + }) +}) + +describe('startMcpFace — Origin allowlist (DNS-rebinding defense)', () => { + it('accepts an allowed Thunderbolt origin', async () => { + const { request } = await startFace() + const res = await request({ method: 'POST', headers: { origin: ALLOWED_ORIGIN }, body: '{"id":1}' }) + expect(res.statusCode).toBe(200) + }) + + it('rejects a disallowed origin with 403 and does NOT delegate', async () => { + const { transport, request } = await startFace() + const res = await request({ method: 'POST', headers: { origin: 'https://evil.example' }, body: '{"id":1}' }) + expect(res.statusCode).toBe(403) + expect(transport.handled).toEqual([]) + }) + + it('allows a missing origin (native/Tauri webviews send none)', async () => { + const { request } = await startFace() + const res = await request({ method: 'POST', headers: {}, body: '{"id":1}' }) + expect(res.statusCode).toBe(200) + }) + + it('--allow-any-origin accepts a junk origin', async () => { + const { request } = await startFace({ cfg: { allowAnyOrigin: true } }) + const res = await request({ method: 'POST', headers: { origin: 'https://evil.example' }, body: '{"id":1}' }) + expect(res.statusCode).toBe(200) + }) +}) + +describe('startMcpFace — CORS preflight', () => { + it('answers OPTIONS with 204 and the correct CORS headers for an allowed origin', async () => { + const { request } = await startFace() + const res = await request({ method: 'OPTIONS', headers: { origin: ALLOWED_ORIGIN } }) + expect(res.statusCode).toBe(204) + expect(res.headers['access-control-allow-origin']).toBe(ALLOWED_ORIGIN) + expect(res.headers['access-control-allow-methods']).toContain('POST') + expect(res.headers['access-control-allow-methods']).toContain('DELETE') + expect(res.headers['access-control-allow-headers']).toContain('Authorization') + expect(res.headers['access-control-allow-headers']).toContain('Mcp-Session-Id') + expect(res.headers['access-control-expose-headers']).toBe('Mcp-Session-Id') + }) + + it('does NOT echo the Origin for a disallowed origin in preflight', async () => { + const { request } = await startFace() + const res = await request({ method: 'OPTIONS', headers: { origin: 'https://evil.example' } }) + expect(res.statusCode).toBe(204) + expect(res.headers['access-control-allow-origin']).toBeUndefined() + }) +}) + +describe('startMcpFace — bearer auth', () => { + it('rejects /mcp with 401 when a bearer is required and absent', async () => { + const { transport, request } = await startFace({ cfg: { requiredBearer: 's3cret' } }) + const res = await request({ method: 'POST', headers: { origin: ALLOWED_ORIGIN }, body: '{"id":1}' }) + expect(res.statusCode).toBe(401) + expect(res.headers['www-authenticate']).toBe('Bearer') + expect(transport.handled).toEqual([]) + }) + + it('rejects with 401 on a wrong bearer', async () => { + const { request } = await startFace({ cfg: { requiredBearer: 's3cret' } }) + const res = await request({ + method: 'POST', + headers: { origin: ALLOWED_ORIGIN, authorization: 'Bearer wrong' }, + body: '{"id":1}', + }) + expect(res.statusCode).toBe(401) + }) + + it('accepts (delegates) with the correct bearer', async () => { + const { transport, request } = await startFace({ cfg: { requiredBearer: 's3cret' } }) + const res = await request({ + method: 'POST', + headers: { origin: ALLOWED_ORIGIN, authorization: 'Bearer s3cret' }, + body: '{"id":1}', + }) + expect(res.statusCode).toBe(200) + expect(transport.handled).toHaveLength(1) + }) + + it('requires NO bearer when unset (plain localhost)', async () => { + const { request } = await startFace() + const res = await request({ method: 'POST', headers: { origin: ALLOWED_ORIGIN }, body: '{"id":1}' }) + expect(res.statusCode).toBe(200) + }) +}) + +describe('startMcpFace — body cap', () => { + it('rejects an oversized body (declared Content-Length) with 413', async () => { + const { request } = await startFace() + const res = await request({ + method: 'POST', + headers: { origin: ALLOWED_ORIGIN, 'content-length': String(5 * 1024 * 1024) }, + body: '{"id":1}', + }) + expect(res.statusCode).toBe(413) + }) +}) + +describe('startMcpFace — health probe', () => { + it('answers GET / with {ok:true}', async () => { + const { request } = await startFace() + const res = await request({ method: 'GET', url: '/', headers: { origin: ALLOWED_ORIGIN } }) + expect(res.statusCode).toBe(200) + expect(JSON.parse(res.body)).toEqual({ ok: true }) + }) +}) diff --git a/thunderbolt-acp-bridge/src/relay.js b/thunderbolt-stdio-bridge/src/relay.js similarity index 85% rename from thunderbolt-acp-bridge/src/relay.js rename to thunderbolt-stdio-bridge/src/relay.js index a495dee58..73cae22be 100644 --- a/thunderbolt-acp-bridge/src/relay.js +++ b/thunderbolt-stdio-bridge/src/relay.js @@ -27,25 +27,32 @@ */ /** - * Determine whether a stdout line is a forwardable JSON-RPC frame. - * - * A real ACP frame is ALWAYS a JSON object. Bare scalars/arrays (`123`, `"x"`, - * `true`, `null`, `[]`) are never valid JSON-RPC, so they're junk — drop them - * rather than forward them to Thunderbolt's unguarded parse. + * Parse a line as a JSON-RPC object, returning the object or `null` for an empty + * line, invalid JSON, or a non-object (a bare scalar/array `123`/`"x"`/`[]` is + * never a valid JSON-RPC frame). Shared by the ACP ws relay and the MCP face. * @param {string} line - * @returns {boolean} + * @returns {Record | null} */ -const isForwardableJson = (line) => { +export const parseRpcObject = (line) => { const trimmed = line.trim() - if (trimmed.length === 0) return false + if (trimmed.length === 0) return null try { const parsed = JSON.parse(trimmed) - return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed) + return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null } catch { - return false + return null } } +/** + * Whether a stdout line is a forwardable JSON-RPC frame: a real ACP frame is + * ALWAYS a JSON object, so drop bare scalars/arrays rather than forward them to + * Thunderbolt's unguarded parse. + * @param {string} line + * @returns {boolean} + */ +const isForwardableJson = (line) => parseRpcObject(line) !== null + /** * Wire the agent→ws direction: for each line emitted by the readline interface, * forward it as one ws frame if it is non-empty valid JSON, otherwise drop it. diff --git a/thunderbolt-acp-bridge/src/relay.test.js b/thunderbolt-stdio-bridge/src/relay.test.js similarity index 100% rename from thunderbolt-acp-bridge/src/relay.test.js rename to thunderbolt-stdio-bridge/src/relay.test.js diff --git a/thunderbolt-stdio-bridge/src/server.js b/thunderbolt-stdio-bridge/src/server.js new file mode 100644 index 000000000..8e8add047 --- /dev/null +++ b/thunderbolt-stdio-bridge/src/server.js @@ -0,0 +1,211 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * ACP WebSocket face for thunderbolt-stdio-bridge: stand up a localhost WebSocket + * server and relay it to the shared stdio child through the pure relay. + * + * The child lifecycle (spawn, line reader, spawn/exit handling, grace window, + * never-orphan SIGKILL, signal stop with escalation) lives in the shared + * supervisor (./child.js). This module owns ONLY the ws-specific concerns: + * - WebSocketServer with Origin allowlist (verifyClient + defense-in-depth); + * - newest-wins single active socket (a new connection supersedes the old); + * - backpressure: pause the agent→ws relay while no client is connected so an + * in-flight response is held by OS pipe backpressure instead of dropped; + * - closeWebSocket as the supervisor's closeFace seam. + * + * Dependencies (spawn, WebSocketServer, readline factory, exit) are injected so + * the face can be exercised with fakes. + */ + +import { serverError } from './errors.js' +import { superviseChild } from './child.js' +import { wireAgentToWs, handleWsMessage } from './relay.js' +import { extractLogEvent, sanitizeOrigin, isOriginAllowed, defaultAllowedOrigins } from './log.js' +import { resolvePort, formatHostForUrl } from './util.js' + +const WS_OPEN = 1 +const WS_CLOSE_NORMAL = 1000 +const WS_CLOSE_GOING_AWAY = 1011 +const WS_CLOSE_POLICY_VIOLATION = 1008 + +/** + * Start the bridge. Resolves once the ready banner has been emitted (server + * listening + child survived grace). Rejects on a fatal startup error after + * printing an actionable message and setting the exit code. + * + * @param {object} cfg + * @param {string[]} cfg.agentCmd - [command, ...args] + * @param {string} cfg.host + * @param {number} cfg.port - 0 = ephemeral + * @param {string[]} [cfg.allowOrigins] - extra Origins to accept (beyond the Thunderbolt defaults) + * @param {boolean} [cfg.allowAnyOrigin] - disable the Origin check entirely (loud escape hatch) + * @param {ReturnType} cfg.logger + * @param {object} deps + * @param {typeof import('node:child_process').spawn} deps.spawn + * @param {new (opts: object) => import('ws').WebSocketServer} deps.WebSocketServer + * @param {(stream: NodeJS.ReadableStream) => import('node:events').EventEmitter} deps.createLineReader + * @param {(label: string) => void} [deps.onBanner] - prints the ready banner + * @param {(stop: (reason: string, code: number) => void) => void} [deps.onStop] - receives the stop fn synchronously (before grace resolves) + * @param {(code: number) => void} [deps.exit] - process.exit (injectable) + * @returns {Promise<{ stop: (reason: string, code: number) => void }>} + */ +export const startBridge = async (cfg, deps) => { + const { agentCmd, host, port, logger, allowOrigins = [], allowAnyOrigin = false } = cfg + const { spawn, WebSocketServer, createLineReader, onBanner, onStop, exit = process.exit } = deps + + const allowlist = [...defaultAllowedOrigins, ...allowOrigins] + + if (allowAnyOrigin) { + logger.warn({ lifecycle: 'origin-check-disabled' }) + process.stderr.write( + '\nWARNING: --allow-any-origin is set — the Origin check is OFF.\n' + + 'Any web page open in a browser on this machine can connect to the bridge\n' + + 'and drive your agent. Use this only for trusted dev/self-host setups.\n', + ) + } + + if (!isLoopbackHost(host)) { + process.stderr.write( + `\nWARNING: --host ${host} is not a loopback address — the bridge (and your\n` + + 'agent) is now reachable by other hosts on the network, not just this\n' + + 'machine. Keep the default 127.0.0.1 unless you really need remote access.\n', + ) + } + + return new Promise((resolve, reject) => { + /** @type {import('ws').WebSocketServer | null} */ + let wss = null + /** @type {import('ws').WebSocket | null} */ + let activeSocket = null + let readerPaused = false + + const closeWebSocket = (code) => { + if (activeSocket && activeSocket.readyState === WS_OPEN) activeSocket.close(code) + wss?.close() + } + + // The shared supervisor owns the child; the ws face plugs in via these seams. + const { child, lines, stop, safeExit } = superviseChild( + { agentCmd, logger }, + { + spawn, + createLineReader, + onReady: () => { + const resolvedPort = resolvePort(wss, port) + onBanner?.(`ws://${formatHostForUrl(host)}:${resolvedPort}`) + resolve({ stop }) + }, + closeFace: (reason) => closeWebSocket(reason === 'going-away' ? WS_CLOSE_GOING_AWAY : WS_CLOSE_NORMAL), + onFatalRejection: (err) => reject(err), + exit, + }, + ) + + // While no client is connected, pause the agent→ws relay so the agent's output + // (e.g. an in-flight response during a client reconnect) is held by OS pipe + // backpressure instead of dropped. Resumed on the next connection. + const clearActiveSocket = (socket) => { + if (activeSocket !== socket) return + activeSocket = null + if (!readerPaused) { + lines.pause() + readerPaused = true + } + } + + // --- agent stdout → ws (single persistent reader, reused across reconnects) --- + wireAgentToWs({ + lines, + send: (line) => { + if (activeSocket && activeSocket.readyState === WS_OPEN) activeSocket.send(line) + }, + onForward: (line) => logger.debug(extractLogEvent({ direction: 'agent->ws', line })), + // A dropped line is a raw, non-JSON stdout line that may contain content. + // Extract ONLY its byte length here — the line text is never logged. + onDrop: (line) => logger.warn({ lifecycle: 'dropped-non-json', byteSize: Buffer.byteLength(line) }), + }) + + // --- WebSocket server ----------------------------------------------------- + // verifyClient runs DURING the upgrade handshake: a disallowed Origin is + // rejected with HTTP 403 and the WebSocket is never established, so a hostile + // web page can't even briefly connect. The 'connection' handler below repeats + // the check as deterministic defense-in-depth (closing with 1008) for any + // path that bypasses verifyClient. + const verifyClient = ({ origin }) => allowAnyOrigin || isOriginAllowed(origin, allowlist) + wss = new WebSocketServer({ host, port, verifyClient }) + + wss.on('error', (err) => { + const { message, exitCode } = serverError(err, { host, port }) + logger.error({ lifecycle: 'server-error', errorCode: err.code }) + process.stderr.write(`\n${message}\n`) + reject(Object.assign(new Error(message), { exitCode })) + safeExit(exitCode) + }) + + wss.on('connection', (socket, request) => { + const rawOrigin = request?.headers?.origin + const origin = sanitizeOrigin(rawOrigin) + + // Browser WebSocket connections aren't same-origin-protected: reject any + // Origin that isn't a known Thunderbolt app origin so a random web page on + // this machine can't hijack the local agent. The origin string is PII-safe + // to log (sanitized to scheme + host). + if (!allowAnyOrigin && !isOriginAllowed(rawOrigin, allowlist)) { + logger.warn({ lifecycle: 'origin-rejected', origin }) + socket.close(WS_CLOSE_POLICY_VIOLATION) + return + } + + logger.info({ lifecycle: 'connected', origin }) + // Single-client bridge: a new connection supersedes any previous one. Assign + // the new socket first (so the old socket's 'close' handler won't null it), + // then close the old one so a superseded client can't keep injecting into the + // shared agent stdin while only the newest receives output. + const previous = activeSocket + activeSocket = socket + if (previous && previous !== socket && previous.readyState === WS_OPEN) previous.close(1000) + if (readerPaused) { + lines.resume() + readerPaused = false + } + + socket.on('message', (data) => { + // Drop messages from a socket that's been superseded by a newer connection: + // close() doesn't synchronously stop buffered 'message' events, so guard on + // identity to keep a stale client out of the shared agent stdin. + if (activeSocket !== socket) return + handleWsMessage({ + data, + write: (chunk) => child.stdin.write(chunk), + onWrite: (chunk) => logger.debug(extractLogEvent({ direction: 'ws->agent', line: chunk.replace(/\n$/, '') })), + }) + }) + socket.on('error', (err) => { + logger.warn({ lifecycle: 'socket-error', errorCode: err.code }) + clearActiveSocket(socket) + }) + socket.on('close', (closeCode) => { + clearActiveSocket(socket) + logger.info({ lifecycle: 'disconnected', closeCode }) + }) + }) + + wss.on('listening', () => { + const resolvedPort = resolvePort(wss, port) + logger.info({ lifecycle: 'listening', port: resolvedPort }) + }) + + deps.onStop?.(stop) + }) +} + +/** + * Whether a bind host is a loopback address (only reachable from this machine). + * A non-loopback host exposes the agent to other hosts on the network, which + * warrants a prominent startup warning. + * @param {string} host + * @returns {boolean} + */ +const isLoopbackHost = (host) => host === '127.0.0.1' || host === 'localhost' || host === '::1' diff --git a/thunderbolt-acp-bridge/src/server.test.js b/thunderbolt-stdio-bridge/src/server.test.js similarity index 100% rename from thunderbolt-acp-bridge/src/server.test.js rename to thunderbolt-stdio-bridge/src/server.test.js diff --git a/thunderbolt-stdio-bridge/src/tunnel.js b/thunderbolt-stdio-bridge/src/tunnel.js new file mode 100644 index 000000000..c004e6ff0 --- /dev/null +++ b/thunderbolt-stdio-bridge/src/tunnel.js @@ -0,0 +1,121 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Optional cloudflared quick-tunnel for the MCP face (MCP-only; ACP is rejected + * upstream in args.js because it carries no client auth). + * + * A quick tunnel exposes the loopback MCP server at a public + * `https://.trycloudflare.com` URL. To keep that public surface from being + * an open agent, the MCP face REQUIRES a bearer secret (generated here) on every + * request — the secret is printed to STDERR only, never embedded in the URL. + * + * `spawn` is injected so the whole module is exercisable with a fake cloudflared + * (no network, no binary) in unit tests. + */ + +import { randomBytes } from 'node:crypto' + +import { tunnelError } from './errors.js' +import { formatHostForUrl } from './util.js' + +/** cloudflared prints the quick-tunnel URL once, e.g. + * `https://random-words-1234.trycloudflare.com`. Match it on either stream. */ +const TRYCLOUDFLARE_URL = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i + +/** + * Generate a strong URL-safe bearer secret for the tunneled MCP face. + * 32 random bytes → base64url (~43 chars), ample entropy and copy-pasteable. + * @returns {string} + */ +export const generateBearer = () => randomBytes(32).toString('base64url') + +/** + * Extract the first `*.trycloudflare.com` URL from a chunk of cloudflared output. + * @param {string} text + * @returns {string | null} the public origin (no path), or null if not present + */ +export const parseTrycloudflareUrl = (text) => { + const match = TRYCLOUDFLARE_URL.exec(text) + return match ? match[0] : null +} + +/** + * Spawn `cloudflared tunnel --url http://HOST:PORT` and resolve once it prints + * the public `*.trycloudflare.com` URL. Rejects (with an actionable + * {@link tunnelError}) if cloudflared is missing from PATH or exits before + * announcing a URL. + * + * The returned `stop()` hard-kills the cloudflared child (SIGKILL — a quick tunnel + * is stateless, so there is nothing to flush) — the CLI calls it alongside the + * stdio-child teardown, and `onStop` hands it back synchronously for mid-startup. + * + * @param {object} cfg + * @param {string} cfg.host - loopback host the MCP face is bound to + * @param {number} cfg.port - the resolved local MCP port + * @param {ReturnType} cfg.logger + * @param {object} deps + * @param {import('node:child_process').spawn} deps.spawn + * @param {(stop: () => void) => void} [deps.onStop] - receives the teardown fn SYNCHRONOUSLY (before the URL is announced) + * @returns {Promise<{ publicUrl: string, mcpUrl: string, stop: () => void }>} + */ +export const startTunnel = ({ host, port, logger }, { spawn, onStop }) => + new Promise((resolve, reject) => { + const child = spawn('cloudflared', ['tunnel', '--url', `http://${formatHostForUrl(host)}:${port}`], { + stdio: ['ignore', 'pipe', 'pipe'], + }) + + // A quick tunnel is a stateless, ephemeral reverse proxy — nothing to flush on + // shutdown — so tear it down with a hard SIGKILL. That GUARANTEES no orphaned + // cloudflared even though the parent process.exits the moment the stdio child + // dies (a SIGTERM + deferred grace would race that exit and could leak). + const stop = () => { + if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL') + } + // Hand the teardown back SYNCHRONOUSLY (before the URL is announced) so a + // Ctrl-C during tunnel startup still tears cloudflared down — otherwise the + // caller has no handle until this Promise resolves and could orphan it. + onStop?.(stop) + + let settled = false + + const onUrlText = (chunk) => { + if (settled) return + const publicUrl = parseTrycloudflareUrl(String(chunk)) + if (publicUrl === null) return + settled = true + logger.info({ lifecycle: 'tunnel-up', host: hostOf(publicUrl) }) + resolve({ publicUrl, mcpUrl: `${publicUrl}/mcp`, stop }) + } + + // cloudflared prints the URL to stderr; scan stdout too for forward-compat. + child.stdout?.on('data', onUrlText) + child.stderr?.on('data', onUrlText) + + child.on('error', (err) => { + if (settled) return + settled = true + const { message, exitCode } = tunnelError(err) + logger.error({ lifecycle: 'tunnel-spawn-failed', errorCode: err?.code }) + reject(Object.assign(new Error(message), { exitCode })) + }) + + child.on('exit', (code, signal) => { + if (settled) return + settled = true + const reason = signal != null ? `signal ${signal}` : `code ${code ?? 'unknown'}` + const { message, exitCode } = tunnelError({ reason: `exited before announcing a URL (${reason})` }) + logger.error({ lifecycle: 'tunnel-exited-early', reason }) + reject(Object.assign(new Error(message), { exitCode })) + }) + }) + +/** Log-safe host of a URL (never the full URL, which is the public secret-ish). */ +const hostOf = (url) => { + try { + return new URL(url).host + } catch { + return 'unknown' + } +} diff --git a/thunderbolt-stdio-bridge/src/tunnel.test.js b/thunderbolt-stdio-bridge/src/tunnel.test.js new file mode 100644 index 000000000..55e22393a --- /dev/null +++ b/thunderbolt-stdio-bridge/src/tunnel.test.js @@ -0,0 +1,150 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { describe, it, expect } from 'bun:test' +import { EventEmitter } from 'node:events' +import { startTunnel, generateBearer, parseTrycloudflareUrl } from './tunnel.js' +import { createLogger } from './log.js' + +const quietLogger = () => createLogger({ stream: { write: () => {} } }) + +/** + * A fake cloudflared child: stdout/stderr are EventEmitters, kill records the + * signals it received and flips exitCode so stop()'s guards behave. + */ +const makeFakeCloudflared = () => { + const child = new EventEmitter() + child.exitCode = null + child.signalCode = null + child.stdout = new EventEmitter() + child.stderr = new EventEmitter() + child.killed = [] + child.kill = (sig) => { + child.killed.push(sig) + return true + } + return child +} + +/** An injectable spawn returning a preset child and recording its argv. */ +const makeFakeSpawn = (child) => { + const calls = [] + const spawn = (cmd, args, opts) => { + calls.push({ cmd, args, opts }) + return child + } + return { spawn, calls } +} + +describe('generateBearer', () => { + it('returns a long url-safe secret with no padding/url-unsafe chars', () => { + const a = generateBearer() + const b = generateBearer() + expect(a).not.toBe(b) + expect(a.length).toBeGreaterThanOrEqual(40) + expect(a).toMatch(/^[A-Za-z0-9_-]+$/) + }) +}) + +describe('parseTrycloudflareUrl', () => { + it('extracts the public URL from a cloudflared log line', () => { + const line = + '2024-01-01T00:00:00Z INF +--------------------------------------------------------+\n' + + '| Your quick Tunnel has been created! Visit it at: |\n' + + '| https://random-funny-words-1234.trycloudflare.com |\n' + expect(parseTrycloudflareUrl(line)).toBe('https://random-funny-words-1234.trycloudflare.com') + }) + + it('returns null when no trycloudflare URL is present', () => { + expect(parseTrycloudflareUrl('Starting tunnel...')).toBeNull() + }) +}) + +describe('startTunnel', () => { + it('spawns cloudflared with the loopback --url and resolves on the announced URL', async () => { + const child = makeFakeCloudflared() + const { spawn, calls } = makeFakeSpawn(child) + const promise = startTunnel({ host: '127.0.0.1', port: 7777, logger: quietLogger() }, { spawn }) + + // cloudflared announces the URL on stderr. + child.stderr.emit('data', Buffer.from('Visit it at: https://abc-def.trycloudflare.com\n')) + + const result = await promise + expect(calls[0].cmd).toBe('cloudflared') + expect(calls[0].args).toEqual(['tunnel', '--url', 'http://127.0.0.1:7777']) + expect(result.publicUrl).toBe('https://abc-def.trycloudflare.com') + expect(result.mcpUrl).toBe('https://abc-def.trycloudflare.com/mcp') + }) + + it('also parses the URL when cloudflared prints it on stdout', async () => { + const child = makeFakeCloudflared() + const { spawn } = makeFakeSpawn(child) + const promise = startTunnel({ host: '127.0.0.1', port: 80, logger: quietLogger() }, { spawn }) + child.stdout.emit('data', 'https://xyz.trycloudflare.com') + const result = await promise + expect(result.mcpUrl).toBe('https://xyz.trycloudflare.com/mcp') + }) + + it('rejects with an actionable error + exit code 70 when cloudflared is missing (ENOENT)', async () => { + const child = makeFakeCloudflared() + const { spawn } = makeFakeSpawn(child) + const promise = startTunnel({ host: '127.0.0.1', port: 1, logger: quietLogger() }, { spawn }) + child.emit('error', Object.assign(new Error('spawn cloudflared ENOENT'), { code: 'ENOENT' })) + await expect(promise).rejects.toMatchObject({ exitCode: 70 }) + await promise.catch((err) => expect(err.message).toContain('cloudflared not found')) + }) + + it('rejects with exit code 69 when cloudflared exits before announcing a URL', async () => { + const child = makeFakeCloudflared() + const { spawn } = makeFakeSpawn(child) + const promise = startTunnel({ host: '127.0.0.1', port: 1, logger: quietLogger() }, { spawn }) + child.exitCode = 1 + child.emit('exit', 1, null) + await expect(promise).rejects.toMatchObject({ exitCode: 69 }) + }) + + it('stop() hard-kills the ephemeral cloudflared tunnel (SIGKILL, no graceful state)', async () => { + const child = makeFakeCloudflared() + const { spawn } = makeFakeSpawn(child) + const promise = startTunnel({ host: '127.0.0.1', port: 7777, logger: quietLogger() }, { spawn }) + child.stderr.emit('data', 'https://a.trycloudflare.com') + const { stop } = await promise + + stop() + expect(child.killed).toEqual(['SIGKILL']) + }) + + it('stop() does not signal a cloudflared child that already exited', async () => { + const child = makeFakeCloudflared() + const { spawn } = makeFakeSpawn(child) + const promise = startTunnel({ host: '127.0.0.1', port: 7777, logger: quietLogger() }, { spawn }) + child.stderr.emit('data', 'https://a.trycloudflare.com') + const { stop } = await promise + + child.exitCode = 0 // already gone + stop() + expect(child.killed).toEqual([]) + }) + + it('hands stop() back synchronously via onStop, before any URL — so a teardown mid-startup still kills cloudflared', () => { + const child = makeFakeCloudflared() + const { spawn } = makeFakeSpawn(child) + let captured = null + // No URL is ever emitted: onStop must have fired synchronously on spawn. + startTunnel({ host: '127.0.0.1', port: 7777, logger: quietLogger() }, { spawn, onStop: (s) => (captured = s) }) + expect(typeof captured).toBe('function') + captured() + expect(child.killed).toEqual(['SIGKILL']) + }) + + it('ignores a late URL announcement after an early-exit rejection (no double-settle)', async () => { + const child = makeFakeCloudflared() + const { spawn } = makeFakeSpawn(child) + const promise = startTunnel({ host: '127.0.0.1', port: 1, logger: quietLogger() }, { spawn }) + child.emit('exit', 1, null) + await expect(promise).rejects.toMatchObject({ exitCode: 69 }) + // A stray late line must not throw / re-settle. + expect(() => child.stderr.emit('data', 'https://late.trycloudflare.com')).not.toThrow() + }) +}) diff --git a/thunderbolt-stdio-bridge/src/util.js b/thunderbolt-stdio-bridge/src/util.js new file mode 100644 index 000000000..bb907b476 --- /dev/null +++ b/thunderbolt-stdio-bridge/src/util.js @@ -0,0 +1,37 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Small shared helpers used by both protocol faces (the ACP ws server and the + * MCP http server). Kept dependency-free and pure. + */ + +/** + * Resolve the actual listening port of a server (an ephemeral `0` request is + * assigned a real port by the OS). Works for both a `ws` WebSocketServer and a + * `node:http` Server — both expose `address(): { port } | string | null`. + * @param {{ address?: () => unknown }} server + * @param {number} requested + * @returns {number} + */ +export const resolvePort = (server, requested) => { + const address = server?.address?.() + if ( + address && + typeof address === 'object' && + typeof (/** @type {{ port?: unknown }} */ (address).port) === 'number' + ) { + return /** @type {{ port: number }} */ (address).port + } + return requested +} + +/** + * Format a bind host for inclusion in a URL. An IPv6 literal (the only host form + * containing a colon) is wrapped in brackets per RFC 3986, unless the caller + * already bracketed it (avoid `[[::1]]`). + * @param {string} host + * @returns {string} + */ +export const formatHostForUrl = (host) => (host.includes(':') && !host.startsWith('[') ? `[${host}]` : host) From 2f8ff63a6eb109469f0200eee2ffd71af40edab9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=8Dtalo=20Menezes?= Date: Thu, 18 Jun 2026 18:12:18 -0300 Subject: [PATCH 19/23] refactor: point bridge command refs at thunderbolt-stdio-bridge --mode acp --- src/acp/transports/index.test.ts | 2 +- src/acp/transports/index.ts | 5 +++-- .../settings/agents/add-custom-agent-dialog.tsx | 2 +- .../settings/agents/agent-catalog-view.test.tsx | 2 +- .../settings/agents/bridge-connect-dialog.tsx | 4 ++-- src/lib/agent-bridge-command.test.ts | 6 +++--- src/lib/agent-bridge-command.ts | 11 ++++++----- 7 files changed, 17 insertions(+), 15 deletions(-) diff --git a/src/acp/transports/index.test.ts b/src/acp/transports/index.test.ts index 59a9e9ba1..9cee75990 100644 --- a/src/acp/transports/index.test.ts +++ b/src/acp/transports/index.test.ts @@ -227,7 +227,7 @@ describe('openTransport — agent-type routing', () => { it.each(['ws://127.0.0.1:7777/acp', 'ws://localhost:7777/acp', 'ws://[::1]:7777/acp', 'ws://sub.localhost:7777/acp'])( 'remote-acp loopback target %s connects directly on Web (no proxy)', async (url) => { - // The thunderbolt-acp-bridge carve-out: a loopback remote-acp target is the local + // The thunderbolt-stdio-bridge carve-out: a loopback remote-acp target is the local // bridge socket. On Web (Connected) it must skip the cloud proxy — the // proxy can't reach localhost — and connect natively to the URL as-is. const transport = await openTransport({ diff --git a/src/acp/transports/index.ts b/src/acp/transports/index.ts index 488e98fd9..cc83a4c5a 100644 --- a/src/acp/transports/index.ts +++ b/src/acp/transports/index.ts @@ -24,7 +24,8 @@ * its own machine has no SSRF surface — the proxy's localhost rejection * protects the *cloud backend*, which is irrelevant here — and the proxy * would reject the `ws://`/private-host target anyway. This is the - * `thunderbolt-acp-bridge` path: a local stdio agent bridged to a localhost socket. + * `thunderbolt-stdio-bridge --mode acp` path: a local stdio agent bridged to a + * localhost socket. * - Web (always Connected): proxied WebSocket via `createProxyWebSocket`. * - Tauri + proxy toggle ON (Connected): proxied WebSocket. * - Tauri + proxy toggle OFF (Standalone): native `new WebSocket()`. @@ -115,7 +116,7 @@ export const resolveWebSocketFactory = (inputs: OpenTransportInputs): WebSocketF if (inputs.agentType === 'managed-acp') { return resolveManagedAcpFactory(inputs) } - // A loopback remote-acp target is the local `thunderbolt-acp-bridge` socket — connect + // A loopback remote-acp target is the local `thunderbolt-stdio-bridge` socket — connect // directly, skipping the cloud proxy, on every platform (web included). The // proxy can't reach localhost and would reject the target regardless. if (isLoopbackUrl(inputs.url)) { diff --git a/src/components/settings/agents/add-custom-agent-dialog.tsx b/src/components/settings/agents/add-custom-agent-dialog.tsx index d7778006e..d1ef4a2ed 100644 --- a/src/components/settings/agents/add-custom-agent-dialog.tsx +++ b/src/components/settings/agents/add-custom-agent-dialog.tsx @@ -159,7 +159,7 @@ export const AddCustomAgentDialog = ({ trimmedName.length > 0 && trimmedUrl.length > 0 && state.connectionStatus === 'success' && !state.submitting // The probe is only meaningful once the URL is a valid WebSocket endpoint. const canTestConnection = trimmedUrl.length > 0 && !urlError - // Loopback targets (the local thunderbolt-acp-bridge socket) trip the browser's Local + // Loopback targets (the local thunderbolt-stdio-bridge socket) trip the browser's Local // Network Access prompt — hint the user so the Allow dialog isn't a surprise. const showLoopbackHint = !urlError && isLoopbackUrl(trimmedUrl) diff --git a/src/components/settings/agents/agent-catalog-view.test.tsx b/src/components/settings/agents/agent-catalog-view.test.tsx index adca08234..7dfa73126 100644 --- a/src/components/settings/agents/agent-catalog-view.test.tsx +++ b/src/components/settings/agents/agent-catalog-view.test.tsx @@ -147,7 +147,7 @@ describe('AgentCatalogView', () => { fireEvent.click(screen.getByRole('button', { name: /connect via bridge/i })) // The bridge command is composed from the npx distribution and shown copyable. - expect(screen.getByText('npx thunderbolt-acp-bridge -- npx goose@1.2.3')).toBeInTheDocument() + expect(screen.getByText('npx thunderbolt-stdio-bridge --mode acp -- npx goose@1.2.3')).toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: /add the agent/i })) expect(onAddCustomAgent).toHaveBeenCalledTimes(1) diff --git a/src/components/settings/agents/bridge-connect-dialog.tsx b/src/components/settings/agents/bridge-connect-dialog.tsx index da0907dd6..af346dfa2 100644 --- a/src/components/settings/agents/bridge-connect-dialog.tsx +++ b/src/components/settings/agents/bridge-connect-dialog.tsx @@ -25,7 +25,7 @@ type BridgeConnectDialogProps = { } /** Walks the user through running a CLI agent locally and bridging it into - * Thunderbolt: install the agent, run `thunderbolt-acp-bridge`, then add the printed + * Thunderbolt: install the agent, run `thunderbolt-stdio-bridge --mode acp`, then add the printed * localhost URL as a custom agent. All commands are derived from the registry * distribution at render — no effects, no local state. */ export const BridgeConnectDialog = ({ entry, open, onOpenChange, onAddCustomAgent }: BridgeConnectDialogProps) => { @@ -73,7 +73,7 @@ export const BridgeConnectDialog = ({ entry, open, onOpenChange, onAddCustomAgen ) : (

{entry.name} ships as a platform binary. Follow its install instructions, then run it under{' '} - thunderbolt-acp-bridge and add the printed{' '} + thunderbolt-stdio-bridge --mode acp and add the printed{' '} ws://127.0.0.1:PORT URL.

)} diff --git a/src/lib/agent-bridge-command.test.ts b/src/lib/agent-bridge-command.test.ts index cab425dea..86fc763af 100644 --- a/src/lib/agent-bridge-command.test.ts +++ b/src/lib/agent-bridge-command.test.ts @@ -59,15 +59,15 @@ describe('composeInstallCommand', () => { }) describe('composeBridgeCommand', () => { - it('wraps the npx launch command in thunderbolt-acp-bridge', () => { + it('wraps the npx launch command in thunderbolt-stdio-bridge --mode acp', () => { expect(composeBridgeCommand(entry({ npx: { package: '@agentclientprotocol/claude-agent-acp' } }))).toBe( - 'npx thunderbolt-acp-bridge -- npx @agentclientprotocol/claude-agent-acp', + 'npx thunderbolt-stdio-bridge --mode acp -- npx @agentclientprotocol/claude-agent-acp', ) }) it('wraps a uvx launch command with its args', () => { expect(composeBridgeCommand(entry({ uvx: { package: 'py-agent', args: ['serve', '--port', '0'] } }))).toBe( - 'npx thunderbolt-acp-bridge -- uvx py-agent serve --port 0', + 'npx thunderbolt-stdio-bridge --mode acp -- uvx py-agent serve --port 0', ) }) diff --git a/src/lib/agent-bridge-command.ts b/src/lib/agent-bridge-command.ts index 4cba29ce8..fa920cf29 100644 --- a/src/lib/agent-bridge-command.ts +++ b/src/lib/agent-bridge-command.ts @@ -12,7 +12,7 @@ import type { RegistryEntry } from '@/types/registry' * * Two flavours are produced from the same launch command: * - the install/launch command (`npx ` / `uvx `), - * - the bridge command that wraps it (`npx thunderbolt-acp-bridge -- `). + * - the bridge command that wraps it (`npx thunderbolt-stdio-bridge --mode acp -- `). * * `binary` distributions have no portable launch line (the registry leaves the * shape opaque per platform), so both helpers return `null` and the UI points @@ -47,14 +47,15 @@ export const composeLaunchCommand = (entry: RegistryEntry): string | null => { export const composeInstallCommand = (entry: RegistryEntry): string | null => composeLaunchCommand(entry) /** - * The `thunderbolt-acp-bridge` invocation that relays the local stdio agent to a localhost - * WebSocket: `npx thunderbolt-acp-bridge -- `. Everything after `--` is the - * agent's own launch argv. Returns `null` for `binary` distributions. + * The `thunderbolt-stdio-bridge` invocation that relays the local stdio agent to a localhost + * WebSocket: `npx thunderbolt-stdio-bridge --mode acp -- `. The `--mode acp` + * flag selects the ACP (WebSocket) face; everything after `--` is the agent's own launch argv. + * Returns `null` for `binary` distributions. */ export const composeBridgeCommand = (entry: RegistryEntry): string | null => { const launch = composeLaunchCommand(entry) if (!launch) { return null } - return `npx thunderbolt-acp-bridge -- ${launch}` + return `npx thunderbolt-stdio-bridge --mode acp -- ${launch}` } From 7bd82773f0fd1365e427bb04ee6b3b6b75e97712 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=8Dtalo=20Menezes?= Date: Thu, 18 Jun 2026 18:12:29 -0300 Subject: [PATCH 20/23] feat: connect loopback MCP servers natively, skipping the cloud proxy --- src/lib/mcp-transport.test.ts | 30 ++++++++++++++++++++++++++++- src/lib/mcp-transport.ts | 36 ++++++++++++++++++++++++++++------- 2 files changed, 58 insertions(+), 8 deletions(-) diff --git a/src/lib/mcp-transport.test.ts b/src/lib/mcp-transport.test.ts index 632c836ce..d0ba33432 100644 --- a/src/lib/mcp-transport.test.ts +++ b/src/lib/mcp-transport.test.ts @@ -5,7 +5,8 @@ import { describe, expect, it } from 'bun:test' import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js' import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' -import { buildMcpHeaders, createMcpTransport } from './mcp-transport' +import { buildMcpHeaders, createMcpTransport, resolveMcpFetch } from './mcp-transport' +import type { FetchFn } from './proxy-fetch' const url = 'https://mcp.example.com/server' const cloudUrl = 'https://cloud.example.com' @@ -53,6 +54,33 @@ describe('createMcpTransport', () => { }) }) +describe('resolveMcpFetch', () => { + const stub = (label: string): FetchFn => + Object.assign(() => Promise.resolve(new Response(label)), { preconnect: () => Promise.resolve(false) }) + const proxy = stub('proxy') + const native = stub('native') + + it.each([ + 'http://localhost:8765/mcp', + 'http://127.0.0.1:8765/mcp', + 'http://127.5.4.3:8765/mcp', + 'http://[::1]:8765/mcp', + 'http://foo.localhost:8765/mcp', + ])('uses the native fetch for the loopback target %s', (url) => { + expect(resolveMcpFetch(url, proxy, native)).toBe(native) + }) + + it.each([ + 'https://mcp.example.com/server', + 'http://192.168.1.10:8765/mcp', + 'http://10.0.0.5/mcp', + 'http://example.com/mcp', + 'not-a-url', + ])('uses the proxy fetch for the non-loopback target %s', (url) => { + expect(resolveMcpFetch(url, proxy, native)).toBe(proxy) + }) +}) + describe('buildMcpHeaders', () => { it('sets a plain Bearer Authorization header when a token is provided', () => { const headers = buildMcpHeaders('tok') diff --git a/src/lib/mcp-transport.ts b/src/lib/mcp-transport.ts index 705ba21aa..d4d74fd4d 100644 --- a/src/lib/mcp-transport.ts +++ b/src/lib/mcp-transport.ts @@ -8,8 +8,9 @@ import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js' import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js' +import { isLoopbackUrl } from '@/acp/transports/is-loopback' import { getAuthToken } from './auth-token' -import { computeEffectiveProxyEnabled, createProxyFetch } from './proxy-fetch' +import { computeEffectiveProxyEnabled, createProxyFetch, type FetchFn } from './proxy-fetch' /** Remote transport kind. stdio (local) servers are connected by THU-575, not here. */ export type MCPTransportType = 'http' | 'sse' @@ -59,12 +60,32 @@ export const buildMcpHeaders = (token?: string): Record => { return headers } +const nativeFetch: FetchFn = Object.assign( + (input: RequestInfo | URL, init?: RequestInit) => globalThis.fetch(input, init), + { preconnect: () => Promise.resolve(false) }, +) + +/** + * Selects the fetch implementation for an MCP server URL. Loopback targets + * (`localhost` / `127.0.0.0-8` / `::1` / `*.localhost` — see {@link isLoopbackUrl}) + * are the local `thunderbolt-stdio-bridge --mode mcp` server: connect directly with + * a native `fetch`, skipping the cloud proxy. A browser reaching its own machine + * has no SSRF surface (the proxy's localhost rejection protects the *cloud backend*, + * which is irrelevant here), and the proxy SSRF-rejects localhost regardless, so the + * proxied path would never reach the bridge. All non-loopback URLs keep the proxy + * hop. The factory is injected so the decision logic is unit-testable. + */ +export const resolveMcpFetch = (url: string, proxyFetch: FetchFn, native: FetchFn = nativeFetch): FetchFn => + isLoopbackUrl(url) ? native : proxyFetch + /** - * Builds an MCP client transport that routes through the universal proxy fetch. - * Hosted mode (web) goes through `${cloudUrl}/v1/proxy` with header rewriting; - * Standalone mode (Tauri) hits the upstream directly. Picks SSE for `sse`, - * otherwise Streamable HTTP — both accept the identical `{ fetch, requestInit }` - * shape. Keeps the provider and the settings test-connection on one code path. + * Builds an MCP client transport. Non-loopback URLs route through the universal + * proxy fetch: Hosted mode (web) goes through `${cloudUrl}/v1/proxy` with header + * rewriting; Standalone mode (Tauri) hits the upstream directly. Loopback URLs + * bypass the proxy and connect natively (see {@link resolveMcpFetch}). Picks SSE + * for `sse`, otherwise Streamable HTTP — both accept the identical + * `{ fetch, requestInit }` shape. Keeps the provider and the settings + * test-connection on one code path. */ export const createMcpTransport = ( url: string, @@ -83,8 +104,9 @@ export const createMcpTransport = ( getProxyAuthToken: getAuthToken, getProxyEnabled: () => computeEffectiveProxyEnabled(), }) + const resolvedFetch = resolveMcpFetch(url, proxyFetch) const options = { - fetch: (input: string | URL, init?: RequestInit) => proxyFetch(input, init), + fetch: (input: string | URL, init?: RequestInit) => resolvedFetch(input, init), requestInit: { headers }, } const transport = From e524294692658dfbf67ace6e30b2c039cd0e063a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=8Dtalo=20Menezes?= Date: Thu, 18 Jun 2026 18:48:45 -0300 Subject: [PATCH 21/23] fix: hold agent output before the first client connects by pausing the relay at startup --- thunderbolt-stdio-bridge/src/server.js | 7 ++++++ thunderbolt-stdio-bridge/src/server.test.js | 27 ++++++++++++++++----- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/thunderbolt-stdio-bridge/src/server.js b/thunderbolt-stdio-bridge/src/server.js index 8e8add047..b4373f66d 100644 --- a/thunderbolt-stdio-bridge/src/server.js +++ b/thunderbolt-stdio-bridge/src/server.js @@ -127,6 +127,13 @@ export const startBridge = async (cfg, deps) => { onDrop: (line) => logger.warn({ lifecycle: 'dropped-non-json', byteSize: Buffer.byteLength(line) }), }) + // No client is connected until Thunderbolt dials in, so start the reader PAUSED: + // early agent stdout is held by OS pipe backpressure instead of read-and-dropped. + // The first connection resumes it (and any later disconnect re-pauses) — so the + // held-not-dropped invariant holds for EVERY no-client window, including the first. + lines.pause() + readerPaused = true + // --- WebSocket server ----------------------------------------------------- // verifyClient runs DURING the upgrade handshake: a disallowed Origin is // rejected with HTTP 403 and the WebSocket is never established, so a hostile diff --git a/thunderbolt-stdio-bridge/src/server.test.js b/thunderbolt-stdio-bridge/src/server.test.js index 3e9b01b95..40e3fa326 100644 --- a/thunderbolt-stdio-bridge/src/server.test.js +++ b/thunderbolt-stdio-bridge/src/server.test.js @@ -210,7 +210,7 @@ describe('startBridge lifecycle', () => { // Client briefly disconnects (e.g. Thunderbolt's reconnect backoff). first.emit('close', 1000) - expect(lines.pauseCalls).toBe(1) // reader paused so output isn't dropped + expect(lines.pauseCalls).toBe(2) // paused at start + again on this disconnect // The agent emits an in-flight response WHILE no client is connected. child.stdout.emit('data', '{"id":42}\n') @@ -218,10 +218,25 @@ describe('startBridge lifecycle', () => { // The client reconnects: the reader resumes and drains the held line in order. const second = connect(wss) - expect(lines.resumeCalls).toBe(1) + expect(lines.resumeCalls).toBe(2) // start-pause + reconnect-pause each resumed once expect(second.sent).toEqual(['{"id":42}']) // the in-flight response survived the disconnect }) + it('holds agent output emitted BEFORE the first client connects (reader paused at start)', async () => { + const { child, wss, getLines } = await startReady() + const lines = getLines() + // No client yet: the reader is paused from the start (not read-and-dropped). + expect(lines.pauseCalls).toBe(1) + + // The agent prints to stdout before anyone has connected. + child.stdout.emit('data', '{"id":1}\n') + + // The first client connects → reader resumes and drains the held line. + const socket = connect(wss) + expect(lines.resumeCalls).toBe(1) + expect(socket.sent).toEqual(['{"id":1}']) // early output survived, not dropped + }) + it('supersedes a previous connection: closes the old socket 1000, new becomes active', async () => { const { child, wss } = await startReady() const first = connect(wss) @@ -287,7 +302,7 @@ describe('startBridge lifecycle', () => { WebSocketServer: function () { return wss }, - createLineReader: () => new EventEmitter(), + createLineReader: (stream) => makeFakeLineReader(stream), exit: (code) => { exited = code }, @@ -314,7 +329,7 @@ describe('startBridge lifecycle', () => { WebSocketServer: function () { return wss }, - createLineReader: () => new EventEmitter(), + createLineReader: (stream) => makeFakeLineReader(stream), exit: (code) => { exited = code }, @@ -339,7 +354,7 @@ describe('startBridge lifecycle', () => { WebSocketServer: function () { return wss }, - createLineReader: () => new EventEmitter(), + createLineReader: (stream) => makeFakeLineReader(stream), exit: (code) => { exited = code }, @@ -364,7 +379,7 @@ describe('startBridge lifecycle', () => { WebSocketServer: function () { return wss }, - createLineReader: () => new EventEmitter(), + createLineReader: (stream) => makeFakeLineReader(stream), exit: (code) => { exited = code }, From 661bf2e9e195a96036d573e5cac09f80e351dfb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=8Dtalo=20Menezes?= Date: Thu, 18 Jun 2026 19:21:50 -0300 Subject: [PATCH 22/23] fix: emit insecure-flag warnings in MCP mode and keep handler logs PII-safe --- thunderbolt-stdio-bridge/src/mcp-server.js | 12 +++- .../src/mcp-server.test.js | 19 +++++ thunderbolt-stdio-bridge/src/server.js | 28 +------- thunderbolt-stdio-bridge/src/util.js | 41 +++++++++++ thunderbolt-stdio-bridge/src/util.test.js | 70 +++++++++++++++++++ 5 files changed, 141 insertions(+), 29 deletions(-) create mode 100644 thunderbolt-stdio-bridge/src/util.test.js diff --git a/thunderbolt-stdio-bridge/src/mcp-server.js b/thunderbolt-stdio-bridge/src/mcp-server.js index eed6ac6ed..140af59d3 100644 --- a/thunderbolt-stdio-bridge/src/mcp-server.js +++ b/thunderbolt-stdio-bridge/src/mcp-server.js @@ -31,7 +31,7 @@ import { randomUUID, timingSafeEqual } from 'node:crypto' import { isOriginAllowed, sanitizeOrigin, defaultAllowedOrigins, classifyKind, safeMethod } from './log.js' import { parseRpcObject } from './relay.js' -import { resolvePort } from './util.js' +import { resolvePort, emitInsecureFlagWarnings } from './util.js' /** Cap on a single request body. MCP messages are small JSON-RPC frames; a * multi-MB POST to a localhost agent bridge is never legitimate and is a cheap @@ -67,6 +67,10 @@ export const startMcpFace = (cfg, deps) => { const { child, lines, host, port, allowOrigins = [], allowAnyOrigin = false, requiredBearer = null, logger } = cfg const { createHttpServer, createTransport } = deps + // Same loud warnings the ACP face emits — disabling the Origin guard or binding + // a non-loopback host fronts a privileged agent and must alert the user in BOTH modes. + emitInsecureFlagWarnings({ host, allowAnyOrigin, logger }) + const allowlist = [...defaultAllowedOrigins, ...allowOrigins] const transport = createTransport() // Streamable HTTP's start() is a no-op (connections are per-request), but the @@ -136,8 +140,10 @@ export const startMcpFace = (cfg, deps) => { const server = createHttpServer((req, res) => { const ctx = { req, res, transport, allowlist, allowAnyOrigin, requiredBearer, child, logger, openResponses } handleRequest(ctx).catch((err) => { - // A handler-level failure must never crash the process; answer 500. - logger.error({ lifecycle: 'mcp-handler-error', detail: err?.message }) + // A handler-level failure must never crash the process; answer 500. Log + // ONLY the error code (a fixed Node string) — never err.message, which can + // echo request-derived content and break the bridge's PII-safe logging. + logger.error({ lifecycle: 'mcp-handler-error', errorCode: err?.code }) if (!res.headersSent) endJson(res, 500, jsonRpcError(JSONRPC_INTERNAL_ERROR, 'Internal bridge error')) }) }) diff --git a/thunderbolt-stdio-bridge/src/mcp-server.test.js b/thunderbolt-stdio-bridge/src/mcp-server.test.js index 37686774d..82c0987af 100644 --- a/thunderbolt-stdio-bridge/src/mcp-server.test.js +++ b/thunderbolt-stdio-bridge/src/mcp-server.test.js @@ -401,3 +401,22 @@ describe('startMcpFace — health probe', () => { expect(JSON.parse(res.body)).toEqual({ ok: true }) }) }) + +describe('startMcpFace — insecure-flag warnings (parity with ACP)', () => { + it('emits the loud warnings in MCP mode when the Origin guard is off AND the host is non-loopback', async () => { + const warned = [] + const logger = { debug() {}, info() {}, warn: (e) => warned.push(e), error() {} } + await startFace({ cfg: { allowAnyOrigin: true, host: '0.0.0.0', logger } }) + expect(warned.some((e) => e.lifecycle === 'origin-check-disabled')).toBe(true) + expect(warned.some((e) => e.lifecycle === 'non-loopback-host')).toBe(true) + }) + + it('stays silent on the safe defaults (loopback host, Origin guard on)', async () => { + const warned = [] + const logger = { debug() {}, info() {}, warn: (e) => warned.push(e), error() {} } + await startFace({ cfg: { allowAnyOrigin: false, host: '127.0.0.1', logger } }) + expect(warned.some((e) => e.lifecycle === 'origin-check-disabled' || e.lifecycle === 'non-loopback-host')).toBe( + false, + ) + }) +}) diff --git a/thunderbolt-stdio-bridge/src/server.js b/thunderbolt-stdio-bridge/src/server.js index b4373f66d..33d79e055 100644 --- a/thunderbolt-stdio-bridge/src/server.js +++ b/thunderbolt-stdio-bridge/src/server.js @@ -23,7 +23,7 @@ import { serverError } from './errors.js' import { superviseChild } from './child.js' import { wireAgentToWs, handleWsMessage } from './relay.js' import { extractLogEvent, sanitizeOrigin, isOriginAllowed, defaultAllowedOrigins } from './log.js' -import { resolvePort, formatHostForUrl } from './util.js' +import { resolvePort, formatHostForUrl, emitInsecureFlagWarnings } from './util.js' const WS_OPEN = 1 const WS_CLOSE_NORMAL = 1000 @@ -57,22 +57,7 @@ export const startBridge = async (cfg, deps) => { const allowlist = [...defaultAllowedOrigins, ...allowOrigins] - if (allowAnyOrigin) { - logger.warn({ lifecycle: 'origin-check-disabled' }) - process.stderr.write( - '\nWARNING: --allow-any-origin is set — the Origin check is OFF.\n' + - 'Any web page open in a browser on this machine can connect to the bridge\n' + - 'and drive your agent. Use this only for trusted dev/self-host setups.\n', - ) - } - - if (!isLoopbackHost(host)) { - process.stderr.write( - `\nWARNING: --host ${host} is not a loopback address — the bridge (and your\n` + - 'agent) is now reachable by other hosts on the network, not just this\n' + - 'machine. Keep the default 127.0.0.1 unless you really need remote access.\n', - ) - } + emitInsecureFlagWarnings({ host, allowAnyOrigin, logger }) return new Promise((resolve, reject) => { /** @type {import('ws').WebSocketServer | null} */ @@ -207,12 +192,3 @@ export const startBridge = async (cfg, deps) => { deps.onStop?.(stop) }) } - -/** - * Whether a bind host is a loopback address (only reachable from this machine). - * A non-loopback host exposes the agent to other hosts on the network, which - * warrants a prominent startup warning. - * @param {string} host - * @returns {boolean} - */ -const isLoopbackHost = (host) => host === '127.0.0.1' || host === 'localhost' || host === '::1' diff --git a/thunderbolt-stdio-bridge/src/util.js b/thunderbolt-stdio-bridge/src/util.js index bb907b476..566718294 100644 --- a/thunderbolt-stdio-bridge/src/util.js +++ b/thunderbolt-stdio-bridge/src/util.js @@ -35,3 +35,44 @@ export const resolvePort = (server, requested) => { * @returns {string} */ export const formatHostForUrl = (host) => (host.includes(':') && !host.startsWith('[') ? `[${host}]` : host) + +/** + * Whether a bind host is loopback-only (reachable just from this machine) — the + * narrow set the bridge binds to by default. Anything else exposes the agent to + * other hosts on the network and warrants a loud warning. + * @param {string} host + * @returns {boolean} + */ +export const isLoopbackHost = (host) => host === '127.0.0.1' || host === 'localhost' || host === '::1' + +/** + * Emit the loud security warnings shared by BOTH faces (ACP ws + MCP http) when + * the user relaxes a default that fronts a privileged local agent: disabling the + * Origin guard, or binding a non-loopback (LAN-reachable) host. Writes the human + * text to stderr AND a structural lifecycle line to the logger (so it's testable + * and PII-safe — `host` is a config scalar, never content). Each face calls this + * at startup so the warning fires whatever protocol the user selected. + * @param {object} cfg + * @param {string} cfg.host + * @param {boolean} cfg.allowAnyOrigin + * @param {{ warn: (event: Record) => void }} cfg.logger + * @param {{ write: (s: string) => void }} [stream] - injectable for tests (default stderr) + */ +export const emitInsecureFlagWarnings = ({ host, allowAnyOrigin, logger }, stream = process.stderr) => { + if (allowAnyOrigin) { + logger.warn({ lifecycle: 'origin-check-disabled' }) + stream.write( + '\nWARNING: --allow-any-origin is set — the Origin check is OFF.\n' + + 'Any web page open in a browser on this machine can connect to the bridge\n' + + 'and drive your agent. Use this only for trusted dev/self-host setups.\n', + ) + } + if (!isLoopbackHost(host)) { + logger.warn({ lifecycle: 'non-loopback-host', host }) + stream.write( + `\nWARNING: --host ${host} is not a loopback address — the bridge (and your\n` + + 'agent) is now reachable by other hosts on the network, not just this\n' + + 'machine. Keep the default 127.0.0.1 unless you really need remote access.\n', + ) + } +} diff --git a/thunderbolt-stdio-bridge/src/util.test.js b/thunderbolt-stdio-bridge/src/util.test.js new file mode 100644 index 000000000..034191841 --- /dev/null +++ b/thunderbolt-stdio-bridge/src/util.test.js @@ -0,0 +1,70 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { describe, it, expect } from 'bun:test' +import { resolvePort, formatHostForUrl, isLoopbackHost, emitInsecureFlagWarnings } from './util.js' + +describe('resolvePort', () => { + it('returns the OS-assigned port from address()', () => { + expect(resolvePort({ address: () => ({ port: 54321 }) }, 0)).toBe(54321) + }) + it('falls back to the requested port when address() is unusable', () => { + expect(resolvePort({ address: () => null }, 8000)).toBe(8000) + expect(resolvePort({ address: () => 'pipe' }, 8000)).toBe(8000) + expect(resolvePort({}, 8000)).toBe(8000) + }) +}) + +describe('formatHostForUrl', () => { + it('brackets a bare IPv6 literal', () => { + expect(formatHostForUrl('::1')).toBe('[::1]') + }) + it('leaves IPv4 / hostnames untouched and does not double-bracket', () => { + expect(formatHostForUrl('127.0.0.1')).toBe('127.0.0.1') + expect(formatHostForUrl('localhost')).toBe('localhost') + expect(formatHostForUrl('[::1]')).toBe('[::1]') + }) +}) + +describe('isLoopbackHost', () => { + it('accepts the loopback set the bridge binds to by default', () => { + for (const h of ['127.0.0.1', 'localhost', '::1']) expect(isLoopbackHost(h)).toBe(true) + }) + it('rejects non-loopback binds', () => { + for (const h of ['0.0.0.0', '192.168.1.5', '::']) expect(isLoopbackHost(h)).toBe(false) + }) +}) + +describe('emitInsecureFlagWarnings', () => { + const capture = () => { + const warned = [] + const written = [] + const logger = { debug() {}, info() {}, warn: (e) => warned.push(e), error() {} } + const stream = { write: (s) => written.push(s) } + return { warned, written, logger, stream } + } + + it('warns (logger + stderr) when the Origin guard is disabled', () => { + const { warned, written, logger, stream } = capture() + emitInsecureFlagWarnings({ host: '127.0.0.1', allowAnyOrigin: true, logger }, stream) + expect(warned.some((e) => e.lifecycle === 'origin-check-disabled')).toBe(true) + expect(written.join('')).toContain('--allow-any-origin') + expect(warned.some((e) => e.lifecycle === 'non-loopback-host')).toBe(false) // loopback host → no host warning + }) + + it('warns when binding a non-loopback host (LAN exposure)', () => { + const { warned, written, logger, stream } = capture() + emitInsecureFlagWarnings({ host: '0.0.0.0', allowAnyOrigin: false, logger }, stream) + expect(warned.some((e) => e.lifecycle === 'non-loopback-host')).toBe(true) + expect(written.join('')).toContain('not a loopback address') + expect(warned.some((e) => e.lifecycle === 'origin-check-disabled')).toBe(false) + }) + + it('stays silent on the safe defaults (loopback host, Origin guard on)', () => { + const { warned, written, logger, stream } = capture() + emitInsecureFlagWarnings({ host: '127.0.0.1', allowAnyOrigin: false, logger }, stream) + expect(warned).toEqual([]) + expect(written).toEqual([]) + }) +}) From aa4e5d9b1c6c0c9b154edce31dc037f7220b6409 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=8Dtalo=20Menezes?= Date: Tue, 23 Jun 2026 16:17:23 -0300 Subject: [PATCH 23/23] build: bundle stdio-bridge as a portable CLI run on the system node --- .github/workflows/stdio-bridge-build.yml | 74 ++++++++++ thunderbolt-stdio-bridge/.gitignore | 4 + thunderbolt-stdio-bridge/bin/cli.js | 7 +- thunderbolt-stdio-bridge/bun.lock | 57 ++++++++ thunderbolt-stdio-bridge/package.json | 10 +- .../scripts/build-cli.mjs | 127 ++++++++++++++++++ 6 files changed, 275 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/stdio-bridge-build.yml create mode 100644 thunderbolt-stdio-bridge/.gitignore create mode 100644 thunderbolt-stdio-bridge/scripts/build-cli.mjs diff --git a/.github/workflows/stdio-bridge-build.yml b/.github/workflows/stdio-bridge-build.yml new file mode 100644 index 000000000..0954f178e --- /dev/null +++ b/.github/workflows/stdio-bridge-build.yml @@ -0,0 +1,74 @@ +name: Build stdio-bridge CLI + +# Builds the thunderbolt-stdio-bridge as a TINY, self-contained CLI: a single +# esbuild bundle that runs on the system node (no embedded runtime, no npm fetch +# at runtime). The bundle is portable JS, so ONE job builds the artifact for +# every OS/arch — no per-target matrix, no signing. + +on: + workflow_dispatch: + push: + branches: [main] + paths: + - 'thunderbolt-stdio-bridge/**' + - '.github/workflows/stdio-bridge-build.yml' + pull_request: + paths: + - 'thunderbolt-stdio-bridge/**' + - '.github/workflows/stdio-bridge-build.yml' + +permissions: + contents: read + +defaults: + run: + working-directory: thunderbolt-stdio-bridge + +jobs: + build: + name: Build & smoke + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: '22' + + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: 1.3.14 + + - run: bun install --frozen-lockfile + + - run: bun run build + + - name: Smoke test (--help on system node) + run: node dist/bridge.cjs --help + + # Boot each mode against a dummy long-lived child so the bundled paths that + # matter — ws + MCP transport construction + socket bind + child spawn — are + # actually exercised, not just argument parsing. + - name: Smoke test (ACP + MCP boot) + run: | + set -e + boot() { + node dist/bridge.cjs --mode "$1" --port 0 -- node -e "setInterval(() => {}, 1e9)" >"/tmp/$1.log" 2>&1 & + local pid=$! + sleep 2 + kill "$pid" 2>/dev/null || true + grep -qi "$2" "/tmp/$1.log" || { echo "FAIL: $1 did not boot"; cat "/tmp/$1.log"; exit 1; } + echo "OK: $1 booted" + } + boot acp listening + boot mcp mcp-listening + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: thunderbolt-bridge + # bridge.cjs is the executable bundle (shebang + .cjs = portable CJS, + # no sibling metadata needed); the .cmd is the Windows launcher. + path: | + thunderbolt-stdio-bridge/dist/bridge.cjs + thunderbolt-stdio-bridge/dist/thunderbolt-bridge.cmd + if-no-files-found: error diff --git a/thunderbolt-stdio-bridge/.gitignore b/thunderbolt-stdio-bridge/.gitignore new file mode 100644 index 000000000..b4b1cded0 --- /dev/null +++ b/thunderbolt-stdio-bridge/.gitignore @@ -0,0 +1,4 @@ +node_modules/ + +# Build artifacts — the CLI bundle is built/shipped, never committed. +dist/ diff --git a/thunderbolt-stdio-bridge/bin/cli.js b/thunderbolt-stdio-bridge/bin/cli.js index 8f5e7b522..df2c06982 100644 --- a/thunderbolt-stdio-bridge/bin/cli.js +++ b/thunderbolt-stdio-bridge/bin/cli.js @@ -46,8 +46,13 @@ const installSignalHandlers = (getStop) => { process.on('SIGTERM', onSignal) } -/** Read the package version without importing JSON (Node version-portable). */ +/** + * Read the package version. In a normal Node install this reads package.json + * relative to the script; in the bundled CLI (no surrounding files) the build + * inlines the version as `__BRIDGE_VERSION__` via esbuild `define`. + */ const readVersion = () => { + if (typeof __BRIDGE_VERSION__ === 'string') return __BRIDGE_VERSION__ const pkg = JSON.parse(readFileSync(join(here, '..', 'package.json'), 'utf8')) return pkg.version } diff --git a/thunderbolt-stdio-bridge/bun.lock b/thunderbolt-stdio-bridge/bun.lock index b6b6c96df..209f8c1c4 100644 --- a/thunderbolt-stdio-bridge/bun.lock +++ b/thunderbolt-stdio-bridge/bun.lock @@ -8,9 +8,64 @@ "@modelcontextprotocol/sdk": "^1.29.0", "ws": "^8.18.0", }, + "devDependencies": { + "esbuild": "^0.28.1", + }, }, }, "packages": { + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="], + "@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="], "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], @@ -57,6 +112,8 @@ "es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="], + "esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], diff --git a/thunderbolt-stdio-bridge/package.json b/thunderbolt-stdio-bridge/package.json index db60bf229..e4bac6158 100644 --- a/thunderbolt-stdio-bridge/package.json +++ b/thunderbolt-stdio-bridge/package.json @@ -1,7 +1,7 @@ { "name": "thunderbolt-stdio-bridge", "version": "0.1.0", - "description": "Tiny CLI that bridges a local stdio agent to Thunderbolt over localhost (today: ACP agents via WebSocket; MCP support via --mode is coming).", + "description": "Tiny CLI that bridges a local stdio agent or MCP server to Thunderbolt over localhost (ACP agents via WebSocket, MCP servers via Streamable HTTP).", "type": "module", "bin": { "thunderbolt-stdio-bridge": "bin/cli.js" @@ -12,14 +12,18 @@ "README.md" ], "scripts": { - "test": "bun test" + "test": "bun test", + "build": "node scripts/build-cli.mjs" }, "engines": { - "node": ">=18" + "node": ">=18.14.1" }, "license": "MPL-2.0", "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", "ws": "^8.18.0" + }, + "devDependencies": { + "esbuild": "^0.28.1" } } diff --git a/thunderbolt-stdio-bridge/scripts/build-cli.mjs b/thunderbolt-stdio-bridge/scripts/build-cli.mjs new file mode 100644 index 000000000..3c02266c7 --- /dev/null +++ b/thunderbolt-stdio-bridge/scripts/build-cli.mjs @@ -0,0 +1,127 @@ +#!/usr/bin/env node + +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Build thunderbolt-stdio-bridge as a TINY, self-contained CLI: a single + * esbuild bundle that runs on the SYSTEM node (no embedded runtime, no npm + * fetch at runtime). Portable JS — the same artifact runs on every OS/arch, + * so there is no per-target matrix and no signing. + * + * Outputs (into dist/): + * - bridge.cjs the CommonJS bundle (bin/cli.js + ws + MCP SDK), + * carrying `#!/usr/bin/env node` and chmod 0o755 — + * directly runnable on Unix as `./bridge.cjs`. The + * `.cjs` extension makes it unambiguously CommonJS, + * so no sibling package.json `type` override is + * needed and the file is portable on its own. + * - thunderbolt-bridge.cmd a Windows launcher that runs `node bridge.cjs`. + * + * The user-facing `thunderbolt-bridge` command name is created at install time + * (a symlink to bridge.cjs on Unix; the .cmd on Windows) — not here. + * + * Usage: node scripts/build-cli.mjs + */ + +import { execFileSync } from 'node:child_process' +import { build } from 'esbuild' +import { readFileSync, writeFileSync, mkdirSync, chmodSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { dirname, join, resolve } from 'node:path' + +const here = dirname(fileURLToPath(import.meta.url)) +const root = resolve(here, '..') +const distDir = join(root, 'dist') +const bundlePath = join(distDir, 'bridge.cjs') +const cmdPath = join(distDir, 'thunderbolt-bridge.cmd') + +const run = (cmd, args, opts = {}) => { + process.stderr.write(`$ ${cmd} ${args.join(' ')}\n`) + execFileSync(cmd, args, { stdio: 'inherit', cwd: root, ...opts }) +} + +const bundle = async () => { + const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) + mkdirSync(distDir, { recursive: true }) + await build({ + entryPoints: [join(root, 'bin', 'cli.js')], + bundle: true, + platform: 'node', + format: 'cjs', + // Match the package's real floor (engines >=18.14.1) so the bundle parses on + // the user's system node, not just node 22. esbuild downgrades only what's + // needed; newer node still runs it. + target: 'node18', + // Keep ws's optional native addons OUT of the bundle so it stays one portable + // pure-JS artifact for every OS/arch. ws falls back to its JS implementation; + // bundling a per-platform .node would break the single-artifact guarantee. + external: ['bufferutil', 'utf-8-validate'], + outfile: bundlePath, + // The bundle runs as CommonJS; the entry uses import.meta.url, which esbuild + // rewrites to a __filename-based shim under platform:node. + define: { + // Inline the version so the bundle never needs package.json at runtime. + __BRIDGE_VERSION__: JSON.stringify(pkg.version), + // The CJS output has no real import.meta; the only use is deriving the + // script dir for the package.json version fallback (which never runs in a + // bundle — version is inlined above). Point it at a banner-defined CJS + // file URL so the expression stays valid instead of `undefined`. + 'import.meta.url': '__importMetaUrl', + }, + banner: { + js: 'const __importMetaUrl = require("node:url").pathToFileURL(__filename).href;', + }, + logLevel: 'info', + }) + process.stderr.write(`bundled -> ${bundlePath}\n`) +} + +const SHEBANG = '#!/usr/bin/env node\n' + +/** + * Make bridge.cjs directly runnable on Unix: ensure the node shebang is present + * and set the executable bit. `#!/usr/bin/env node` resolves node from PATH + * (node-on-machine is acceptable; only npx is removed). + */ +const makeBundleExecutable = () => { + const src = readFileSync(bundlePath, 'utf8') + // esbuild preserves bin/cli.js's shebang, so the bundle usually already starts + // with one — only prepend if it's somehow missing (a second line would be a + // syntax error). + if (!src.startsWith('#!')) writeFileSync(bundlePath, `${SHEBANG}${src}`) + chmodSync(bundlePath, 0o755) + process.stderr.write(`executable -> ${bundlePath}\n`) +} + +/** + * Write the Windows launcher: a .cmd that invokes the system node on the + * sibling bridge.cjs. `%~dp0` is the directory of the .cmd, so the two ship + * together. `%*` forwards all args. + */ +const writeWindowsLauncher = () => { + writeFileSync(cmdPath, '@echo off\r\nnode "%~dp0bridge.cjs" %*\r\n') + process.stderr.write(`launcher -> ${cmdPath}\n`) +} + +/** + * Prove the bundle is self-contained: run it on the system node with --help. + * Any unresolved/broken require throws before help prints. On Unix, also smoke + * the executable bundle directly to prove the shebang launcher works end-to-end. + */ +const verify = () => { + run(process.execPath, [bundlePath, '--help'], { stdio: 'ignore' }) + process.stderr.write('bundle smoke (--help via node) ok\n') + if (process.platform !== 'win32') { + run(bundlePath, ['--help'], { stdio: 'ignore' }) + process.stderr.write('executable smoke (--help) ok\n') + } +} + +await bundle() +makeBundleExecutable() +writeWindowsLauncher() +verify() + +process.stderr.write(`\nDone. Run: ${bundlePath} --help\n`)