Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
9317fd0
feat: add ACP registry data model, parser, and live-fetch hook
ital0 Jun 16, 2026
e5279a3
feat: add browseable ACP agent catalog UI to settings
ital0 Jun 16, 2026
e60efb0
fix: clear button now resets agent catalog search
ital0 Jun 16, 2026
6f39720
feat: add acp-bridge package for local stdio ACP agents
ital0 Jun 16, 2026
54974c4
feat: route loopback agent connections directly, skipping the proxy
ital0 Jun 16, 2026
344c9e9
feat: add Connect via bridge action to the agent catalog
ital0 Jun 16, 2026
a3a289b
docs: add acp-bridge usage guide
ital0 Jun 16, 2026
f093a43
fix: correct Thunderbolt link to thunderbolt.io
ital0 Jun 16, 2026
1208c9f
fix: kill spawned agent on fatal bind error to prevent orphan
ital0 Jun 16, 2026
a674446
fix: drop stale ws connections to stop superseded stdin injection
ital0 Jun 16, 2026
4bdb67a
docs: document grace-timer early-return safety invariant
ital0 Jun 16, 2026
cb6dbb4
fix: accept 127.0.0.1 and [::1] dev origins on bridge ws allowlist
ital0 Jun 16, 2026
5832c1d
fix: exit 69 when the agent dies by signal after startup
ital0 Jun 16, 2026
10f8b69
fix: bracket IPv6 literal host in the bridge banner URL
ital0 Jun 16, 2026
7b1df21
fix: pause agent relay while no client connected to avoid dropped output
ital0 Jun 16, 2026
5318b45
chore: rename package to thunderbolt-acp-bridge
ital0 Jun 16, 2026
96d2eb7
fix: avoid double-bracketing an already-bracketed IPv6 host in banner
ital0 Jun 16, 2026
199d4a6
feat: bridge both MCP and ACP local stdio agents via thunderbolt-stdi…
ital0 Jun 18, 2026
2f8ff63
refactor: point bridge command refs at thunderbolt-stdio-bridge --mod…
ital0 Jun 18, 2026
7bd8277
feat: connect loopback MCP servers natively, skipping the cloud proxy
ital0 Jun 18, 2026
e524294
fix: hold agent output before the first client connects by pausing th…
ital0 Jun 18, 2026
661bf2e
fix: emit insecure-flag warnings in MCP mode and keep handler logs PI…
ital0 Jun 18, 2026
d7647ee
Merge branch 'main' into italomenezes/thu-600-acp-marketplace-browse-…
ital0 Jun 19, 2026
d0353a1
Merge branch 'italomenezes/thu-600-acp-marketplace-browse-catalog-bri…
ital0 Jun 22, 2026
6e7aace
Merge branch 'main' into italomenezes/thu-600-acp-marketplace-browse-…
ital0 Jun 23, 2026
141c37c
Merge remote-tracking branch 'origin/main' into italomenezes/thu-601-…
ital0 Jun 23, 2026
34bc20a
Merge remote-tracking branch 'origin/italomenezes/thu-600-acp-marketp…
ital0 Jun 23, 2026
aa4e5d9
build: bundle stdio-bridge as a portable CLI run on the system node
ital0 Jun 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions .github/workflows/stdio-bridge-build.yml
Original file line number Diff line number Diff line change
@@ -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
44 changes: 44 additions & 0 deletions e2e/acp-agents-catalog.spec.ts
Original file line number Diff line number Diff line change
@@ -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([])
})
})
51 changes: 51 additions & 0 deletions src/acp/transports/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
})
16 changes: 15 additions & 1 deletion src/acp/transports/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()`.
Expand All @@ -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 = {
Expand Down Expand Up @@ -104,10 +112,16 @@ export const openTransport = async (inputs: OpenTransportInputs): Promise<AcpTra
* When the proxied path is selected, `createProxyWebSocket` returns a sync
* factory that builds the `Sec-WebSocket-Protocol` list (carrier + bearer +
* target) synchronously from the in-memory bearer token. */
const resolveWebSocketFactory = (inputs: OpenTransportInputs): WebSocketFactory => {
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
}
Expand Down
27 changes: 27 additions & 0 deletions src/acp/transports/is-loopback.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
46 changes: 46 additions & 0 deletions src/acp/transports/is-loopback.ts
Original file line number Diff line number Diff line change
@@ -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
}
}
13 changes: 13 additions & 0 deletions src/components/settings/agents/add-custom-agent-dialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }))

Expand Down
9 changes: 9 additions & 0 deletions src/components/settings/agents/add-custom-agent-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -254,6 +258,11 @@ export const AddCustomAgentDialog = ({
<p className="text-[length:var(--font-size-xs)] text-muted-foreground">
WebSocket endpoint for the remote ACP agent
</p>
{showLoopbackHint && (
<p className="text-[length:var(--font-size-xs)] text-muted-foreground">
Your browser may ask permission to reach your local network — click Allow.
</p>
)}
</div>
<div className="grid gap-2">
<Label htmlFor="agent-description">Description</Label>
Expand Down
Loading
Loading