From 91eda76f96580409f333fef22017294d79dd12ee Mon Sep 17 00:00:00 2001 From: Chris Roth Date: Fri, 24 Jul 2026 16:18:24 -0400 Subject: [PATCH] refactor: harden CLI auth/bridge/webfetch and PAT limits from deep review Review fixes for the CLI backend-auth work (#1064/#1135), split out of the settings-redesign branch to keep that PR design-only: - enable a generous per-key PAT rate limit (300/min) instead of disabling it - single-source the webfetch tool name across the permission gates and ACP kind mapping; document the auto-allow rationale - unify the CLI's two FetchFn types; interpolate the heartbeat banner interval - isolate bridge heartbeat tick failures and connection-close cleanup - replace object-boxed loop counters in the webfetch HTML scanner; narrow the DNS-validation types to drop a dead optional chain - export the 90-day PAT expiry as a named constant shared with test fixtures - fix the RFC 8628 citation scope and pat-lifecycle doc wording Co-authored-by: Cursor --- backend/docs/pat-lifecycle.md | 4 +- backend/src/api/encryption.test.ts | 2 +- backend/src/api/powersync.test.ts | 4 +- backend/src/auth/auth.ts | 10 +++-- backend/src/config/settings.ts | 12 +++--- backend/src/dal/devices.test.ts | 13 ++++-- backend/src/test-utils/settings.ts | 4 +- cli/src/acp/harness-agent.ts | 4 +- cli/src/acp/harness-to-acp.ts | 3 +- cli/src/agent/permissions.ts | 6 ++- cli/src/agent/webfetch.ts | 65 +++++++++++++++-------------- cli/src/auth/http-transport.test.ts | 2 +- cli/src/auth/http-transport.ts | 7 ++-- cli/src/iroh/account-allowlist.ts | 5 +-- cli/src/iroh/bridge.test.ts | 2 +- cli/src/iroh/bridge.ts | 46 +++++++++++++------- 16 files changed, 112 insertions(+), 77 deletions(-) diff --git a/backend/docs/pat-lifecycle.md b/backend/docs/pat-lifecycle.md index 422f8d4cd..8b1c32a2c 100644 --- a/backend/docs/pat-lifecycle.md +++ b/backend/docs/pat-lifecycle.md @@ -17,7 +17,7 @@ curl --fail-with-body --silent --show-error \ --data '{"name":"ci"}' ``` -Response contains plaintext `key` once. Store that value as `THUNDERBOLT_TOKEN`. New keys expire after `API_KEY_DEFAULT_EXPIRES_IN` seconds; default is `7776000` seconds (90 days). Creation may include `expiresIn` in seconds to request another plugin-supported lifetime (currently 1–365 days). Listing never returns plaintext key. +Response contains the plaintext `key` once. Store that value as `THUNDERBOLT_TOKEN`. New keys expire after `API_KEY_DEFAULT_EXPIRES_IN` seconds; the default is `7776000` seconds (90 days). The create request may include `expiresIn` (seconds) to request a different lifetime supported by the plugin (currently 1–365 days). Listing never returns the plaintext key. ## List @@ -39,4 +39,4 @@ curl --fail-with-body --silent --show-error \ --data '{"keyId":""}' ``` -Deletion revokes key immediately. If PAT may be compromised, revoke it, replace stored `THUNDERBOLT_TOKEN`, and issue a new key. CLI reads PAT from `THUNDERBOLT_TOKEN` and sends it as `x-api-key`; API-key sessions and disabled per-key rate limiting are deliberate for headless automation. Account/IP-level limits still apply. +Deletion revokes the key immediately. If a PAT may be compromised, revoke it, replace the stored `THUNDERBOLT_TOKEN`, and issue a new key. The CLI reads the PAT from `THUNDERBOLT_TOKEN` and sends it as `x-api-key`; API-key sessions carry a generous per-key rate limit (300 requests/minute) sized for headless automation. Account/IP-level limits still apply. diff --git a/backend/src/api/encryption.test.ts b/backend/src/api/encryption.test.ts index 707b77453..e5017bbb5 100644 --- a/backend/src/api/encryption.test.ts +++ b/backend/src/api/encryption.test.ts @@ -1680,7 +1680,7 @@ describe('Encryption API', () => { expect((await response.json()).nodeIds).toEqual([]) }) - it('never leaks another account rows', async () => { + it("never leaks another account's rows", async () => { await createUserAndSession(p('u-al-a'), p('tok-al-a'), `${p('al-a')}@test.com`) await createUserAndSession(p('u-al-b'), p('tok-al-b'), `${p('al-b')}@test.com`) await insertDeviceWithNode(p('al-mine'), p('u-al-a'), 'node-mine') diff --git a/backend/src/api/powersync.test.ts b/backend/src/api/powersync.test.ts index 0bda64d28..6f37c4d37 100644 --- a/backend/src/api/powersync.test.ts +++ b/backend/src/api/powersync.test.ts @@ -10,7 +10,7 @@ import { createTestDb } from '@/test-utils/db' import { createHmac } from 'crypto' import { eq } from 'drizzle-orm' import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test' -import { clearSettingsCache } from '@/config/settings' +import { clearSettingsCache, defaultApiKeyExpirySeconds } from '@/config/settings' import { Elysia } from 'elysia' import { createPowerSyncRoutes } from './powersync' @@ -61,7 +61,7 @@ const powersyncSettings: Settings = { betterAuthSecret, deviceAuthExpiresIn: '30m', deviceAuthInterval: '5s', - apiKeyDefaultExpiresInSeconds: 90 * 24 * 60 * 60, + apiKeyDefaultExpiresInSeconds: defaultApiKeyExpirySeconds, e2eeEnabled: true, rateLimitEnabled: false, swaggerEnabled: false, diff --git a/backend/src/auth/auth.ts b/backend/src/auth/auth.ts index f9a8ace63..429cedd3d 100644 --- a/backend/src/auth/auth.ts +++ b/backend/src/auth/auth.ts @@ -393,13 +393,15 @@ export const createAuth = (database: typeof DbType, emailDeps: AuthEmailDeps = { // for the key's owner when an `x-api-key` header is present, so a key authenticates as the // account that created it — the escape hatch when the interactive device grant can't run. // The plugin's per-key rate limit defaults to 10 requests/day, which is unusable for - // automation; disable it and rely on the account/IP-level limits already in this stack. - // A leaked PAT is mitigated by expiry and revoking the key (same posture as a - // compromised device). Installed plugin runtime interprets defaultExpiresIn as seconds. + // automation; raise it to a generous per-minute budget instead of disabling it, so PAT + // traffic keeps a per-credential throttle on top of the account/IP-level limits (the + // Better Auth IP limiter is in-memory and single-instance only). A leaked PAT is further + // mitigated by expiry and revoking the key (same posture as a compromised device). + // Installed plugin runtime interprets defaultExpiresIn as seconds. apiKey({ enableSessionForAPIKeys: true, keyExpiration: { defaultExpiresIn: settings.apiKeyDefaultExpiresInSeconds }, - rateLimit: { enabled: false }, + rateLimit: { enabled: true, timeWindow: 60_000, maxRequests: 300 }, }), // Anonymous plugin is operator-gated: register only when AUTH_ALLOW_ANONYMOUS=true. // Otherwise /v1/api/auth/sign-in/anonymous returns 404 — defense-in-depth against diff --git a/backend/src/config/settings.ts b/backend/src/config/settings.ts index 3b61e3b61..950f58a11 100644 --- a/backend/src/config/settings.ts +++ b/backend/src/config/settings.ts @@ -8,6 +8,9 @@ const betterAuthTimeString = z.string().regex(/^\d+[smhd]$/, { message: 'must be a Better Auth time string (digits followed by s, m, h, or d)', }) +/** Default PAT lifetime (90 days, in seconds) — exported so test fixtures can't drift from the schema default. */ +export const defaultApiKeyExpirySeconds = 90 * 24 * 60 * 60 + /** * Settings schema for environment variables validation */ @@ -58,17 +61,14 @@ const settingsSchema = z // Device Authorization Grant (RFC 8628) — used by the `thunderbolt` CLI to log in // headless. `deviceAuthExpiresIn` is how long the device/user code stays valid before // the CLI must restart the flow; `deviceAuthInterval` is the minimum client polling - // gap. Better Auth time strings ('30m', '5s', '1h'). Defaults follow RFC 8628 §3.2. + // gap. Better Auth time strings ('30m', '5s', '1h'). The 5s interval default follows + // RFC 8628 §3.2; the 30m code lifetime is our own choice (the RFC specifies none). deviceAuthExpiresIn: betterAuthTimeString.default('30m'), deviceAuthInterval: betterAuthTimeString.default('5s'), // Better Auth API-key expiry values use seconds at runtime. New PATs expire after // 90 days by default; callers may request a different supported lifetime at creation. - apiKeyDefaultExpiresInSeconds: z.coerce - .number() - .int() - .positive() - .default(90 * 24 * 60 * 60), + apiKeyDefaultExpiresInSeconds: z.coerce.number().int().positive().default(defaultApiKeyExpirySeconds), // General settings logLevel: z.enum(['DEBUG', 'INFO', 'WARN', 'ERROR']).default('INFO'), diff --git a/backend/src/dal/devices.test.ts b/backend/src/dal/devices.test.ts index 9698f8a06..44bfe4211 100644 --- a/backend/src/dal/devices.test.ts +++ b/backend/src/dal/devices.test.ts @@ -258,10 +258,10 @@ describe('devices DAL', () => { }) describe('setDeviceNodeId', () => { - const seedDevice = (over: Record) => + const seedDevice = (overrides: Record) => db .insert(devicesTable) - .values({ id: 'd-bind', userId, name: 'Bind', lastSeen: new Date(), createdAt: new Date(), ...over }) + .values({ id: 'd-bind', userId, name: 'Bind', lastSeen: new Date(), createdAt: new Date(), ...overrides }) it('binds a node_id on a trusted device', async () => { await seedDevice({ trusted: true, approvalPending: false }) @@ -294,11 +294,16 @@ describe('devices DAL', () => { const seed = ( id: string, nodeId: string | null, - over: { trusted?: boolean; approvalPending?: boolean; revokedAt?: Date; deviceType?: 'normal' | 'bridge' } = {}, + overrides: { + trusted?: boolean + approvalPending?: boolean + revokedAt?: Date + deviceType?: 'normal' | 'bridge' + } = {}, forUserId = userId, ) => { const now = new Date() - const { trusted = true, approvalPending = !trusted, revokedAt, deviceType = 'normal' } = over + const { trusted = true, approvalPending = !trusted, revokedAt, deviceType = 'normal' } = overrides return db.insert(devicesTable).values({ id, userId: forUserId, diff --git a/backend/src/test-utils/settings.ts b/backend/src/test-utils/settings.ts index f06cf646e..cedb1899e 100644 --- a/backend/src/test-utils/settings.ts +++ b/backend/src/test-utils/settings.ts @@ -2,7 +2,7 @@ * 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 { Settings } from '@/config/settings' +import { defaultApiKeyExpirySeconds, type Settings } from '@/config/settings' /** * Creates a fully-populated `Settings` object for tests. @@ -35,7 +35,7 @@ export const createTestSettings = (overrides: Partial = {}): Settings betterAuthSecret: 'test-secret-at-least-32-chars-long!!', deviceAuthExpiresIn: '30m', deviceAuthInterval: '5s', - apiKeyDefaultExpiresInSeconds: 90 * 24 * 60 * 60, + apiKeyDefaultExpiresInSeconds: defaultApiKeyExpirySeconds, logLevel: 'INFO' as const, port: 8000, appUrl: 'http://localhost:1420', diff --git a/cli/src/acp/harness-agent.ts b/cli/src/acp/harness-agent.ts index 8da78c357..cb6802b42 100644 --- a/cli/src/acp/harness-agent.ts +++ b/cli/src/acp/harness-agent.ts @@ -54,6 +54,7 @@ import { cliVersion } from '../cli.ts' import { buildHarness } from '../agent/harness.ts' import type { HarnessConfig, ServeConfig } from '../agent/types.ts' import { isExistingPathInWorkspace } from '../agent/workspace-jail.ts' +import { webFetchToolName } from '../agent/webfetch.ts' import { createHarnessToAcpTranslator, toAcpStopReason, toToolKind } from './harness-to-acp.ts' import type { SessionStore } from './session-store.ts' @@ -176,7 +177,8 @@ const attachAcpPermissionGate = ( const sessionAllowed = new Set() harness.registerToolCallGate(async ({ toolCallId, toolName, input }) => { - if (toolName === 'webfetch') return undefined + // webfetch is auto-allowed: read-only and SSRF-guarded, so no prompt. + if (toolName === webFetchToolName) return undefined if (isReadOnlyAgentTool(toolName)) { const path = typeof input === 'object' && input !== null && 'path' in input && typeof input.path === 'string' diff --git a/cli/src/acp/harness-to-acp.ts b/cli/src/acp/harness-to-acp.ts index b605c0442..802f4487b 100644 --- a/cli/src/acp/harness-to-acp.ts +++ b/cli/src/acp/harness-to-acp.ts @@ -26,9 +26,10 @@ import type { StopReason as PiStopReason } from '@earendil-works/pi-ai' import type { AgentHarnessEvent } from '@earendil-works/pi-agent-core' import type { SessionUpdate, StopReason, ToolCallContent } from '@agentclientprotocol/sdk' import { toAcpToolKind } from '../../../shared/agent-tool-permissions.ts' +import { webFetchToolName } from '../agent/webfetch.ts' /** Map a built-in tool name to its ACP {@link ToolKind}. */ -export const toToolKind = (toolName: string) => (toolName === 'webfetch' ? 'fetch' : toAcpToolKind(toolName)) +export const toToolKind = (toolName: string) => (toolName === webFetchToolName ? 'fetch' : toAcpToolKind(toolName)) /** * Map a Pi {@link PiStopReason} to the ACP {@link StopReason} returned from diff --git a/cli/src/agent/permissions.ts b/cli/src/agent/permissions.ts index 8668d70a7..fdefc789c 100644 --- a/cli/src/agent/permissions.ts +++ b/cli/src/agent/permissions.ts @@ -5,6 +5,7 @@ import type { AgentHarness } from '@earendil-works/pi-agent-core' import { isReadOnlyAgentTool } from '../../../shared/agent-tool-permissions.ts' import { sanitizePermissionText } from '../ui/render.ts' +import { webFetchToolName } from './webfetch.ts' import type { PermissionPrompt, PermissionRequest } from './types.ts' /** @@ -41,7 +42,10 @@ export const attachPermissionGate = (harness: AgentHarness, opts: { yolo: boolea const sessionAllowed = new Set() harness.on('tool_call', async ({ toolName, input }) => { - if (isReadOnlyAgentTool(toolName) || toolName === 'webfetch' || sessionAllowed.has(toolName)) return undefined + // webfetch is auto-allowed: read-only and SSRF-guarded, so no prompt. + if (isReadOnlyAgentTool(toolName) || toolName === webFetchToolName || sessionAllowed.has(toolName)) { + return undefined + } const request: PermissionRequest = { toolName, summary: summarize(toolName, input) } const decision = await opts.ask(request) diff --git a/cli/src/agent/webfetch.ts b/cli/src/agent/webfetch.ts index e985079d4..823d8a03b 100644 --- a/cli/src/agent/webfetch.ts +++ b/cli/src/agent/webfetch.ts @@ -7,6 +7,9 @@ import type { AgentTool } from '@earendil-works/pi-agent-core' import { lookup } from 'node:dns/promises' import { isPrivateOrInternalAddress, parseIpAddress } from '../../../shared/ip-classification.ts' +/** Single source for the tool's name — the permission gates and ACP kind mapping key off it. */ +export const webFetchToolName = 'webfetch' + const defaultTimeoutMs = 15_000 const defaultMaxResponseBytes = 1_500_000 const defaultMaxTextLength = 100_000 @@ -91,14 +94,15 @@ const validateAndPin = async (url: URL, resolve: WebFetchResolver): Promise<[pin const addresses = await resolve(hostname) if (addresses.length === 0) throw new Error(`webfetch could not resolve hostname: ${hostname}`) - const parsedAddresses = addresses.map(({ address }) => parseIpAddress(address)) - if (parsedAddresses.some((address) => !address || isPrivateOrInternalAddress(address))) { - throw new Error(privateAddressError) - } + const parsedAddresses = addresses.map(({ address }) => { + const parsed = parseIpAddress(address) + if (!parsed || isPrivateOrInternalAddress(parsed)) throw new Error(privateAddressError) + return parsed + }) const pinnedUrl = new URL(parsedUrl) const firstAddress = addresses[0].address - pinnedUrl.hostname = parsedAddresses[0]?.version === 6 ? `[${firstAddress}]` : firstAddress + pinnedUrl.hostname = parsedAddresses[0].version === 6 ? `[${firstAddress}]` : firstAddress const headers = new Headers() headers.set('Host', hostname) @@ -175,24 +179,22 @@ const isRawTextElement = (name: string): name is RawTextElement => rawTextElemen const readTagName = (tag: string): { readonly closing: boolean; readonly name: string } => { const closing = tag.startsWith('/') const nameStart = closing ? 1 : 0 - const nameEnd = { value: nameStart } - while (isTagNameCharacter(tag[nameEnd.value] ?? '')) nameEnd.value += 1 - return { closing, name: tag.slice(nameStart, nameEnd.value).toLowerCase() } + let nameEnd = nameStart + while (isTagNameCharacter(tag[nameEnd] ?? '')) nameEnd += 1 + return { closing, name: tag.slice(nameStart, nameEnd).toLowerCase() } } /** Test whether a completed opening tag closes itself. */ const isSelfClosingTag = (tag: string): boolean => { - const index = { value: tag.length - 1 } - while (isHtmlWhitespace(tag[index.value])) index.value -= 1 - return tag[index.value] === '/' + let index = tag.length - 1 + while (isHtmlWhitespace(tag[index])) index -= 1 + return tag[index] === '/' } /** Test a case-insensitive substring without allocating a normalized copy. */ const matchesCaseInsensitive = (value: string, index: number, expected: string): boolean => { - const offset = { value: 0 } - while (offset.value < expected.length) { - if (value[index + offset.value]?.toLowerCase() !== expected[offset.value]) return false - offset.value += 1 + for (let offset = 0; offset < expected.length; offset += 1) { + if (value[index + offset]?.toLowerCase() !== expected[offset]) return false } return true } @@ -202,9 +204,9 @@ const findRawTextCloseEnd = (html: string, index: number, element: RawTextElemen if (html[index] !== '<' || html[index + 1] !== '/') return undefined if (!matchesCaseInsensitive(html, index + 2, element)) return undefined - const closingIndex = { value: index + element.length + 2 } - while (isHtmlWhitespace(html[closingIndex.value])) closingIndex.value += 1 - return html[closingIndex.value] === '>' ? closingIndex.value + 1 : undefined + let closingIndex = index + element.length + 2 + while (isHtmlWhitespace(html[closingIndex])) closingIndex += 1 + return html[closingIndex] === '>' ? closingIndex + 1 : undefined } /** Restore an unfinished ordinary tag as text for terminal angle-bracket escaping. */ @@ -335,20 +337,21 @@ const readCappedBody = async ( const reader = response.body.getReader() const chunks: Uint8Array[] = [] - const state = { bytesRead: 0, truncated: false } + let bytesRead = 0 + let truncated = false try { - while (state.bytesRead <= maxBytes) { + while (bytesRead <= maxBytes) { const { value, done } = await reader.read() if (done) break - const remaining = maxBytes - state.bytesRead + const remaining = maxBytes - bytesRead if (value.byteLength <= remaining) { chunks.push(value) - state.bytesRead += value.byteLength + bytesRead += value.byteLength continue } if (remaining > 0) chunks.push(value.subarray(0, remaining)) - state.bytesRead = maxBytes - state.truncated = true + bytesRead = maxBytes + truncated = true await reader.cancel() break } @@ -356,13 +359,13 @@ const readCappedBody = async ( reader.releaseLock() } - const bytes = new Uint8Array(state.bytesRead) - const offset = { value: 0 } + const bytes = new Uint8Array(bytesRead) + let offset = 0 for (const chunk of chunks) { - bytes.set(chunk, offset.value) - offset.value += chunk.byteLength + bytes.set(chunk, offset) + offset += chunk.byteLength } - return { bytes, truncated: state.truncated } + return { bytes, truncated } } /** Limit model-visible text and append one explicit truncation marker. */ @@ -383,8 +386,8 @@ export const createWebFetchTool = ( const maxTextLength = dependencies.maxTextLength ?? defaultMaxTextLength return { - name: 'webfetch', - label: 'webfetch', + name: webFetchToolName, + label: webFetchToolName, description: 'Read a specific HTTP or HTTPS URL from the web and return readable text. Use web_search first when you need to discover URLs.', parameters: webFetchSchema, diff --git a/cli/src/auth/http-transport.test.ts b/cli/src/auth/http-transport.test.ts index 6954b7cd3..98712436e 100644 --- a/cli/src/auth/http-transport.test.ts +++ b/cli/src/auth/http-transport.test.ts @@ -20,7 +20,7 @@ const authBase = 'https://api.test/v1/api/auth' const stubFetch = (response: Response) => { const requests: { url: string; body: unknown }[] = [] const fetchFn: FetchFn = async (url, init) => { - requests.push({ url, body: JSON.parse(String(init.body)) }) + requests.push({ url, body: JSON.parse(String(init?.body)) }) return response } return { fetchFn, requests } diff --git a/cli/src/auth/http-transport.ts b/cli/src/auth/http-transport.ts index d6221f4e1..3f82e00e7 100644 --- a/cli/src/auth/http-transport.ts +++ b/cli/src/auth/http-transport.ts @@ -44,9 +44,10 @@ const mapPollError = (error: string): TokenPollResult => { throw new Error(`device authorization failed: ${error}`) } -/** The subset of `fetch` this transport uses; injectable so the wire contract can - * be unit-tested without a real network. */ -export type FetchFn = (url: string, init: RequestInit) => Promise +/** The subset of `fetch` the CLI's wire clients use; injectable so wire contracts + * can be unit-tested without a real network. Shared with the iroh account + * allowlist client so the CLI has one `FetchFn`, not two drifting copies. */ +export type FetchFn = (url: string, init?: RequestInit) => Promise /** * Build a {@link DeviceGrantTransport} bound to a Better Auth base URL diff --git a/cli/src/iroh/account-allowlist.ts b/cli/src/iroh/account-allowlist.ts index 6c6ffc7d0..b3e30aa4e 100644 --- a/cli/src/iroh/account-allowlist.ts +++ b/cli/src/iroh/account-allowlist.ts @@ -16,11 +16,10 @@ */ import { apiBaseUrl } from '../auth/config.ts' +import type { FetchFn } from '../auth/http-transport.ts' import type { BridgeCredential } from '../auth/token-store.ts' -/** The subset of `fetch` this client uses; injected so the wire contract is - * unit-testable without a real network (mirrors {@link auth/http-transport}). */ -export type FetchFn = (url: string, init?: RequestInit) => Promise +export type { FetchFn } from '../auth/http-transport.ts' /** `GET /devices/allowlist` 200 body: one row per trusted, non-revoked device that * has bound an iroh identity. `nodeId` is non-null in practice (the query filters diff --git a/cli/src/iroh/bridge.test.ts b/cli/src/iroh/bridge.test.ts index 416b21718..5c3736e98 100644 --- a/cli/src/iroh/bridge.test.ts +++ b/cli/src/iroh/bridge.test.ts @@ -71,7 +71,7 @@ describe('renderIrohBridgeBanner', () => { 'https://app.example.com', ) - expect(banner).toContain(' pair in Thunderbolt app: https://app.example.com/settings/mcp-servers\n') + expect(banner).toContain(' pair in Thunderbolt app: https://app.example.com/settings/connections\n') }) }) diff --git a/cli/src/iroh/bridge.ts b/cli/src/iroh/bridge.ts index 324f87238..b379ced20 100644 --- a/cli/src/iroh/bridge.ts +++ b/cli/src/iroh/bridge.ts @@ -341,7 +341,15 @@ export const startMembershipHeartbeat = ( while (running) { await clock.sleep(intervalMs) if (!running) break - await heartbeatTick(accountAllowlist, openConnections) + try { + await heartbeatTick(accountAllowlist, openConnections) + } catch (err) { + // heartbeatTick isolates its own failures; this backstop keeps the loop + // alive even if an injected allowlist throws where the built-in soft-fails. + process.stderr.write( + `⚡ iroh bridge: heartbeat tick failed: ${err instanceof Error ? err.message : String(err)}\n`, + ) + } } })() return () => { @@ -360,12 +368,12 @@ export const handleConnection = async ( config: BridgeConfig, activeProcs: Set, guard: { release: () => void }, - opts: HandleConnectionOptions = {}, + options: HandleConnectionOptions = {}, ): Promise => { const connection = await handshake(incoming, guard) const remoteId = connection.remoteId().toString() - if (!(await isConnectionAllowed(remoteId, opts.accountAllowlist))) { + if (!(await isConnectionAllowed(remoteId, options.accountAllowlist))) { process.stderr.write(`⚡ iroh bridge: refused ${remoteId} (not allowlisted)\n`) connection.close(closeRefused, reasonBytes('not allowlisted')) return @@ -376,7 +384,7 @@ export const handleConnection = async ( // runs before its data plane exists. The wait is bounded: a peer that completes // the handshake (and passes the allowlist) but never opens the stream is closed // rather than left to pin the connection forever. - const bi = await acceptBidiStream(connection, opts.acceptTimeoutMs ?? defaultAcceptTimeoutMs) + const bi = await acceptBidiStream(connection, options.acceptTimeoutMs ?? defaultAcceptTimeoutMs) if (!bi) { process.stderr.write(`⚡ iroh bridge: closed ${remoteId} (idle: no data stream)\n`) return @@ -397,12 +405,20 @@ export const handleConnection = async ( return } activeProcs.add(proc) - const openConnections = opts.openConnections + const openConnections = options.openConnections if (openConnections) { const open: OpenConnection = { remoteId, connection } openConnections.add(open) const removeOpenConnection = (): boolean => openConnections.delete(open) - void connection.closed().then(removeOpenConnection, removeOpenConnection) + void (async () => { + try { + await connection.closed() + } catch { + // A rejected closed() still means the connection is gone; prune regardless. + } finally { + removeOpenConnection() + } + })() } void proc.exited.then(() => activeProcs.delete(proc)) // A dropped connection kills the agent and removes the heartbeat registry entry. @@ -432,7 +448,7 @@ type AccountTrust = { readonly accountAllowlist: AccountAllowlist; readonly stop /** Format the startup banner's account-trust status from the initialized trust state. */ export const accountTrustBanner = (enabled: boolean): string => enabled - ? ' same-account auto-trust: on (backend allowlist, 45s heartbeat)\n' + ? ` same-account auto-trust: on (backend allowlist, ${heartbeatIntervalMs / 1000}s heartbeat)\n` : ' same-account auto-trust: off (manual allowlist only)\n' + ' allow a peer with: thunderbolt iroh allow \n' @@ -452,7 +468,7 @@ export const renderIrohBridgeBanner = ( accountTrustEnabled: boolean, appUrl: string = resolveAppUrl(), ): string => { - const settingsPath = config.protocol === 'acp' ? '/settings/agents' : '/settings/mcp-servers' + const settingsPath = config.protocol === 'acp' ? '/settings/agents' : '/settings/connections' const pairingUrl = `${appUrl.replace(/\/+$/, '')}${settingsPath}` return ( `⚡ thunderbolt ${config.protocol} bridge (iroh) ready\n` + @@ -550,15 +566,17 @@ export const runIrohBridge = async (config: BridgeConfig): Promise => { // Only track open connections when the heartbeat is live to re-check them; in // Standalone there is nothing to consume the registry, so we don't populate it. - const opts: HandleConnectionOptions = accountTrust + const connectionOptions: HandleConnectionOptions = accountTrust ? { accountAllowlist: accountTrust.accountAllowlist, openConnections } : {} while (true) { const incoming = await endpoint.acceptNext() if (!incoming) break - void admitConnection(incoming, config, activeProcs, handshakeBudget, handshakeGuard, opts).catch((err) => { - process.stderr.write(`⚡ iroh bridge: connection error: ${err instanceof Error ? err.message : String(err)}\n`) - }) + void admitConnection(incoming, config, activeProcs, handshakeBudget, handshakeGuard, connectionOptions).catch( + (err) => { + process.stderr.write(`⚡ iroh bridge: connection error: ${err instanceof Error ? err.message : String(err)}\n`) + }, + ) } // The accept loop only ends when the endpoint closes; end the heartbeat loop so no // further tick runs after the endpoint is gone. @@ -578,7 +596,7 @@ export const admitConnection = async ( activeProcs: Set, handshakeBudget: { allow: (key: string) => boolean }, handshakeGuard: { tryAcquire: () => boolean; release: () => void }, - opts: HandleConnectionOptions = {}, + options: HandleConnectionOptions = {}, ): Promise => { const key = await remoteKey(incoming) if (!handshakeBudget.allow(key)) { @@ -591,5 +609,5 @@ export const admitConnection = async ( await incoming.ignore() return } - await handleConnection(incoming, config, activeProcs, handshakeGuard, opts) + await handleConnection(incoming, config, activeProcs, handshakeGuard, options) }