Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 2 additions & 2 deletions backend/docs/pat-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -39,4 +39,4 @@ curl --fail-with-body --silent --show-error \
--data '{"keyId":"<key-id>"}'
```

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.
2 changes: 1 addition & 1 deletion backend/src/api/encryption.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
4 changes: 2 additions & 2 deletions backend/src/api/powersync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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,
Expand Down
10 changes: 6 additions & 4 deletions backend/src/auth/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 6 additions & 6 deletions backend/src/config/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down Expand Up @@ -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'),
Expand Down
13 changes: 9 additions & 4 deletions backend/src/dal/devices.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,10 +258,10 @@ describe('devices DAL', () => {
})

describe('setDeviceNodeId', () => {
const seedDevice = (over: Record<string, unknown>) =>
const seedDevice = (overrides: Record<string, unknown>) =>
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 })
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions backend/src/test-utils/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -35,7 +35,7 @@ export const createTestSettings = (overrides: Partial<Settings> = {}): 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',
Expand Down
4 changes: 3 additions & 1 deletion cli/src/acp/harness-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -176,7 +177,8 @@ const attachAcpPermissionGate = (
const sessionAllowed = new Set<string>()

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'
Expand Down
3 changes: 2 additions & 1 deletion cli/src/acp/harness-to-acp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion cli/src/agent/permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

/**
Expand Down Expand Up @@ -41,7 +42,10 @@ export const attachPermissionGate = (harness: AgentHarness, opts: { yolo: boolea
const sessionAllowed = new Set<string>()

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)
Expand Down
65 changes: 34 additions & 31 deletions cli/src/agent/webfetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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
}
Expand All @@ -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. */
Expand Down Expand Up @@ -335,34 +337,35 @@ 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
}
} finally {
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. */
Expand All @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion cli/src/auth/http-transport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
7 changes: 4 additions & 3 deletions cli/src/auth/http-transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Response>
/** 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<Response>

/**
* Build a {@link DeviceGrantTransport} bound to a Better Auth base URL
Expand Down
5 changes: 2 additions & 3 deletions cli/src/iroh/account-allowlist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Response>
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
Expand Down
2 changes: 1 addition & 1 deletion cli/src/iroh/bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
})
})

Expand Down
Loading
Loading