diff --git a/apps/daemon/src/chat-routes.ts b/apps/daemon/src/chat-routes.ts index 9889faa472..c11cde5e33 100644 --- a/apps/daemon/src/chat-routes.ts +++ b/apps/daemon/src/chat-routes.ts @@ -5,6 +5,7 @@ import { agentIdToTracking, projectKindToTracking, } from '@open-design/contracts/analytics'; +import { validateBaseUrlResolved } from './connectionTest.js'; export interface RegisterChatRoutesDeps extends RouteDeps<'db' | 'design' | 'http' | 'chat' | 'agents' | 'critique' | 'validation' | 'lifecycle'> {} @@ -46,7 +47,6 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) { critiqueResponseCapBytes, critiqueRunRegistry, } = ctx.critique; - const { validateBaseUrl } = ctx.validation; const isDaemonShuttingDown = ctx.lifecycle?.isDaemonShuttingDown ?? (() => false); const rejectProxyPluginContext = (body: Record, res: any) => { if ( @@ -507,8 +507,13 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) { const redactAuthTokens = (text: string) => text.replace(/Bearer [A-Za-z0-9_\-.+/=]+/g, 'Bearer [REDACTED]'); + // DNS-aware wrapper. The sync `validateBaseUrl` only inspects the literal + // hostname string, so a public DNS name pointing at an internal address + // (`internal.example.com → 10.0.0.5`) still passes. We delegate to + // `validateBaseUrlResolved` here so every proxy/stream handler runs the + // same resolved-IP check before issuing the upstream request. const validateExternalApiBaseUrl = (baseUrl: string) => { - return validateBaseUrl(baseUrl); + return validateBaseUrlResolved(baseUrl); }; const proxyErrorCode = (status: number) => { @@ -702,7 +707,7 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) { ); } - const validated = validateExternalApiBaseUrl(baseUrl); + const validated = await validateExternalApiBaseUrl(baseUrl); if (validated.error) { return sendApiError( res, @@ -714,7 +719,7 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) { const url = appendVersionedApiPath(baseUrl, '/messages'); console.log( - `[proxy:anthropic] ${req.method} ${validated.parsed.hostname} model=${model}`, + `[proxy:anthropic] ${req.method} ${validated.parsed!.hostname} model=${model}`, ); const payload: any = { @@ -798,7 +803,7 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) { ); } - const validated = validateExternalApiBaseUrl(baseUrl); + const validated = await validateExternalApiBaseUrl(baseUrl); if (validated.error) { return sendApiError( res, @@ -810,7 +815,7 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) { const url = appendVersionedApiPath(baseUrl, '/chat/completions'); console.log( - `[proxy:openai] ${req.method} ${validated.parsed.hostname} model=${model}`, + `[proxy:openai] ${req.method} ${validated.parsed!.hostname} model=${model}`, ); const payloadMessages = Array.isArray(messages) ? [...messages] : []; @@ -894,7 +899,7 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) { ); } - const validated = validateExternalApiBaseUrl(baseUrl); + const validated = await validateExternalApiBaseUrl(baseUrl); if (validated.error) { return sendApiError( res, @@ -923,7 +928,7 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) { url.searchParams.set('api-version', version); } console.log( - `[proxy:azure] ${req.method} ${validated.parsed.hostname} deployment=${model} api-version=${version || 'omitted'}`, + `[proxy:azure] ${req.method} ${validated.parsed!.hostname} deployment=${model} api-version=${version || 'omitted'}`, ); const payloadMessages = Array.isArray(messages) ? [...messages] : []; @@ -1007,7 +1012,7 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) { } const effectiveBaseUrl = baseUrl || 'https://generativelanguage.googleapis.com'; - const validated = validateExternalApiBaseUrl(effectiveBaseUrl); + const validated = await validateExternalApiBaseUrl(effectiveBaseUrl); if (validated.error) { return sendApiError( res, @@ -1020,7 +1025,7 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) { const clean = effectiveBaseUrl.replace(/\/+$/, ''); const url = `${clean}/v1beta/models/${encodeURIComponent(model)}:streamGenerateContent?alt=sse`; console.log( - `[proxy:google] ${req.method} ${validated.parsed.hostname} model=${model}`, + `[proxy:google] ${req.method} ${validated.parsed!.hostname} model=${model}`, ); const contents = (Array.isArray(messages) ? messages : []).map((message) => ({ @@ -1101,7 +1106,7 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) { } const effectiveBaseUrl = baseUrl || 'https://ollama.com'; - const validated = validateExternalApiBaseUrl(effectiveBaseUrl); + const validated = await validateExternalApiBaseUrl(effectiveBaseUrl); if (validated.error) { return sendApiError( res, @@ -1113,7 +1118,7 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) { const clean = effectiveBaseUrl.replace(/\/+$/, '').replace(/\/api\/?$/, ''); const url = `${clean}/api/chat`; - console.log(`[proxy:ollama] ${req.method} ${validated.parsed.hostname} model=${model}`); + console.log(`[proxy:ollama] ${req.method} ${validated.parsed!.hostname} model=${model}`); const payloadMessages = Array.isArray(messages) ? [...messages] : []; if (typeof systemPrompt === 'string' && systemPrompt) { @@ -1132,6 +1137,7 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` }, body: JSON.stringify(payload), + redirect: 'error', }); if (!response.ok) { diff --git a/apps/daemon/src/connectionTest.ts b/apps/daemon/src/connectionTest.ts index e406e59a13..f344f98e40 100644 --- a/apps/daemon/src/connectionTest.ts +++ b/apps/daemon/src/connectionTest.ts @@ -17,6 +17,7 @@ // contracts so Settings and daemon-side checks reject the same hosts. import { spawn } from 'node:child_process'; +import { promises as dnsPromises } from 'node:dns'; import { promises as fsp } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -41,9 +42,11 @@ import { } from './runtimes/auth.js'; import type { AgentCliEnvPrefs } from './app-config.js'; import { + isBlockedExternalApiHostname, isLoopbackApiHost, validateBaseUrl, type AgentTestRequest, + type BaseUrlValidationResult, type ConnectionTestKind, type ConnectionTestProtocol, type ConnectionTestResponse, @@ -53,6 +56,68 @@ import { export { validateBaseUrl } from '@open-design/contracts/api/connectionTest'; +// DNS-aware companion to `validateBaseUrl`. The contracts-side check only +// inspects the literal hostname string, so a public DNS name pointing at +// internal infrastructure (`internal.example.com → 10.0.0.5`) slips through +// and the daemon ends up issuing a request to a private address on behalf of +// whichever caller supplied the base URL. Resolve the hostname and re-run +// the block-list against every address the system would actually connect to. +// +// Loopback is intentionally allowed for local LLM providers like Ollama; any +// hostname that resolves to a loopback address (including `*.localhost` per +// RFC 6761 and IPv4-mapped IPv6 loopback) follows that same carve-out. +// +// DNS lookup failures are *not* treated as a security signal — the caller is +// going to surface a connection error from `fetch` anyway, and turning a +// transient resolver hiccup into a 403 would just confuse users. The sync +// hostname check still rejected the obvious literal-IP cases before we ever +// got here. + +export type DnsLookupAddress = { address: string; family: number }; +export type DnsLookupFn = (hostname: string) => Promise; + +const defaultDnsLookup: DnsLookupFn = async (hostname) => { + const result = await dnsPromises.lookup(hostname, { all: true, family: 0 }); + return result.map(({ address, family }) => ({ address, family })); +}; + +function looksLikeIpLiteral(hostname: string): boolean { + const host = hostname.startsWith('[') && hostname.endsWith(']') + ? hostname.slice(1, -1) + : hostname; + if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(host)) return true; + return host.includes(':'); +} + +export async function validateBaseUrlResolved( + baseUrl: string, + lookup: DnsLookupFn = defaultDnsLookup, +): Promise { + const sync = validateBaseUrl(baseUrl); + if (sync.error || !sync.parsed) return sync; + + const hostname = sync.parsed.hostname.toLowerCase(); + if (isLoopbackApiHost(hostname)) return sync; + if (looksLikeIpLiteral(hostname)) return sync; + + let addresses: DnsLookupAddress[]; + try { + addresses = await lookup(hostname); + } catch { + return sync; + } + + for (const addr of addresses) { + const ip = String(addr.address).toLowerCase(); + if (isLoopbackApiHost(ip)) continue; + if (isBlockedExternalApiHostname(ip)) { + return { error: 'Internal IPs blocked', forbidden: true }; + } + } + + return sync; +} + // Aggressive but not punitive — happy paths usually return in under 2 s. // Override with OD_CONNECTION_TEST_PROVIDER_TIMEOUT_MS for slow networks // or distant providers; invalid values fall back to the default. @@ -557,7 +622,7 @@ export async function testProviderConnection( ): Promise { const start = Date.now(); const model = String(input.model ?? ''); - const validated = validateBaseUrl(input.baseUrl); + const validated = await validateBaseUrlResolved(input.baseUrl); if (validated.error || !validated.parsed) { const kind: ConnectionTestKind = validated.forbidden ? 'forbidden' : 'invalid_base_url'; return { diff --git a/apps/daemon/src/import-export-routes.ts b/apps/daemon/src/import-export-routes.ts index b75bcc1f9f..04f0cfb410 100644 --- a/apps/daemon/src/import-export-routes.ts +++ b/apps/daemon/src/import-export-routes.ts @@ -558,7 +558,7 @@ export function registerFinalizeRoutes(app: Express, ctx: RegisterFinalizeRoutes if (typeof baseUrl !== 'string' || !baseUrl.trim()) { return sendApiError(res, 400, 'BAD_REQUEST', 'baseUrl must be a non-empty string when provided'); } - const validated = validateExternalApiBaseUrl(baseUrl); + const validated = await validateExternalApiBaseUrl(baseUrl); if (validated.error) { return sendApiError( res, diff --git a/apps/daemon/src/providerModels.ts b/apps/daemon/src/providerModels.ts index fa79395ba7..3194640056 100644 --- a/apps/daemon/src/providerModels.ts +++ b/apps/daemon/src/providerModels.ts @@ -7,8 +7,8 @@ import type { ProviderModelsRequest, ProviderModelsResponse, } from '@open-design/contracts/api/providerModels'; -import { isLoopbackApiHost, validateBaseUrl } from '@open-design/contracts/api/connectionTest'; -import { redactSecrets } from './connectionTest.js'; +import { isLoopbackApiHost } from '@open-design/contracts/api/connectionTest'; +import { redactSecrets, validateBaseUrlResolved } from './connectionTest.js'; type ProviderModelsInput = ProviderModelsRequest & { signal?: AbortSignal }; @@ -197,7 +197,7 @@ export async function listProviderModels( }; } - const validated = validateBaseUrl(input.baseUrl); + const validated = await validateBaseUrlResolved(input.baseUrl); if (validated.error || !validated.parsed) { return { ok: false, diff --git a/apps/daemon/src/server.ts b/apps/daemon/src/server.ts index 2df6b59f35..381e68bfbb 100644 --- a/apps/daemon/src/server.ts +++ b/apps/daemon/src/server.ts @@ -145,6 +145,7 @@ import { testAgentConnection, testProviderConnection, validateBaseUrl, + validateBaseUrlResolved, } from './connectionTest.js'; import { listProviderModels } from './providerModels.js'; import { importClaudeDesignZip } from './claude-design-import.js'; @@ -3487,7 +3488,12 @@ export async function startServer({ getAppVersion: () => cachedAppVersion, }); - const validateExternalApiBaseUrl = (baseUrl) => validateBaseUrl(baseUrl); + // DNS-aware wrapper. The sync `validateBaseUrl` only inspects the literal + // hostname string, so a public DNS name pointing at an internal address + // (`internal.example.com → 10.0.0.5`) still passes. We delegate to + // `validateBaseUrlResolved` here so every proxy and finalize handler runs + // the same resolved-IP check before issuing the upstream request. + const validateExternalApiBaseUrl = (baseUrl) => validateBaseUrlResolved(baseUrl); const resolvedPortRef = { get current() { diff --git a/apps/daemon/tests/connection-test.test.ts b/apps/daemon/tests/connection-test.test.ts index 1e5210ceae..d02379347d 100644 --- a/apps/daemon/tests/connection-test.test.ts +++ b/apps/daemon/tests/connection-test.test.ts @@ -2,6 +2,7 @@ // provider protocol and uses fake CLI bins for deterministic agent outcomes. import type http from 'node:http'; +import { promises as dnsPromises } from 'node:dns'; import { promises as fsp } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -13,6 +14,8 @@ import { resolveConnectionTestTimeoutMs, testAgentConnection, testProviderConnection, + validateBaseUrlResolved, + type DnsLookupAddress, } from '../src/connectionTest.js'; import { listProviderModels } from '../src/providerModels.js'; import { startServer } from '../src/server.js'; @@ -358,7 +361,53 @@ describe('POST /api/provider/models', () => { ).toBe(false); }); + // Regression for the DNS-bypass SSRF gap flagged on PR #1176: the route + // must resolve the hostname and reject when *any* resolved address is in + // a blocked range, not just when the literal hostname is a private IP. + it('rejects hostnames that resolve to a private IP without calling upstream fetch', async () => { + const fetchMock = passThroughOrUpstream(() => jsonResponse({})); + vi.stubGlobal('fetch', fetchMock); + const dnsSpy = vi + .spyOn(dnsPromises, 'lookup') + .mockImplementation((async (hostname: string) => { + if (hostname === 'rebind.example.test') { + return [{ address: '10.0.0.5', family: 4 }]; + } + const err: NodeJS.ErrnoException = new Error('ENOTFOUND'); + err.code = 'ENOTFOUND'; + throw err; + }) as unknown as typeof dnsPromises.lookup); + try { + const res = await realFetch(`${baseUrl}/api/provider/models`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + protocol: 'openai', + baseUrl: 'https://rebind.example.test/v1', + apiKey: 'sk-good', + }), + }); + const body = (await res.json()) as Record; + expect(body).toMatchObject({ ok: false, kind: 'forbidden' }); + expect( + fetchMock.mock.calls.some( + ([input]) => !String(input).startsWith(baseUrl), + ), + ).toBe(false); + } finally { + dnsSpy.mockRestore(); + } + }); + it('reports timeout when model listing is aborted by the probe timer', async () => { + // The DNS-aware validator runs before the probe timer is installed; stub + // the resolver so the test doesn't race against real DNS while fake + // timers are active. + const dnsSpy = vi + .spyOn(dnsPromises, 'lookup') + .mockImplementation((async () => [ + { address: '203.0.113.10', family: 4 }, + ]) as unknown as typeof dnsPromises.lookup); vi.useFakeTimers(); vi.stubGlobal( 'fetch', @@ -371,17 +420,21 @@ describe('POST /api/provider/models', () => { ), ); - const pending = listProviderModels({ - protocol: 'openai', - baseUrl: 'https://api.openai.com/v1', - apiKey: 'sk-timeout', - }); + try { + const pending = listProviderModels({ + protocol: 'openai', + baseUrl: 'https://api.openai.com/v1', + apiKey: 'sk-timeout', + }); - await vi.advanceTimersByTimeAsync(12_000); - await expect(pending).resolves.toMatchObject({ - ok: false, - kind: 'timeout', - }); + await vi.advanceTimersByTimeAsync(12_000); + await expect(pending).resolves.toMatchObject({ + ok: false, + kind: 'timeout', + }); + } finally { + dnsSpy.mockRestore(); + } }); }); @@ -771,6 +824,47 @@ describe('POST /api/test/connection provider mode', () => { ).toBe(false); }); + // Regression for the DNS-bypass SSRF gap flagged on PR #1176: provider + // mode must run the same resolved-IP check as the proxy/finalize paths + // so a public hostname pointing at a private address can't be fetched. + it('reports forbidden for hostnames that resolve to a private IP without calling fetch', async () => { + const fetchMock = passThroughOrUpstream(() => jsonResponse({})); + vi.stubGlobal('fetch', fetchMock); + const dnsSpy = vi + .spyOn(dnsPromises, 'lookup') + .mockImplementation((async (hostname: string) => { + if (hostname === 'rebind.example.test') { + return [{ address: '10.0.0.5', family: 4 }]; + } + const err: NodeJS.ErrnoException = new Error('ENOTFOUND'); + err.code = 'ENOTFOUND'; + throw err; + }) as unknown as typeof dnsPromises.lookup); + try { + const res = await realFetch(`${baseUrl}/api/test/connection`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + mode: 'provider', + protocol: 'openai', + baseUrl: 'https://rebind.example.test/v1', + apiKey: 'sk-good', + model: 'gpt-4o', + }), + }); + const body = (await res.json()) as Record; + expect(body.ok).toBe(false); + expect(body.kind).toBe('forbidden'); + expect( + fetchMock.mock.calls.some( + ([input]) => !String(input).startsWith(baseUrl), + ), + ).toBe(false); + } finally { + dnsSpy.mockRestore(); + } + }); + it('allows IPv6 loopback base URLs for local OpenAI-compatible providers', async () => { for (const loopbackBaseUrl of [ 'http://[::1]:1234/v1', @@ -1945,3 +2039,127 @@ describe('connection test timeout overrides', () => { } }); }); + +describe('validateBaseUrlResolved (DNS-aware base URL validation)', () => { + function lookupReturning(addresses: DnsLookupAddress[]) { + return vi.fn(async () => addresses); + } + + it('passes through the contracts-level error for invalid input', async () => { + expect(await validateBaseUrlResolved('not-a-url', lookupReturning([]))).toMatchObject({ + error: 'Invalid baseUrl', + }); + }); + + it('rejects the literal-IP cases the sync check already catches', async () => { + for (const baseUrl of [ + 'http://10.0.0.5:11434/v1', + 'http://169.254.169.254/latest/meta-data', + 'http://[fd00::1]:11434/v1', + 'http://[fe80::1]:11434/v1', + ]) { + expect(await validateBaseUrlResolved(baseUrl, lookupReturning([]))).toMatchObject({ + error: 'Internal IPs blocked', + forbidden: true, + }); + } + }); + + it('skips DNS for loopback hostnames so local LLMs (Ollama, *.localhost) still work', async () => { + const lookup = lookupReturning([{ address: '127.0.0.1', family: 4 }]); + for (const baseUrl of [ + 'http://localhost:11434/v1', + 'http://127.0.0.1:11434/v1', + 'http://[::1]:11434/v1', + ]) { + const result = await validateBaseUrlResolved(baseUrl, lookup); + expect(result.error).toBeUndefined(); + } + expect(lookup).not.toHaveBeenCalled(); + }); + + it('skips DNS for IP-literal hostnames the sync check already vetted', async () => { + const lookup = lookupReturning([]); + expect((await validateBaseUrlResolved('https://1.2.3.4/v1', lookup)).error).toBeUndefined(); + expect((await validateBaseUrlResolved('https://[2606:4700::]/v1', lookup)).error).toBeUndefined(); + expect(lookup).not.toHaveBeenCalled(); + }); + + it('rejects public hostnames that resolve to private IPv4 ranges', async () => { + const cases: Array<{ resolved: string; family: number }> = [ + { resolved: '10.0.0.5', family: 4 }, + { resolved: '172.16.0.5', family: 4 }, + { resolved: '192.168.1.5', family: 4 }, + { resolved: '100.64.0.1', family: 4 }, + { resolved: '169.254.169.254', family: 4 }, + { resolved: '0.0.0.0', family: 4 }, + { resolved: '224.0.0.1', family: 4 }, + ]; + for (const { resolved, family } of cases) { + const result = await validateBaseUrlResolved( + 'https://internal.example.com/v1', + lookupReturning([{ address: resolved, family }]), + ); + expect(result).toMatchObject({ + error: 'Internal IPs blocked', + forbidden: true, + }); + } + }); + + it('rejects public hostnames that resolve to private IPv6 ranges', async () => { + for (const resolved of ['fd00::1', 'fe80::1', '::']) { + const result = await validateBaseUrlResolved( + 'https://internal.example.com/v1', + lookupReturning([{ address: resolved, family: 6 }]), + ); + expect(result).toMatchObject({ + error: 'Internal IPs blocked', + forbidden: true, + }); + } + }); + + it('rejects when ANY resolved record (round-robin / dual-stack) is internal', async () => { + const result = await validateBaseUrlResolved( + 'https://mixed.example.com/v1', + lookupReturning([ + { address: '52.84.10.1', family: 4 }, + { address: '10.0.0.5', family: 4 }, + ]), + ); + expect(result).toMatchObject({ + error: 'Internal IPs blocked', + forbidden: true, + }); + }); + + it('allows public hostnames that resolve to public addresses (the api.openai.com case)', async () => { + const result = await validateBaseUrlResolved( + 'https://api.openai.com/v1', + lookupReturning([ + { address: '104.18.7.192', family: 4 }, + { address: '2606:4700::6812:7c0', family: 6 }, + ]), + ); + expect(result.error).toBeUndefined(); + expect(result.parsed?.hostname).toBe('api.openai.com'); + }); + + it('allows hostnames that resolve to loopback (e.g. *.localhost / lvh.me)', async () => { + const result = await validateBaseUrlResolved( + 'http://app.localhost:11434/v1', + lookupReturning([{ address: '127.0.0.1', family: 4 }]), + ); + expect(result.error).toBeUndefined(); + }); + + it('falls back to allow-through on DNS resolver errors so transient failures are not 403s', async () => { + const failingLookup = vi.fn(async () => { + throw new Error('ENOTFOUND'); + }); + const result = await validateBaseUrlResolved('https://offline.example.com/v1', failingLookup); + expect(result.error).toBeUndefined(); + expect(failingLookup).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/daemon/tests/proxy-routes.test.ts b/apps/daemon/tests/proxy-routes.test.ts index 6d9a4aad83..7bccb5556e 100644 --- a/apps/daemon/tests/proxy-routes.test.ts +++ b/apps/daemon/tests/proxy-routes.test.ts @@ -488,6 +488,41 @@ describe('API proxy routes', () => { }); }); + // Regression for PR #1176: the Ollama proxy fetch must also set + // `redirect: 'error'`. Without it, a validated public host could + // 3xx the daemon to a private/internal URL and slip past the + // resolved-IP SSRF check that runs *before* the fetch. + it('forwards redirect:error on the Ollama proxy upstream fetch', async () => { + const ndjsonResponse = new Response( + new TextEncoder().encode('{"done":true}\n'), + { + status: 200, + headers: { 'content-type': 'application/x-ndjson' }, + }, + ); + const fetchMock = vi.fn((input: FetchInput, init?: FetchInit) => { + const url = String(input); + if (url.startsWith(baseUrl)) return realFetch(input, init); + return Promise.resolve(ndjsonResponse); + }); + vi.stubGlobal('fetch', fetchMock); + + await realFetch(`${baseUrl}/api/proxy/ollama/stream`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + baseUrl: 'https://ollama.example.com', + apiKey: 'ollama-key', + model: 'llama3', + messages: [{ role: 'user', content: 'hello' }], + }), + }); + + const [upstreamUrl, upstreamInit] = fetchMock.mock.calls[0]!; + expect(String(upstreamUrl)).toBe('https://ollama.example.com/api/chat'); + expect(upstreamInit?.redirect).toBe('error'); + }); + // Plan §3.A4 / spec §11.8 (e2e-7): the API-fallback proxy paths must // never carry plugin context. The web sidecar's fallback mode bypasses // the daemon snapshot bus, so any pluginId / appliedPluginSnapshotId in