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/e2e/acp-agents-catalog.spec.ts b/e2e/acp-agents-catalog.spec.ts new file mode 100644 index 000000000..960849e9d --- /dev/null +++ b/e2e/acp-agents-catalog.spec.ts @@ -0,0 +1,44 @@ +/* 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) + + // 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() + + // 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/acp/transports/index.test.ts b/src/acp/transports/index.test.ts index dca112df4..9cee75990 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 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({ + 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..cc83a4c5a 100644 --- a/src/acp/transports/index.ts +++ b/src/acp/transports/index.ts @@ -19,6 +19,13 @@ * (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 + * `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()`. @@ -36,6 +43,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 +112,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 `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)) { + 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 2fbbd7259..3fa8e7b79 100644 --- a/src/components/settings/agents/add-custom-agent-dialog.test.tsx +++ b/src/components/settings/agents/add-custom-agent-dialog.test.tsx @@ -283,6 +283,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 59c294e53..44239af34 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' import type { Agent } from '@/types/acp' /** Maps a user-entered URL to the ACP transport flavor we support, or `null` @@ -182,6 +183,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 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) const handleOpenChange = (next: boolean) => { if (!next) { @@ -254,6 +258,11 @@ export const AddCustomAgentDialog = ({

WebSocket endpoint for the remote ACP agent

+ {showLoopbackHint && ( +

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

+ )}
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..5ddb4011b --- /dev/null +++ b/src/components/settings/agents/agent-catalog-card.tsx @@ -0,0 +1,94 @@ +/* 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, 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 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 + 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}

+
+ + {websiteUrl && ( + + )} + {sourceUrl && ( + + )} +
+
+ +
+ ) +} 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..7dfa73126 --- /dev/null +++ b/src/components/settings/agents/agent-catalog-view.test.tsx @@ -0,0 +1,164 @@ +/* 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, mock } 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, onAddCustomAgent: () => void = () => {}) => + 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('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' } }) + + 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 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[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 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) + }) + + 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..0a198d7db --- /dev/null +++ b/src/components/settings/agents/agent-catalog-view.tsx @@ -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 { 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 = { + /** 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 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) + + 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..200dc5174 --- /dev/null +++ b/src/components/settings/agents/agent-catalog.tsx @@ -0,0 +1,19 @@ +/* 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' + +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 = ({ onAddCustomAgent }: AgentCatalogProps) => { + const entries = useAgentRegistry() + 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..af346dfa2 --- /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 `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) => { + 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{' '} + thunderbolt-stdio-bridge --mode acp 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/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-bridge-command.test.ts b/src/lib/agent-bridge-command.test.ts new file mode 100644 index 000000000..86fc763af --- /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 thunderbolt-stdio-bridge --mode acp', () => { + expect(composeBridgeCommand(entry({ npx: { package: '@agentclientprotocol/claude-agent-acp' } }))).toBe( + '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-stdio-bridge --mode acp -- 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..fa920cf29 --- /dev/null +++ b/src/lib/agent-bridge-command.ts @@ -0,0 +1,61 @@ +/* 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 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 + * 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 `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-stdio-bridge --mode acp -- ${launch}` +} 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/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 = diff --git a/src/routes/settings/agents/index.tsx b/src/routes/settings/agents/index.tsx index 8d898c192..587aabdd0 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' @@ -142,6 +143,8 @@ export default function AgentsSettingsPage({ isStandalone }: AgentsSettingsPageP onDelete={handleDelete} /> + setDialogOpen(true)} /> + { 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 +} 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/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..df2c06982 --- /dev/null +++ b/thunderbolt-stdio-bridge/bin/cli.js @@ -0,0 +1,302 @@ +#!/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. 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 +} + +/** + * 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..209f8c1c4 --- /dev/null +++ b/thunderbolt-stdio-bridge/bun.lock @@ -0,0 +1,259 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "thunderbolt-stdio-bridge", + "dependencies": { + "@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=="], + + "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=="], + + "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=="], + + "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..e4bac6158 --- /dev/null +++ b/thunderbolt-stdio-bridge/package.json @@ -0,0 +1,29 @@ +{ + "name": "thunderbolt-stdio-bridge", + "version": "0.1.0", + "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" + }, + "files": [ + "bin", + "src", + "README.md" + ], + "scripts": { + "test": "bun test", + "build": "node scripts/build-cli.mjs" + }, + "engines": { + "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`) diff --git a/thunderbolt-stdio-bridge/src/args.js b/thunderbolt-stdio-bridge/src/args.js new file mode 100644 index 000000000..5641745ab --- /dev/null +++ b/thunderbolt-stdio-bridge/src/args.js @@ -0,0 +1,182 @@ +/* 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 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-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-stdio-bridge --mode [options] -- [args...] + +Everything after \`--\` is the agent/server command, passed straight to the OS (no shell). + +Options: + --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 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 + +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 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 + * 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, + * mode: 'acp' | 'mcp' | null, + * tunnel: 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, + mode: null, + tunnel: 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 === '--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' } + 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. `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}` } + } + + if (separatorIndex === -1 || agentCmd.length === 0) { + 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-stdio-bridge/src/args.test.js b/thunderbolt-stdio-bridge/src/args.test.js new file mode 100644 index 000000000..49afac620 --- /dev/null +++ b/thunderbolt-stdio-bridge/src/args.test.js @@ -0,0 +1,164 @@ +/* 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(['--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') + expect(r.port).toBe(0) + }) + + it('keeps agent flags that look like bridge flags (they are after --)', () => { + 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(['--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(['--mode', 'acp', '--port=8123', '--', 'agent']) + expect(r.port).toBe(8123) + }) + + it('parses --host before --', () => { + 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(['--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(['--mode', 'acp', '--', 'agent']) + expect(r.allowOrigins).toEqual([]) + expect(r.allowAnyOrigin).toBe(false) + }) + + it('collects repeatable --allow-origin values', () => { + 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() + }) + + 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(['--mode', 'acp', '--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') + }) + + 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-stdio-bridge/src/errors.js b/thunderbolt-stdio-bridge/src/errors.js new file mode 100644 index 000000000..0b03e2679 --- /dev/null +++ b/thunderbolt-stdio-bridge/src/errors.js @@ -0,0 +1,129 @@ +/* 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) + * 70 (EX_SOFTWARE) — a required external dependency is missing (cloudflared) + * 130 (128+SIGINT) — clean Ctrl-C stop + * 0 — clean shutdown + */ + +export const exitCodes = { + ok: 0, + usage: 64, + unavailable: 69, + dependencyMissing: 70, + 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: `thunderbolt-stdio-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 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 + * @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 it was ready — try running ${cmd0} directly to see why`, + exitCode: exitCodes.unavailable, + } +} diff --git a/thunderbolt-stdio-bridge/src/errors.test.js b/thunderbolt-stdio-bridge/src/errors.test.js new file mode 100644 index 000000000..483f233cf --- /dev/null +++ b/thunderbolt-stdio-bridge/src/errors.test.js @@ -0,0 +1,109 @@ +/* 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, + tunnelError, + earlyExitError, +} from './errors.js' + +describe('exitCodes', () => { + it('uses sysexits-style codes', () => { + expect(exitCodes).toEqual({ ok: 0, usage: 64, unavailable: 69, dependencyMissing: 70, 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('thunderbolt-stdio-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('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' }) + 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/thunderbolt-stdio-bridge/src/log.js b/thunderbolt-stdio-bridge/src/log.js new file mode 100644 index 000000000..93fd2593d --- /dev/null +++ b/thunderbolt-stdio-bridge/src/log.js @@ -0,0 +1,313 @@ +/* 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 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, + * 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 + 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', + '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', + // 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, 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} + */ +export const safeMethod = (method) => { + if (typeof method !== 'string') return undefined + 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 + * 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. + * Shared by the ACP log extractor and the MCP face's PII-safe event extractor. + * @param {Record} obj + * @returns {'request' | 'response' | 'notification' | 'error'} + */ +export 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 (+ 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. + */ +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', +]) + +/** + * 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/thunderbolt-stdio-bridge/src/log.test.js b/thunderbolt-stdio-bridge/src/log.test.js new file mode 100644 index 000000000..662b7a99a --- /dev/null +++ b/thunderbolt-stdio-bridge/src/log.test.js @@ -0,0 +1,243 @@ +/* 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.thunderbolt.io')).toBe('https://app.thunderbolt.io') + }) + + 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('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) + }) + + 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/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..140af59d3 --- /dev/null +++ b/thunderbolt-stdio-bridge/src/mcp-server.js @@ -0,0 +1,411 @@ +/* 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, 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 + * 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 + + // 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 + // 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. 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')) + }) + }) + + 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..82c0987af --- /dev/null +++ b/thunderbolt-stdio-bridge/src/mcp-server.test.js @@ -0,0 +1,422 @@ +/* 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 }) + }) +}) + +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/relay.js b/thunderbolt-stdio-bridge/src/relay.js new file mode 100644 index 000000000..73cae22be --- /dev/null +++ b/thunderbolt-stdio-bridge/src/relay.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/. */ + +/** + * 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. + */ + +/** + * 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 {Record | null} + */ +export const parseRpcObject = (line) => { + const trimmed = line.trim() + if (trimmed.length === 0) return null + try { + const parsed = JSON.parse(trimmed) + return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null + } catch { + 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. + * + * @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/thunderbolt-stdio-bridge/src/relay.test.js b/thunderbolt-stdio-bridge/src/relay.test.js new file mode 100644 index 000000000..15448bb8e --- /dev/null +++ b/thunderbolt-stdio-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/thunderbolt-stdio-bridge/src/server.js b/thunderbolt-stdio-bridge/src/server.js new file mode 100644 index 000000000..33d79e055 --- /dev/null +++ b/thunderbolt-stdio-bridge/src/server.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/. */ + +/** + * 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, emitInsecureFlagWarnings } 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] + + emitInsecureFlagWarnings({ host, allowAnyOrigin, logger }) + + 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) }), + }) + + // 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 + // 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) + }) +} diff --git a/thunderbolt-stdio-bridge/src/server.test.js b/thunderbolt-stdio-bridge/src/server.test.js new file mode 100644 index 000000000..40e3fa326 --- /dev/null +++ b/thunderbolt-stdio-bridge/src/server.test.js @@ -0,0 +1,485 @@ +/* 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 +} + +/** + * 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 + 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 + let lines = 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) => { + lines = makeFakeLineReader(stream) + 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, + getLines: () => lines, + } +} + +/** 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('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 + + // 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 () => { + 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('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(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') + 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(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) + 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) + + 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: (stream) => makeFakeLineReader(stream), + 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: (stream) => makeFakeLineReader(stream), + 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: (stream) => makeFakeLineReader(stream), + 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) + }) + + 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: (stream) => makeFakeLineReader(stream), + 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) + }) + + 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)', () => { + 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=') + }) +}) 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..566718294 --- /dev/null +++ b/thunderbolt-stdio-bridge/src/util.js @@ -0,0 +1,78 @@ +/* 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) + +/** + * 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([]) + }) +})