// Coverage for the /api/test/connection route. Hits status mapping for each // provider protocol and uses fake CLI bins for deterministic agent outcomes. import * as 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'; import { Socks5ProxyAgent } from 'undici'; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; import * as platform from '@open-design/platform'; import { createAgentSink, isSmokeOkReply, mergeNoProxyWithLoopbackDefaults, proxyDispatcherRequestInit, redactSecrets, resolveConnectionTestTimeoutMs, testAgentConnection, testProviderConnection, validateBaseUrlResolved, type DnsLookupAddress, } from '../src/connectionTest.js'; import { listProviderModels } from '../src/providerModels.js'; import { startServer } from '../src/server.js'; type FetchInput = Parameters[0]; type FetchInit = Parameters[1]; interface StartedServer { url: string; server: http.Server; } const realFetch = globalThis.fetch; let baseUrl: string; let server: http.Server; function jsonResponse(body: unknown, init?: ResponseInit): Response { return new Response(JSON.stringify(body), { status: init?.status ?? 200, headers: { 'content-type': 'application/json', ...(init?.headers ?? {}) }, }); } function textResponse(body: string, init?: ResponseInit): Response { return new Response(body, { status: init?.status ?? 200, headers: { 'content-type': 'text/plain', ...(init?.headers ?? {}) }, }); } function passThroughOrUpstream(handler: (url: string, init?: FetchInit) => Response | Promise) { return vi.fn((input: FetchInput, init?: FetchInit) => { const url = String(input); if (url.startsWith(baseUrl)) return realFetch(input, init); return Promise.resolve(handler(url, init)); }); } async function withFakeAgent( binName: string, script: string, run: () => Promise, ): Promise { const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'od-conn-test-bin-')); const oldPath = process.env.PATH; try { if (process.platform === 'win32') { const runner = path.join(dir, `${binName}-test-runner.cjs`); await fsp.writeFile(runner, script); await fsp.writeFile( path.join(dir, `${binName}.cmd`), `@echo off\r\nnode "${runner}" %*\r\n`, ); } else { const bin = path.join(dir, binName); await fsp.writeFile(bin, `#!/usr/bin/env node\n${script}`); await fsp.chmod(bin, 0o755); } process.env.PATH = `${dir}${path.delimiter}${oldPath ?? ''}`; return await run(); } finally { process.env.PATH = oldPath; await fsp.rm(dir, { recursive: true, force: true }); } } async function withOnlyFakeAgent( binName: string, script: string, run: () => Promise, ): Promise { const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'od-conn-test-bin-')); const oldPath = process.env.PATH; const oldAgentHome = process.env.OD_AGENT_HOME; const oldClaudeBin = process.env.CLAUDE_BIN; try { if (process.platform === 'win32') { const runner = path.join(dir, `${binName}-test-runner.cjs`); await fsp.writeFile(runner, script); await fsp.writeFile( path.join(dir, `${binName}.cmd`), `@echo off\r\nnode "${runner}" %*\r\n`, ); } else { const bin = path.join(dir, binName); await fsp.writeFile(bin, `#!/usr/bin/env node\n${script}`); await fsp.chmod(bin, 0o755); } process.env.PATH = dir; process.env.OD_AGENT_HOME = dir; delete process.env.CLAUDE_BIN; return await run(); } finally { process.env.PATH = oldPath; if (oldAgentHome === undefined) delete process.env.OD_AGENT_HOME; else process.env.OD_AGENT_HOME = oldAgentHome; if (oldClaudeBin === undefined) delete process.env.CLAUDE_BIN; else process.env.CLAUDE_BIN = oldClaudeBin; await fsp.rm(dir, { recursive: true, force: true }); } } async function withFakeCodex(script: string, run: () => Promise): Promise { return withFakeAgent('codex', script, run); } async function withFakeClaude(script: string, run: () => Promise): Promise { return withFakeAgent('claude', script, run); } async function withOnlyFakeOpenClaude(script: string, run: () => Promise): Promise { return withOnlyFakeAgent('openclaude', script, run); } async function withFakeOpenCode(script: string, run: () => Promise): Promise { return withFakeAgent('opencode', script, run); } async function withFakeCursorAgent(script: string, run: () => Promise): Promise { return withFakeAgent('cursor-agent', script, run); } async function withFakeDeepSeek(script: string, run: () => Promise): Promise { return withFakeAgent('deepseek', script, run); } async function waitForFile(file: string, timeoutMs = 5_000): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { try { await fsp.access(file); return; } catch { await new Promise((resolve) => setTimeout(resolve, 10)); } } throw new Error(`Timed out waiting for ${file}`); } async function waitForPidToExit(pid: number, timeoutMs = 5_000): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { try { process.kill(pid, 0); } catch { return; } await new Promise((resolve) => setTimeout(resolve, 10)); } throw new Error(`Timed out waiting for process ${pid} to exit`); } beforeAll(async () => { const started = (await startServer({ port: 0, returnServer: true })) as StartedServer; baseUrl = started.url; server = started.server; }); afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); }); afterAll(() => new Promise((resolve) => server.close(() => resolve()))); describe('POST /api/provider/models', () => { it('lists OpenAI-compatible models from /models', async () => { const fetchMock = passThroughOrUpstream((url, init) => { expect(url).toBe('https://api.openai.com/v1/models'); expect((init?.headers as Record).authorization).toBe( 'Bearer sk-openai', ); return jsonResponse({ data: [ { id: 'gpt-4o-mini', object: 'model' }, { id: 'gpt-4o', object: 'model' }, { id: 'gpt-4o', object: 'model' }, ], }); }); vi.stubGlobal('fetch', fetchMock); const res = await realFetch(`${baseUrl}/api/provider/models`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ protocol: 'openai', baseUrl: 'https://api.openai.com/v1', apiKey: 'sk-openai', }), }); expect(res.status).toBe(200); await expect(res.json()).resolves.toMatchObject({ ok: true, kind: 'success', models: [ { id: 'gpt-4o', label: 'gpt-4o' }, { id: 'gpt-4o-mini', label: 'gpt-4o-mini' }, ], }); }); it('routes provider model discovery through the live proxy dispatcher', async () => { const proxySpy = vi.spyOn(platform, 'resolveSystemProxyEnv').mockReturnValue({ HTTP_PROXY: 'http://proxy.example.test:8080', NODE_USE_ENV_PROXY: '1', NO_PROXY: 'localhost,127.0.0.1,[::1]', }); const fetchMock = passThroughOrUpstream((_url, init) => { expect(init?.dispatcher).toBeTruthy(); return jsonResponse({ data: [{ id: 'gpt-4o', object: 'model' }], }); }); vi.stubGlobal('fetch', fetchMock); try { const res = await realFetch(`${baseUrl}/api/provider/models`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ protocol: 'openai', baseUrl: 'https://api.openai.com/v1', apiKey: 'sk-openai', }), }); expect(res.status).toBe(200); await expect(res.json()).resolves.toMatchObject({ ok: true, kind: 'success', models: [{ id: 'gpt-4o', label: 'gpt-4o' }], }); expect(proxySpy).toHaveBeenCalledWith(); } finally { proxySpy.mockRestore(); } }); it('lists Anthropic models with display names and a high page limit', async () => { const fetchMock = passThroughOrUpstream((url, init) => { expect(url).toBe('https://api.anthropic.com/v1/models?limit=1000'); expect((init?.headers as Record)['x-api-key']).toBe( 'sk-ant', ); expect((init?.headers as Record)['anthropic-version']).toBe( '2023-06-01', ); return jsonResponse({ data: [ { id: 'claude-sonnet-4-5', display_name: 'Claude Sonnet 4.5', type: 'model', }, { id: 'claude-haiku-4-5', display_name: 'Claude Haiku 4.5', type: 'model', }, ], }); }); vi.stubGlobal('fetch', fetchMock); const res = await realFetch(`${baseUrl}/api/provider/models`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ protocol: 'anthropic', baseUrl: 'https://api.anthropic.com', apiKey: 'sk-ant', }), }); await expect(res.json()).resolves.toMatchObject({ ok: true, models: [ { id: 'claude-haiku-4-5', label: 'Claude Haiku 4.5' }, { id: 'claude-sonnet-4-5', label: 'Claude Sonnet 4.5' }, ], }); }); it('lists only Gemini models that support generateContent', async () => { const fetchMock = passThroughOrUpstream((url) => { expect(url).toBe( 'https://generativelanguage.googleapis.com/v1beta/models?key=goog-key', ); return jsonResponse({ models: [ { name: 'models/gemini-custom', displayName: 'Gemini Custom', supportedGenerationMethods: ['generateContent'], }, { name: 'models/text-embedding-004', displayName: 'Embedding', supportedGenerationMethods: ['embedContent'], }, { name: 'models/gemini-2.0-flash-001', baseModelId: 'gemini-2.0-flash', displayName: 'Gemini 2.0 Flash', supportedGenerationMethods: ['generateContent', 'countTokens'], }, ], }); }); vi.stubGlobal('fetch', fetchMock); const res = await realFetch(`${baseUrl}/api/provider/models`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ protocol: 'google', baseUrl: 'https://generativelanguage.googleapis.com', apiKey: 'goog-key', }), }); await expect(res.json()).resolves.toMatchObject({ ok: true, models: [ { id: 'gemini-2.0-flash', label: 'Gemini 2.0 Flash' }, { id: 'gemini-custom', label: 'Gemini Custom' }, ], }); }); it('does not double-append v1beta when listing Gemini models', async () => { const fetchMock = passThroughOrUpstream((url) => { expect(url).toBe( 'https://generativelanguage.googleapis.com/v1beta/models?key=goog-key', ); return jsonResponse({ models: [ { name: 'models/gemini-2.0-flash', displayName: 'Gemini 2.0 Flash', supportedGenerationMethods: ['generateContent'], }, ], }); }); vi.stubGlobal('fetch', fetchMock); const res = await realFetch(`${baseUrl}/api/provider/models`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ protocol: 'google', baseUrl: 'https://generativelanguage.googleapis.com/v1beta', apiKey: 'goog-key', }), }); await expect(res.json()).resolves.toMatchObject({ ok: true, models: [{ id: 'gemini-2.0-flash', label: 'Gemini 2.0 Flash' }], }); }); it('lets unsupported contract protocols return a classified provider-models result', async () => { const fetchMock = passThroughOrUpstream(() => jsonResponse({})); vi.stubGlobal('fetch', fetchMock); const res = await realFetch(`${baseUrl}/api/provider/models`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ protocol: 'ollama', baseUrl: 'https://ollama.com', apiKey: 'ollama-key', }), }); const body = (await res.json()) as Record; expect(res.status).toBe(200); expect(body).toMatchObject({ ok: false, kind: 'unsupported_protocol', }); expect( fetchMock.mock.calls.some( ([input]) => !String(input).startsWith(baseUrl), ), ).toBe(false); }); it('maps upstream listing failures to categorized results and redacts keys', async () => { for (const [status, kind, response] of [ [ 401, 'auth_failed', (apiKey: string) => jsonResponse( { error: { message: `bad key ${apiKey}` } }, { status: 401 }, ), ], [ 429, 'rate_limited', (apiKey: string) => textResponse(`rate limit for ${apiKey}`, { status: 429 }), ], [ 503, 'upstream_unavailable', (apiKey: string) => textResponse( `temporary outage for ${apiKey}`, { status: 503, headers: { 'content-type': 'text/html' } }, ), ], ] as const) { const apiKey = `sk-secret-models-${status}`; vi.stubGlobal( 'fetch', passThroughOrUpstream(() => response(apiKey)), ); const res = await realFetch(`${baseUrl}/api/provider/models`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ protocol: 'openai', baseUrl: 'https://api.openai.com/v1', apiKey, }), }); const body = (await res.json()) as Record; expect(body).toMatchObject({ ok: false, kind, status }); expect(String(body.detail)).not.toContain(apiKey); vi.unstubAllGlobals(); } }); it('rejects private-network base URLs without calling upstream fetch', async () => { const fetchMock = passThroughOrUpstream(() => jsonResponse({})); vi.stubGlobal('fetch', fetchMock); const res = await realFetch(`${baseUrl}/api/provider/models`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ protocol: 'openai', baseUrl: 'http://192.168.1.5:8080/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); }); // 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', vi.fn((_input: FetchInput, init?: FetchInit) => new Promise((_resolve, reject) => { init?.signal?.addEventListener('abort', () => { reject(new DOMException('Aborted', 'AbortError')); }); }), ), ); 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', }); } finally { dnsSpy.mockRestore(); } }); }); describe('POST /api/test/connection provider mode', () => { it('reports success and returns the model sample for an Anthropic 200', async () => { vi.stubGlobal( 'fetch', passThroughOrUpstream(() => jsonResponse({ content: [{ type: 'text', text: 'ok' }], }), ), ); const res = await realFetch(`${baseUrl}/api/test/connection`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ mode: 'provider', protocol: 'anthropic', baseUrl: 'https://api.anthropic.com', apiKey: 'sk-ant-test', model: 'claude-sonnet-4-5', }), }); expect(res.status).toBe(200); const body = (await res.json()) as Record; expect(body.ok).toBe(true); expect(body.kind).toBe('success'); expect(body.model).toBe('claude-sonnet-4-5'); expect(body.sample).toBe('ok'); }); it('redacts submitted keys from success samples', async () => { vi.stubGlobal( 'fetch', passThroughOrUpstream(() => jsonResponse({ content: [{ type: 'text', text: 'debug echo sk-success-secret' }], }), ), ); const res = await realFetch(`${baseUrl}/api/test/connection`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ mode: 'provider', protocol: 'anthropic', baseUrl: 'https://api.anthropic.com', apiKey: 'sk-success-secret', model: 'claude-sonnet-4-5', }), }); const body = (await res.json()) as Record; expect(body.ok).toBe(true); expect(body.sample).toBe('debug echo [REDACTED]'); expect(body.sample).not.toContain('sk-success-secret'); }); it('maps a 401 to auth_failed', async () => { vi.stubGlobal( 'fetch', passThroughOrUpstream(() => jsonResponse({ error: { message: 'invalid x-api-key' } }, { status: 401 }), ), ); const res = await realFetch(`${baseUrl}/api/test/connection`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ mode: 'provider', protocol: 'openai', baseUrl: 'https://api.openai.com/v1', apiKey: 'sk-bad', model: 'gpt-4o', }), }); const body = (await res.json()) as Record; expect(body.ok).toBe(false); expect(body.kind).toBe('auth_failed'); expect(body.status).toBe(401); }); it('does not add a duplicate version segment for versioned OpenAI-compatible subpaths', async () => { const fetchMock = vi.fn((input: FetchInput, init?: FetchInit) => { const url = String(input); if (url.startsWith(baseUrl)) return realFetch(input, init); if (url.endsWith('/models')) { return Promise.resolve(jsonResponse({ data: [{ id: 'm' }] })); } return Promise.resolve( jsonResponse({ choices: [{ message: { content: 'ok' } }], }), ); }); vi.stubGlobal('fetch', fetchMock); const res = await realFetch(`${baseUrl}/api/test/connection`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ mode: 'provider', protocol: 'openai', baseUrl: 'https://api.deepinfra.com/v1/openai', apiKey: 'sk-good', model: 'm', }), }); const body = (await res.json()) as Record; expect(body.ok).toBe(true); expect(fetchMock).toHaveBeenCalledWith( 'https://api.deepinfra.com/v1/openai/chat/completions', expect.anything(), ); }); it('checks SenseAudio non-chat model availability without probing chat completions', async () => { const fetchMock = passThroughOrUpstream((url) => { if (url === 'https://api.senseaudio.cn/v1/models') { return jsonResponse({ data: [ { id: 'doubao-1-5-pro-32k-250115' }, { id: 'senseaudio-image-2.0-260319' }, ], }); } return jsonResponse({ error: { message: 'unexpected endpoint' } }, { status: 500 }); }); vi.stubGlobal('fetch', fetchMock); const res = await realFetch(`${baseUrl}/api/test/connection`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ mode: 'provider', protocol: 'senseaudio', baseUrl: 'https://api.senseaudio.cn', apiKey: 'sense-key', model: 'senseaudio-image-2.0-260319', }), }); const body = (await res.json()) as Record; expect(body.ok).toBe(true); expect(body.kind).toBe('success'); expect(body.detail).toContain('not chat-testable'); expect(fetchMock).toHaveBeenCalledWith( 'https://api.senseaudio.cn/v1/models', expect.objectContaining({ method: 'GET' }), ); expect(fetchMock).not.toHaveBeenCalledWith( 'https://api.senseaudio.cn/v1/chat/completions', expect.anything(), ); }); it('returns not_found_model when a SenseAudio non-chat model is absent from /models', async () => { vi.stubGlobal( 'fetch', passThroughOrUpstream((url) => { expect(url).toBe('https://api.senseaudio.cn/v1/models'); return jsonResponse({ data: [{ id: 'senseaudio-image-1.0-260319' }], }); }), ); const res = await realFetch(`${baseUrl}/api/test/connection`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ mode: 'provider', protocol: 'senseaudio', baseUrl: 'https://api.senseaudio.cn', apiKey: 'sense-key', model: 'senseaudio-image-2.0-260319', }), }); const body = (await res.json()) as Record; expect(body.ok).toBe(false); expect(body.kind).toBe('not_found_model'); expect(body.detail).toContain('not reported by SenseAudio /models'); }); it('keeps SenseAudio chat models on the chat completions smoke test', async () => { const fetchMock = passThroughOrUpstream((url) => { if (url === 'https://api.senseaudio.cn/v1/chat/completions') { return jsonResponse({ choices: [{ message: { content: 'ok' }, finish_reason: 'stop' }], }); } return jsonResponse({ error: { message: 'unexpected endpoint' } }, { status: 500 }); }); vi.stubGlobal('fetch', fetchMock); const res = await realFetch(`${baseUrl}/api/test/connection`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ mode: 'provider', protocol: 'senseaudio', baseUrl: 'https://api.senseaudio.cn', apiKey: 'sense-key', model: 'doubao-1-5-pro-32k-250115', }), }); const body = (await res.json()) as Record; expect(body.ok).toBe(true); expect(fetchMock).toHaveBeenCalledWith( 'https://api.senseaudio.cn/v1/chat/completions', expect.objectContaining({ method: 'POST' }), ); }); it('maps a 404 to not_found_model', async () => { vi.stubGlobal( 'fetch', passThroughOrUpstream(() => jsonResponse({ error: { message: 'model not found' } }, { status: 404 }), ), ); const res = await realFetch(`${baseUrl}/api/test/connection`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ mode: 'provider', protocol: 'openai', baseUrl: 'https://api.openai.com/v1', apiKey: 'sk-good', model: 'gpt-does-not-exist', }), }); const body = (await res.json()) as Record; expect(body.kind).toBe('not_found_model'); expect(body.status).toBe(404); }); it('maps an ambiguous 404 to invalid_base_url', async () => { vi.stubGlobal( 'fetch', passThroughOrUpstream(() => new Response('', { status: 404 })), ); const res = await realFetch(`${baseUrl}/api/test/connection`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ mode: 'provider', protocol: 'openai', baseUrl: 'https://ark.cn-beijing.volces.com/api/v2', apiKey: 'ark-key', model: 'doubao-1-5-lite-32k-250115', }), }); const body = (await res.json()) as Record; expect(body.ok).toBe(false); expect(body.kind).toBe('invalid_base_url'); expect(body.status).toBe(404); expect(body.detail).toContain('HTTP 404'); }); it('maps a 429 to rate_limited', async () => { vi.stubGlobal( 'fetch', passThroughOrUpstream(() => jsonResponse({ error: { message: 'too many requests' } }, { status: 429 }), ), ); const res = await realFetch(`${baseUrl}/api/test/connection`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ mode: 'provider', protocol: 'openai', baseUrl: 'https://api.openai.com/v1', apiKey: 'sk-good', model: 'gpt-4o', }), }); const body = (await res.json()) as Record; expect(body.kind).toBe('rate_limited'); }); it('maps a 500 to upstream_unavailable', async () => { vi.stubGlobal( 'fetch', passThroughOrUpstream(() => jsonResponse({ error: { message: 'oops' } }, { status: 503 }), ), ); const res = await realFetch(`${baseUrl}/api/test/connection`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ mode: 'provider', protocol: 'openai', baseUrl: 'https://api.openai.com/v1', apiKey: 'sk-good', model: 'gpt-4o', }), }); const body = (await res.json()) as Record; expect(body.kind).toBe('upstream_unavailable'); expect(body.status).toBe(503); }); it('does not treat a 200 response without assistant text as success', async () => { vi.stubGlobal( 'fetch', passThroughOrUpstream(() => jsonResponse({ error: { message: 'Unexpected endpoint or method. (POST /v2/chat/completions). Returning 200 anyway', }, }), ), ); const res = await realFetch(`${baseUrl}/api/test/connection`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ mode: 'provider', protocol: 'openai', baseUrl: 'http://localhost:1234/v2', apiKey: 'lm-studio', model: 'google/gemma-4-e4b', }), }); const body = (await res.json()) as Record; expect(body.ok).toBe(false); expect(body.kind).toBe('unknown'); expect(body.status).toBe(200); expect(body.detail).toContain('Unexpected endpoint or method'); }); it('does not treat model-error assistant text as provider success', async () => { vi.stubGlobal( 'fetch', passThroughOrUpstream(() => jsonResponse({ choices: [ { message: { role: 'assistant', content: "There's an issue with the selected model (abcde). It may not exist.", }, }, ], }), ), ); const res = await realFetch(`${baseUrl}/api/test/connection`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ mode: 'provider', protocol: 'openai', baseUrl: 'https://api.openai.com/v1', apiKey: 'sk-good', model: 'abcde', }), }); const body = (await res.json()) as Record; expect(body.ok).toBe(false); expect(body.kind).toBe('not_found_model'); expect(body.model).toBe('abcde'); expect(body.detail).toContain('Expected smoke test reply "ok"'); }); it('treats a structured local reasoning completion with empty content as connected', async () => { vi.stubGlobal( 'fetch', passThroughOrUpstream((url) => { if (url === 'http://localhost:1234/v1/models') { return jsonResponse({ data: [{ id: 'google/gemma-4-e4b', object: 'model' }], }); } return jsonResponse({ id: 'chatcmpl-reasoning', object: 'chat.completion', model: 'google/gemma-4-e4b', choices: [ { index: 0, message: { role: 'assistant', content: '', reasoning_content: '\nThe user wants me to reply with only ok', }, finish_reason: 'length', }, ], }); }), ); const res = await realFetch(`${baseUrl}/api/test/connection`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ mode: 'provider', protocol: 'openai', baseUrl: 'http://localhost:1234/v1', apiKey: 'lm-studio', model: 'google/gemma-4-e4b', }), }); const body = (await res.json()) as Record; expect(body.ok).toBe(true); expect(body.kind).toBe('success'); expect(body.model).toBe('google/gemma-4-e4b'); expect(body.sample).toBe('valid completion (length)'); }); it('rejects an unloaded local OpenAI-compatible model before completion', async () => { const fetchMock = passThroughOrUpstream((url) => { if (url === 'http://localhost:1234/v1/models') { return jsonResponse({ data: [{ id: 'google/gemma-4-e4b', object: 'model' }], }); } return jsonResponse({ choices: [{ message: { role: 'assistant', content: 'ok' } }], }); }); vi.stubGlobal('fetch', fetchMock); const res = await realFetch(`${baseUrl}/api/test/connection`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ mode: 'provider', protocol: 'openai', baseUrl: 'http://localhost:1234/v1', apiKey: 'lm-studio', model: 'helo', }), }); const body = (await res.json()) as Record; expect(body.ok).toBe(false); expect(body.kind).toBe('not_found_model'); expect(body.model).toBe('helo'); expect(body.detail).toContain('helo'); expect( fetchMock.mock.calls.some(([input]) => String(input).endsWith('/chat/completions'), ), ).toBe(false); }); it('reports forbidden for an internal-IP base URL without calling fetch', async () => { const fetchMock = passThroughOrUpstream(() => jsonResponse({})); vi.stubGlobal('fetch', fetchMock); const res = await realFetch(`${baseUrl}/api/test/connection`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ mode: 'provider', protocol: 'openai', baseUrl: 'http://192.168.1.5:8080/v1', apiKey: 'sk-good', model: 'gpt-4o', }), }); const body = (await res.json()) as Record; expect(body.ok).toBe(false); expect(body.kind).toBe('forbidden'); // Internal-IP guard fires before any outbound fetch. expect( fetchMock.mock.calls.some( ([input]) => !String(input).startsWith(baseUrl), ), ).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', 'http://[::ffff:127.0.0.1]:1234/v1', ]) { const fetchMock = passThroughOrUpstream((url) => { if (url.endsWith('/models')) { return jsonResponse({ data: [{ id: 'local-model', object: 'model' }], }); } return jsonResponse({ choices: [{ message: { role: 'assistant', content: 'ok' } }], }); }); vi.stubGlobal('fetch', fetchMock); const res = await realFetch(`${baseUrl}/api/test/connection`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ mode: 'provider', protocol: 'openai', baseUrl: loopbackBaseUrl, apiKey: 'lm-studio', model: 'local-model', }), }); const body = (await res.json()) as Record; expect(body.ok).toBe(true); expect(body.kind).toBe('success'); vi.unstubAllGlobals(); } }); it('reports forbidden for internal IPv6 base URLs without calling fetch', async () => { for (const blockedBaseUrl of [ 'http://[fd00::1]:1234/v1', 'http://[fe80::1]:1234/v1', 'http://[::ffff:192.168.1.5]:1234/v1', ]) { const fetchMock = passThroughOrUpstream(() => jsonResponse({})); vi.stubGlobal('fetch', fetchMock); const res = await realFetch(`${baseUrl}/api/test/connection`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ mode: 'provider', protocol: 'openai', baseUrl: blockedBaseUrl, 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); vi.unstubAllGlobals(); } }); it('routes Azure tests to the deployments endpoint with api-key auth', async () => { const fetchMock = passThroughOrUpstream(() => jsonResponse({ choices: [{ message: { role: 'assistant', content: 'ok' } }], }), ); vi.stubGlobal('fetch', fetchMock); const res = await realFetch(`${baseUrl}/api/test/connection`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ mode: 'provider', protocol: 'azure', baseUrl: 'https://my-azure.openai.azure.com', apiKey: 'azure-key', model: 'deployment-1', apiVersion: '2024-10-21', }), }); const body = (await res.json()) as Record; expect(body.ok).toBe(true); expect(body.sample).toBe('ok'); const upstream = fetchMock.mock.calls.find( ([input]) => !String(input).startsWith(baseUrl), ); expect(upstream).toBeDefined(); const [upstreamUrl, upstreamInit] = upstream!; expect(String(upstreamUrl)).toBe( 'https://my-azure.openai.azure.com/openai/deployments/deployment-1/chat/completions?api-version=2024-10-21', ); expect((upstreamInit?.headers as Record)['api-key']).toBe( 'azure-key', ); }); it('retries Azure OpenAI-compatible v1 alias connection tests with max_completion_tokens when max_tokens is rejected', async () => { const fetchMock = passThroughOrUpstream((_url, init) => { const body = JSON.parse(String(init?.body)) as Record; if ('max_tokens' in body) { return jsonResponse({ error: { message: "Unsupported parameter: 'max_tokens' is not supported with this model. Use 'max_completion_tokens' instead.", type: 'invalid_request_error', param: 'max_tokens', code: 'unsupported_parameter', }, }, { status: 400 }); } return jsonResponse({ choices: [{ message: { role: 'assistant', content: 'ok' } }], }); }); vi.stubGlobal('fetch', fetchMock); const res = await realFetch(`${baseUrl}/api/test/connection`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ mode: 'provider', protocol: 'azure', baseUrl: 'https://my-resource.services.ai.azure.com/api/projects/project/openai/v1', apiKey: 'azure-key', model: 'prod', apiVersion: '', }), }); const body = (await res.json()) as Record; expect(body.ok).toBe(true); const upstreamCalls = fetchMock.mock.calls.filter( ([input]) => !String(input).startsWith(baseUrl), ); expect(upstreamCalls).toHaveLength(2); const firstBody = JSON.parse(String(upstreamCalls[0]![1]?.body)); const secondBody = JSON.parse(String(upstreamCalls[1]![1]?.body)); expect(firstBody).toMatchObject({ model: 'prod', messages: [{ role: 'user', content: 'Reply with only: ok' }], max_tokens: 100, stream: false, }); expect(firstBody).not.toHaveProperty('max_completion_tokens'); expect(secondBody).toMatchObject({ model: 'prod', messages: [{ role: 'user', content: 'Reply with only: ok' }], max_completion_tokens: 100, stream: false, }); expect(secondBody).not.toHaveProperty('max_tokens'); }); it('retries Azure deployment-mode connection tests with max_completion_tokens when max_tokens is rejected', async () => { const fetchMock = passThroughOrUpstream((_url, init) => { const body = JSON.parse(String(init?.body)) as Record; if ('max_tokens' in body) { return jsonResponse({ error: { message: "Unsupported parameter: 'max_tokens' is not supported with this model. Use 'max_completion_tokens' instead.", type: 'invalid_request_error', param: 'max_tokens', code: 'unsupported_parameter', }, }, { status: 400 }); } return jsonResponse({ choices: [{ message: { role: 'assistant', content: 'ok' } }], }); }); vi.stubGlobal('fetch', fetchMock); const res = await realFetch(`${baseUrl}/api/test/connection`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ mode: 'provider', protocol: 'azure', baseUrl: 'https://my-azure.openai.azure.com', apiKey: 'azure-key', model: 'prod', apiVersion: '', }), }); const body = (await res.json()) as Record; expect(body.ok).toBe(true); const upstreamCalls = fetchMock.mock.calls.filter( ([input]) => !String(input).startsWith(baseUrl), ); expect(upstreamCalls).toHaveLength(2); const firstBody = JSON.parse(String(upstreamCalls[0]![1]?.body)); const secondBody = JSON.parse(String(upstreamCalls[1]![1]?.body)); expect(firstBody).toMatchObject({ max_tokens: 100, stream: false }); expect(firstBody).not.toHaveProperty('max_completion_tokens'); expect(secondBody).toMatchObject({ max_completion_tokens: 100, stream: false }); expect(secondBody).not.toHaveProperty('max_tokens'); }); it('reports Azure retry latency from the final provider response', async () => { let now = 10_000; const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => now); const fetchMock = vi.fn((_input: FetchInput, init?: FetchInit) => { const body = JSON.parse(String(init?.body)) as Record; if ('max_tokens' in body) { now += 25; return Promise.resolve(jsonResponse({ error: { message: "Unsupported parameter: 'max_tokens' is not supported with this model. Use 'max_completion_tokens' instead.", type: 'invalid_request_error', param: 'max_tokens', code: 'unsupported_parameter', }, }, { status: 400 })); } now += 75; return Promise.resolve(jsonResponse({ choices: [{ message: { role: 'assistant', content: 'ok' } }], })); }); vi.stubGlobal('fetch', fetchMock); try { await expect(testProviderConnection({ protocol: 'azure', baseUrl: 'https://my-azure.openai.azure.com', apiKey: 'azure-key', model: 'prod', apiVersion: '', })).resolves.toMatchObject({ ok: true, latencyMs: 100, }); } finally { nowSpy.mockRestore(); } }); it('reports Azure failed-retry latency from the final provider response', async () => { let now = 20_000; const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => now); const fetchMock = vi.fn((_input: FetchInput, init?: FetchInit) => { const body = JSON.parse(String(init?.body)) as Record; if ('max_tokens' in body) { now += 25; return Promise.resolve(jsonResponse({ error: { message: "Unsupported parameter: 'max_tokens' is not supported with this model. Use 'max_completion_tokens' instead.", type: 'invalid_request_error', param: 'max_tokens', code: 'unsupported_parameter', }, }, { status: 400 })); } now += 75; return Promise.resolve(jsonResponse({ error: { message: 'retry failed', }, }, { status: 500 })); }); vi.stubGlobal('fetch', fetchMock); try { await expect(testProviderConnection({ protocol: 'azure', baseUrl: 'https://my-azure.openai.azure.com', apiKey: 'azure-key', model: 'prod', apiVersion: '', })).resolves.toMatchObject({ ok: false, kind: 'upstream_unavailable', status: 500, latencyMs: 100, }); } finally { nowSpy.mockRestore(); } }); it('keeps max_tokens for legacy OpenAI connection tests', async () => { const fetchMock = passThroughOrUpstream(() => jsonResponse({ choices: [{ message: { role: 'assistant', content: 'ok' } }], }), ); vi.stubGlobal('fetch', fetchMock); const res = await realFetch(`${baseUrl}/api/test/connection`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ mode: 'provider', protocol: 'openai', baseUrl: 'https://api.openai.com/v1', apiKey: 'sk-good', model: 'gpt-4o', }), }); const body = (await res.json()) as Record; expect(body.ok).toBe(true); const upstream = fetchMock.mock.calls.find( ([input]) => !String(input).startsWith(baseUrl), ); expect(upstream).toBeDefined(); const [, upstreamInit] = upstream!; expect(JSON.parse(String(upstreamInit?.body))).toMatchObject({ model: 'gpt-4o', max_tokens: 100, stream: false, }); expect(JSON.parse(String(upstreamInit?.body))).not.toHaveProperty( 'max_completion_tokens', ); }); it('keeps max_tokens for DeepSeek-style OpenAI-compatible connection tests', async () => { const fetchMock = passThroughOrUpstream((url) => { if (url === 'https://api.deepseek.com/v1/models') { return jsonResponse({ data: [{ id: 'deepseek-chat', object: 'model' }], }); } return jsonResponse({ choices: [{ message: { role: 'assistant', content: 'ok' } }], }); }); vi.stubGlobal('fetch', fetchMock); const res = await realFetch(`${baseUrl}/api/test/connection`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ mode: 'provider', protocol: 'openai', baseUrl: 'https://api.deepseek.com', apiKey: 'deepseek-key', model: 'deepseek-chat', }), }); const body = (await res.json()) as Record; expect(body.ok).toBe(true); const upstream = fetchMock.mock.calls.find( ([input]) => String(input) === 'https://api.deepseek.com/v1/chat/completions', ); expect(upstream).toBeDefined(); const [, upstreamInit] = upstream!; expect(JSON.parse(String(upstreamInit?.body))).toMatchObject({ model: 'deepseek-chat', max_tokens: 100, stream: false, }); expect(JSON.parse(String(upstreamInit?.body))).not.toHaveProperty( 'max_completion_tokens', ); }); it('keeps max_tokens for Azure gpt-4o connection tests on the default deployment path', async () => { const fetchMock = passThroughOrUpstream(() => jsonResponse({ choices: [{ message: { role: 'assistant', content: 'ok' } }], }), ); vi.stubGlobal('fetch', fetchMock); const res = await realFetch(`${baseUrl}/api/test/connection`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ mode: 'provider', protocol: 'azure', baseUrl: 'https://my-azure.openai.azure.com', apiKey: 'azure-key', model: 'gpt-4o', apiVersion: '', }), }); const body = (await res.json()) as Record; expect(body.ok).toBe(true); const upstream = fetchMock.mock.calls.find( ([input]) => !String(input).startsWith(baseUrl), ); expect(upstream).toBeDefined(); const [, upstreamInit] = upstream!; expect(JSON.parse(String(upstreamInit?.body))).toMatchObject({ messages: [{ role: 'user', content: 'Reply with only: ok' }], max_tokens: 100, stream: false, }); expect(JSON.parse(String(upstreamInit?.body))).not.toHaveProperty( 'max_completion_tokens', ); }); it('keeps the default Azure api-version in connection tests when the field is blank', async () => { const fetchMock = passThroughOrUpstream(() => jsonResponse({ choices: [{ message: { role: 'assistant', content: 'ok' } }], }), ); vi.stubGlobal('fetch', fetchMock); const res = await realFetch(`${baseUrl}/api/test/connection`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ mode: 'provider', protocol: 'azure', baseUrl: 'https://my-azure.openai.azure.com', apiKey: 'azure-key', model: 'deployment-1', apiVersion: '', }), }); const body = (await res.json()) as Record; expect(body.ok).toBe(true); const upstream = fetchMock.mock.calls.find( ([input]) => !String(input).startsWith(baseUrl), ); expect(upstream).toBeDefined(); const [upstreamUrl] = upstream!; expect(String(upstreamUrl)).toBe( 'https://my-azure.openai.azure.com/openai/deployments/deployment-1/chat/completions?api-version=2024-10-21', ); }); it('omits Azure api-version in connection tests for OpenAI-compatible v1 paths when blank', async () => { const fetchMock = passThroughOrUpstream(() => jsonResponse({ choices: [{ message: { role: 'assistant', content: 'ok' } }], }), ); vi.stubGlobal('fetch', fetchMock); const res = await realFetch(`${baseUrl}/api/test/connection`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ mode: 'provider', protocol: 'azure', baseUrl: 'https://my-resource.services.ai.azure.com/api/projects/project/openai/v1', apiKey: 'azure-key', model: 'deployment-1', apiVersion: '', }), }); const body = (await res.json()) as Record; expect(body.ok).toBe(true); const upstream = fetchMock.mock.calls.find( ([input]) => !String(input).startsWith(baseUrl), ); expect(upstream).toBeDefined(); const [upstreamUrl, upstreamInit] = upstream!; expect(String(upstreamUrl)).toBe( 'https://my-resource.services.ai.azure.com/api/projects/project/openai/v1/chat/completions', ); expect(JSON.parse(String(upstreamInit?.body))).toMatchObject({ model: 'deployment-1', }); }); it('removes copied Azure api-version query params in connection tests for OpenAI-compatible v1 paths when blank', async () => { const fetchMock = passThroughOrUpstream(() => jsonResponse({ choices: [{ message: { role: 'assistant', content: 'ok' } }], }), ); vi.stubGlobal('fetch', fetchMock); const res = await realFetch(`${baseUrl}/api/test/connection`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ mode: 'provider', protocol: 'azure', baseUrl: 'https://my-resource.services.ai.azure.com/api/projects/project/openai/v1?api-version=2024-10-21', apiKey: 'azure-key', model: 'deployment-1', apiVersion: '', }), }); const body = (await res.json()) as Record; expect(body.ok).toBe(true); const upstream = fetchMock.mock.calls.find( ([input]) => !String(input).startsWith(baseUrl), ); expect(upstream).toBeDefined(); const [upstreamUrl] = upstream!; expect(String(upstreamUrl)).toBe( 'https://my-resource.services.ai.azure.com/api/projects/project/openai/v1/chat/completions', ); }); it('uses the non-streaming Gemini endpoint and extracts text from candidates', async () => { const fetchMock = passThroughOrUpstream(() => jsonResponse({ candidates: [ { content: { parts: [{ text: 'ok' }] } }, ], }), ); vi.stubGlobal('fetch', fetchMock); const res = await realFetch(`${baseUrl}/api/test/connection`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ mode: 'provider', protocol: 'google', baseUrl: 'https://generativelanguage.googleapis.com', apiKey: 'goog-key', model: 'gemini-2.0-flash', }), }); const body = (await res.json()) as Record; expect(body.ok).toBe(true); expect(body.sample).toBe('ok'); const upstream = fetchMock.mock.calls.find( ([input]) => !String(input).startsWith(baseUrl), ); expect(String(upstream![0])).toBe( 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent', ); }); it('normalizes Gemini model ids and base URLs in the provider smoke test', async () => { const fetchMock = passThroughOrUpstream(() => jsonResponse({ candidates: [ { content: { parts: [{ text: 'ok' }] } }, ], }), ); vi.stubGlobal('fetch', fetchMock); const res = await realFetch(`${baseUrl}/api/test/connection`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ mode: 'provider', protocol: 'google', baseUrl: 'https://generativelanguage.googleapis.com/v1beta', apiKey: 'goog-key', model: 'models/gemini-2.0-flash', }), }); const body = (await res.json()) as Record; expect(body.ok).toBe(true); expect(body.sample).toBe('ok'); const upstream = fetchMock.mock.calls.find( ([input]) => !String(input).startsWith(baseUrl), ); expect(String(upstream![0])).toBe( 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent', ); }); it('rejects malformed bodies with HTTP 400 (not the test envelope)', async () => { const res = await realFetch(`${baseUrl}/api/test/connection`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ mode: 'provider', protocol: 'openai' }), }); expect(res.status).toBe(400); }); it('cancels provider probes when the caller aborts', async () => { const controller = new AbortController(); vi.stubGlobal( 'fetch', vi.fn((_input: FetchInput, init?: FetchInit) => new Promise((_resolve, reject) => { if (init?.signal?.aborted) { reject(new DOMException('Aborted', 'AbortError')); return; } init?.signal?.addEventListener('abort', () => { reject(new DOMException('Aborted', 'AbortError')); }); }), ), ); const pending = testProviderConnection({ protocol: 'openai', baseUrl: 'https://api.openai.com/v1', apiKey: 'sk-good', model: 'gpt-4o', signal: controller.signal, }); controller.abort(); await expect(pending).resolves.toMatchObject({ ok: false, kind: 'timeout', }); }); it('uses a live system-proxy dispatcher for provider-mode fetches', async () => { const proxySpy = vi.spyOn(platform, 'resolveSystemProxyEnv').mockReturnValue({ HTTPS_PROXY: 'http://system-proxy.internal:8443', NODE_USE_ENV_PROXY: '1', }); const fetchMock = vi.fn((_input: FetchInput, init?: FetchInit) => { expect(init?.dispatcher).toBeDefined(); return Promise.resolve(jsonResponse({ choices: [{ message: { role: 'assistant', content: 'ok' } }], })); }); vi.stubGlobal('fetch', fetchMock); try { await expect(testProviderConnection({ protocol: 'openai', baseUrl: 'https://api.openai.com/v1', apiKey: 'sk-good', model: 'gpt-4o', })).resolves.toMatchObject({ ok: true, kind: 'success', }); } finally { proxySpy.mockRestore(); } }); it.each([ ['*', '*'], ['*,.corp.example', '*'], [' * , .corp.example ', '*'], ['* .corp.example', '*'], ['.corp.example', '.corp.example,localhost,127.0.0.1,[::1]'], ['::1', '[::1],localhost,127.0.0.1'], [undefined, 'localhost,127.0.0.1,[::1]'], ])('mergeNoProxyWithLoopbackDefaults(%p)', (input, expected) => { expect(mergeNoProxyWithLoopbackDefaults(input)).toBe(expected); }); it('uses a SOCKS dispatcher when ALL_PROXY is the only configured proxy', async () => { const proxySpy = vi.spyOn(platform, 'resolveSystemProxyEnv').mockReturnValue({}); try { const { close, requestInit } = proxyDispatcherRequestInit({ ALL_PROXY: 'socks5://system-socks:1080', }); expect(requestInit.dispatcher).toBeDefined(); await expect(close()).resolves.toBeUndefined(); } finally { proxySpy.mockRestore(); } }); it('forwards timeout options through SOCKS dispatches', async () => { const proxySpy = vi.spyOn(platform, 'resolveSystemProxyEnv').mockReturnValue({}); const dispatchSpy = vi .spyOn(Socks5ProxyAgent.prototype, 'dispatch') .mockReturnValue(true as ReturnType); try { const { close, requestInit } = proxyDispatcherRequestInit( { ALL_PROXY: 'socks5://system-socks:1080', }, { headersTimeout: 1234, bodyTimeout: 5678, }, ); const dispatcher = requestInit.dispatcher as unknown as { dispatch(options: { origin: string; path: string; method: string }, handler: unknown): boolean; }; expect(dispatcher).toBeDefined(); dispatcher.dispatch( { origin: 'https://api.openai.com', path: '/v1/chat/completions', method: 'POST', }, {}, ); expect(dispatchSpy).toHaveBeenCalledWith( expect.objectContaining({ origin: 'https://api.openai.com', path: '/v1/chat/completions', headersTimeout: 1234, bodyTimeout: 5678, }), expect.anything(), ); await expect(close()).resolves.toBeUndefined(); } finally { dispatchSpy.mockRestore(); proxySpy.mockRestore(); } }); it('resolves system proxy env for each HTTP proxy dispatcher request', async () => { const proxySpy = vi.spyOn(platform, 'resolveSystemProxyEnv').mockReturnValue({}); try { const { close, requestInit } = proxyDispatcherRequestInit(); expect(proxySpy).toHaveBeenCalledWith(); expect(requestInit).toEqual({}); await expect(close()).resolves.toBeUndefined(); } finally { proxySpy.mockRestore(); } }); it('reports malformed proxy env without leaking the connection-test timer', async () => { const originalHttpProxy = process.env.HTTP_PROXY; const originalHttpsProxy = process.env.HTTPS_PROXY; const originalAllProxy = process.env.ALL_PROXY; process.env.HTTP_PROXY = 'not a valid proxy url'; delete process.env.HTTPS_PROXY; delete process.env.ALL_PROXY; try { await expect(testProviderConnection({ protocol: 'openai', baseUrl: 'https://api.openai.com/v1', apiKey: 'sk-good', model: 'gpt-4o', })).resolves.toMatchObject({ ok: false, kind: 'unknown', }); } finally { if (originalHttpProxy === undefined) delete process.env.HTTP_PROXY; else process.env.HTTP_PROXY = originalHttpProxy; if (originalHttpsProxy === undefined) delete process.env.HTTPS_PROXY; else process.env.HTTPS_PROXY = originalHttpsProxy; if (originalAllProxy === undefined) delete process.env.ALL_PROXY; else process.env.ALL_PROXY = originalAllProxy; } }); it('keeps loopback provider probes off the proxy when user NO_PROXY omits localhost', async () => { const providerServer = http.createServer((req, res) => { if (req.url === '/v1/models') { res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify({ data: [{ id: 'google/gemma-4-e4b', object: 'model' }] })); return; } if (req.url === '/v1/chat/completions') { res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify({ choices: [{ message: { role: 'assistant', content: 'ok' } }], })); return; } res.writeHead(404).end(); }); await new Promise((resolve) => providerServer.listen(0, '127.0.0.1', () => resolve())); const address = providerServer.address(); if (!address || typeof address === 'string') { providerServer.close(); throw new Error('Expected an IPv4 provider test server address'); } const originalNoProxy = process.env.NO_PROXY; const proxySpy = vi.spyOn(platform, 'resolveSystemProxyEnv').mockReturnValue({ HTTP_PROXY: 'http://127.0.0.1:9', NO_PROXY: 'localhost,127.0.0.1,[::1]', NODE_USE_ENV_PROXY: '1', }); process.env.NO_PROXY = '*.corp.com'; try { await expect(testProviderConnection({ protocol: 'openai', baseUrl: `http://127.0.0.1:${address.port}/v1`, apiKey: 'lm-studio', model: 'google/gemma-4-e4b', })).resolves.toMatchObject({ ok: true, kind: 'success', }); } finally { if (originalNoProxy === undefined) delete process.env.NO_PROXY; else process.env.NO_PROXY = originalNoProxy; proxySpy.mockRestore(); await new Promise((resolve, reject) => providerServer.close((error) => (error ? reject(error) : resolve())), ); } }); it('keeps loopback provider probes off the proxy when inherited proxy env omits NO_PROXY', async () => { const providerServer = http.createServer((req, res) => { if (req.url === '/v1/models') { res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify({ data: [{ id: 'llama3.2', object: 'model' }] })); return; } if (req.url === '/v1/chat/completions') { res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify({ choices: [{ message: { role: 'assistant', content: 'ok' } }], })); return; } res.writeHead(404).end(); }); await new Promise((resolve) => providerServer.listen(0, '127.0.0.1', () => resolve())); const address = providerServer.address(); if (!address || typeof address === 'string') { providerServer.close(); throw new Error('Expected an IPv4 provider test server address'); } const originalHttpProxy = process.env.HTTP_PROXY; const originalHttpsProxy = process.env.HTTPS_PROXY; const originalNoProxy = process.env.NO_PROXY; const proxySpy = vi.spyOn(platform, 'resolveSystemProxyEnv').mockReturnValue({}); process.env.HTTP_PROXY = 'http://127.0.0.1:9'; process.env.HTTPS_PROXY = 'http://127.0.0.1:9'; delete process.env.NO_PROXY; try { await expect(testProviderConnection({ protocol: 'openai', baseUrl: `http://localhost:${address.port}/v1`, apiKey: 'ollama', model: 'llama3.2', })).resolves.toMatchObject({ ok: true, kind: 'success', }); } finally { if (originalHttpProxy === undefined) delete process.env.HTTP_PROXY; else process.env.HTTP_PROXY = originalHttpProxy; if (originalHttpsProxy === undefined) delete process.env.HTTPS_PROXY; else process.env.HTTPS_PROXY = originalHttpsProxy; if (originalNoProxy === undefined) delete process.env.NO_PROXY; else process.env.NO_PROXY = originalNoProxy; proxySpy.mockRestore(); await new Promise((resolve, reject) => providerServer.close((error) => (error ? reject(error) : resolve())), ); } }); it('keeps loopback provider probes off a SOCKS-only proxy', async () => { const providerServer = http.createServer((req, res) => { if (req.url === '/v1/models') { res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify({ data: [{ id: 'llama3.2', object: 'model' }] })); return; } if (req.url === '/v1/chat/completions') { res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify({ choices: [{ message: { role: 'assistant', content: 'ok' } }], })); return; } res.writeHead(404).end(); }); await new Promise((resolve) => providerServer.listen(0, '127.0.0.1', () => resolve())); const address = providerServer.address(); if (!address || typeof address === 'string') { providerServer.close(); throw new Error('Expected an IPv4 provider test server address'); } const originalAllProxy = process.env.ALL_PROXY; const originalNoProxy = process.env.NO_PROXY; const proxySpy = vi.spyOn(platform, 'resolveSystemProxyEnv').mockReturnValue({}); process.env.ALL_PROXY = 'socks5://127.0.0.1:9'; delete process.env.NO_PROXY; try { await expect(testProviderConnection({ protocol: 'openai', baseUrl: `http://localhost:${address.port}/v1`, apiKey: 'ollama', model: 'llama3.2', })).resolves.toMatchObject({ ok: true, kind: 'success', }); } finally { if (originalAllProxy === undefined) delete process.env.ALL_PROXY; else process.env.ALL_PROXY = originalAllProxy; if (originalNoProxy === undefined) delete process.env.NO_PROXY; else process.env.NO_PROXY = originalNoProxy; proxySpy.mockRestore(); await new Promise((resolve, reject) => providerServer.close((error) => (error ? reject(error) : resolve())), ); } }); }); describe('POST /api/test/connection agent mode', () => { it('reports success for a fake Codex agent response', async () => { await withFakeCodex( ` console.log(JSON.stringify({ type: 'item.completed', item: { type: 'agent_message', text: 'ok' } })); setImmediate(() => process.exit(0)); `, async () => { const res = await realFetch(`${baseUrl}/api/test/connection`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ mode: 'agent', agentId: 'codex' }), }); expect(res.status).toBe(200); await expect(res.json()).resolves.toMatchObject({ ok: true, kind: 'success', agentName: 'Codex CLI', sample: 'ok', }); }, ); }); it('spawns agent tests with draft allowlisted CLI env', async () => { const markerDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'od-conn-test-env-')); const envFile = path.join(markerDir, 'env.json'); const codexHome = path.join(markerDir, 'codex-home'); try { await withFakeCodex( ` const fs = require('node:fs'); fs.writeFileSync(${JSON.stringify(envFile)}, JSON.stringify({ CODEX_HOME: process.env.CODEX_HOME || null, OPENAI_BASE_URL: process.env.OPENAI_BASE_URL || null, CODEX_API_KEY: process.env.CODEX_API_KEY || null, SHOULD_NOT_PASS: process.env.OD_CONNECTION_TEST_SHOULD_NOT_PASS || null, })); console.log(JSON.stringify({ type: 'item.completed', item: { type: 'agent_message', text: 'ok' } })); setImmediate(() => process.exit(0)); `, async () => { // CODEX_API_KEY only flows through when the user has also // configured a custom OPENAI_BASE_URL — i.e. they intend to // authenticate Codex CLI against a third-party gateway. Without // the base URL, spawnEnvForAgent strips the credential so Codex // CLI's own `codex login` wins (issue #2420). const res = await realFetch(`${baseUrl}/api/test/connection`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ mode: 'agent', agentId: 'codex', agentCliEnv: { codex: { CODEX_HOME: codexHome, OPENAI_BASE_URL: 'https://proxy.example.com/v1', CODEX_API_KEY: 'codex-key', OD_CONNECTION_TEST_SHOULD_NOT_PASS: 'leaked', }, claude: { CLAUDE_CONFIG_DIR: path.join(markerDir, 'claude'), }, }, }), }); expect(res.status).toBe(200); await expect(res.json()).resolves.toMatchObject({ ok: true, kind: 'success', agentName: 'Codex CLI', }); await expect(fsp.readFile(envFile, 'utf8')).resolves.toBe( JSON.stringify({ CODEX_HOME: codexHome, OPENAI_BASE_URL: 'https://proxy.example.com/v1', CODEX_API_KEY: 'codex-key', SHOULD_NOT_PASS: null, }), ); }, ); } finally { await fsp.rm(markerDir, { recursive: true, force: true }); } }); it('strips stale Codex API keys when no custom OPENAI_BASE_URL is configured', async () => { const markerDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'od-conn-test-codex-strip-')); const envFile = path.join(markerDir, 'env.json'); const codexHome = path.join(markerDir, 'codex-home'); try { await withFakeCodex( ` const fs = require('node:fs'); fs.writeFileSync(${JSON.stringify(envFile)}, JSON.stringify({ CODEX_HOME: process.env.CODEX_HOME || null, OPENAI_API_KEY: process.env.OPENAI_API_KEY || null, CODEX_API_KEY: process.env.CODEX_API_KEY || null, })); console.log(JSON.stringify({ type: 'item.completed', item: { type: 'agent_message', text: 'ok' } })); setImmediate(() => process.exit(0)); `, async () => { // Simulates the user flow that triggered issue #2420: a stale // BYOK OPENAI_API_KEY sat in agentCliEnv.codex from a previous // session, the user cleared the BYOK dialog (which doesn't // touch agentCliEnv) and switched back to Local CLI. Without // an OPENAI_BASE_URL the daemon must keep the secret out of // the spawn so Codex CLI's own `codex login` wins. const res = await realFetch(`${baseUrl}/api/test/connection`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ mode: 'agent', agentId: 'codex', agentCliEnv: { codex: { CODEX_HOME: codexHome, OPENAI_API_KEY: 'sk-stale-byok', CODEX_API_KEY: 'sk-stale-byok', }, }, }), }); expect(res.status).toBe(200); await expect(res.json()).resolves.toMatchObject({ ok: true, kind: 'success', agentName: 'Codex CLI', }); await expect(fsp.readFile(envFile, 'utf8')).resolves.toBe( JSON.stringify({ CODEX_HOME: codexHome, OPENAI_API_KEY: null, CODEX_API_KEY: null, }), ); }, ); } finally { await fsp.rm(markerDir, { recursive: true, force: true }); } }); it('waits for the Codex process before accepting early success text', async () => { await withFakeCodex( ` console.log(JSON.stringify({ type: 'item.completed', item: { type: 'agent_message', text: 'ok' } })); setTimeout(() => { console.log(JSON.stringify({ type: 'error', message: 'late failure after ok' })); setTimeout(() => process.exit(1), 50); }, 700); `, async () => { const res = await realFetch(`${baseUrl}/api/test/connection`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ mode: 'agent', agentId: 'codex' }), }); expect(res.status).toBe(200); await expect(res.json()).resolves.toMatchObject({ ok: false, kind: 'agent_spawn_failed', agentName: 'Codex CLI', detail: 'late failure after ok', }); }, ); }); it('classifies split agent model-error text after buffering the full response', async () => { await withFakeCodex( ` console.log(JSON.stringify({ type: 'item.completed', item: { type: 'agent_message', text: 'Error:' } })); console.log(JSON.stringify({ type: 'item.completed', item: { type: 'agent_message', text: ' model not found' } })); `, async () => { const res = await realFetch(`${baseUrl}/api/test/connection`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ mode: 'agent', agentId: 'codex', model: 'missing-model' }), }); await expect(res.json()).resolves.toMatchObject({ ok: false, kind: 'not_found_model', model: 'missing-model', }); }, ); }); it('reports structured agent stream errors without treating them as success', async () => { await withFakeCodex( `console.log(JSON.stringify({ type: 'error', message: "The 'gpt-5.5' model requires a newer version of Codex." }));`, async () => { const result = await testAgentConnection({ agentId: 'codex' }); expect(result).toMatchObject({ ok: false, kind: 'agent_spawn_failed', agentName: 'Codex CLI', }); expect(result.detail).toContain('requires a newer version'); }, ); }); it('wraps Claude connection smoke prompts as stream-json stdin', async () => { await withFakeClaude( ` let input = ''; process.stdin.setEncoding('utf8'); process.stdin.on('data', (chunk) => { input += chunk; }); process.stdin.on('end', () => { try { const line = input.trim(); const parsed = JSON.parse(line); const content = parsed?.message?.content; if ( parsed.type !== 'user' || parsed.message?.role !== 'user' || !Array.isArray(content) || content[0]?.type !== 'text' || content[0]?.text !== 'Reply with only: ok' ) { console.error('unexpected stdin payload: ' + line); process.exit(1); } console.log(JSON.stringify({ type: 'assistant', message: { id: 'msg_1', content: [{ type: 'text', text: 'ok' }], stop_reason: 'end_turn', }, })); } catch (err) { console.error(err instanceof Error ? err.message : String(err)); process.exit(1); } }); `, async () => { const result = await testAgentConnection({ agentId: 'claude' }); expect(result).toMatchObject({ ok: true, kind: 'success', agentName: 'Claude Code', sample: 'ok', }); }, ); }); it('preserves ANTHROPIC_API_KEY when Claude adapter launches the OpenClaude fallback', async () => { const envFile = path.join(os.tmpdir(), `od-openclaude-env-${Date.now()}-${Math.random()}.json`); const previousKey = process.env.ANTHROPIC_API_KEY; try { process.env.ANTHROPIC_API_KEY = 'sk-openclaude-test'; await withOnlyFakeOpenClaude( ` const fs = require('node:fs'); fs.writeFileSync(${JSON.stringify(envFile)}, JSON.stringify({ ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY || null, })); let input = ''; process.stdin.setEncoding('utf8'); process.stdin.on('data', (chunk) => { input += chunk; }); process.stdin.on('end', () => { try { JSON.parse(input.trim()); console.log(JSON.stringify({ type: 'assistant', message: { id: 'msg_1', content: [{ type: 'text', text: 'ok' }], stop_reason: 'end_turn', }, })); } catch (err) { console.error(err instanceof Error ? err.message : String(err)); process.exit(1); } }); `, async () => { const result = await testAgentConnection({ agentId: 'claude' }); expect(result).toMatchObject({ ok: true, kind: 'success', agentName: 'Claude Code', }); await expect(fsp.readFile(envFile, 'utf8')).resolves.toBe( JSON.stringify({ ANTHROPIC_API_KEY: 'sk-openclaude-test' }), ); expect(result.diagnostics?.binaryPath ?? '').toMatch(/openclaude/i); }, ); } finally { if (previousKey === undefined) delete process.env.ANTHROPIC_API_KEY; else process.env.ANTHROPIC_API_KEY = previousKey; await fsp.rm(envFile, { force: true }); } }); it('returns Claude /login guidance when the spawned CLI cannot authenticate', async () => { await withFakeClaude( `console.error(JSON.stringify({ apiKeySource: 'none', error_status: 401 })); process.exit(1);`, async () => { const result = await testAgentConnection({ agentId: 'claude' }); expect(result).toMatchObject({ ok: false, kind: 'agent_spawn_failed', agentName: 'Claude Code', }); expect(result.detail).toContain('/login'); expect(result.detail).toContain('CLAUDE_CONFIG_DIR'); }, ); }); it('returns Claude /login guidance when auth failure stream JSON is emitted on stdout', async () => { await withFakeClaude( `console.log(JSON.stringify({ apiKeySource: 'none', error_status: 401 })); process.exit(1);`, async () => { const result = await testAgentConnection({ agentId: 'claude' }); expect(result).toMatchObject({ ok: false, kind: 'agent_spawn_failed', agentName: 'Claude Code', }); expect(result.detail).toContain('/login'); expect(result.detail).toContain('CLAUDE_CONFIG_DIR'); }, ); }); it('returns custom endpoint guidance for Claude model access failures', async () => { const previous = process.env.ANTHROPIC_BASE_URL; process.env.ANTHROPIC_BASE_URL = 'https://proxy.example.com'; try { await withFakeClaude( `console.error('Error: The selected model is not available in your current plan or region.'); process.exit(1);`, async () => { const result = await testAgentConnection({ agentId: 'claude' }); expect(result).toMatchObject({ ok: false, kind: 'agent_spawn_failed', agentName: 'Claude Code', }); expect(result.detail).toContain('ANTHROPIC_BASE_URL'); expect(result.detail).toContain('custom'); }, ); } finally { if (previous == null) { delete process.env.ANTHROPIC_BASE_URL; } else { process.env.ANTHROPIC_BASE_URL = previous; } } }); it('returns custom endpoint guidance for Claude auth failures with a custom endpoint', async () => { const previous = process.env.ANTHROPIC_BASE_URL; process.env.ANTHROPIC_BASE_URL = 'https://proxy.example.com'; try { await withFakeClaude( `console.error(JSON.stringify({ apiKeySource: 'none', error_status: 401 })); process.exit(1);`, async () => { const result = await testAgentConnection({ agentId: 'claude' }); expect(result).toMatchObject({ ok: false, kind: 'agent_spawn_failed', agentName: 'Claude Code', }); expect(result.detail).toContain('ANTHROPIC_BASE_URL'); expect(result.detail).toContain('proxy credentials'); expect(result.detail).not.toContain('use `/login`'); }, ); } finally { if (previous == null) { delete process.env.ANTHROPIC_BASE_URL; } else { process.env.ANTHROPIC_BASE_URL = previous; } } }); it('returns configured profile guidance for silent Claude exits', async () => { const previous = process.env.CLAUDE_CONFIG_DIR; process.env.CLAUDE_CONFIG_DIR = '/tmp/claude-alt'; try { await withFakeClaude( `process.exit(1);`, async () => { const result = await testAgentConnection({ agentId: 'claude' }); expect(result).toMatchObject({ ok: false, kind: 'agent_spawn_failed', agentName: 'Claude Code', }); expect(result.detail).toContain('configured Claude profile'); expect(result.detail).toContain('Effective CLAUDE_CONFIG_DIR: /tmp/claude-alt'); }, ); } finally { if (previous == null) { delete process.env.CLAUDE_CONFIG_DIR; } else { process.env.CLAUDE_CONFIG_DIR = previous; } } }); it('classifies structured Codex model errors as not_found_model', async () => { await withFakeCodex( `console.log(JSON.stringify({ type: 'error', message: "The 'dddd' model is not supported when using Codex with a ChatGPT account." }));`, async () => { const res = await realFetch(`${baseUrl}/api/test/connection`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ mode: 'agent', agentId: 'codex', model: 'dddd', }), }); expect(res.status).toBe(200); await expect(res.json()).resolves.toMatchObject({ ok: false, kind: 'not_found_model', model: 'dddd', agentName: 'Codex CLI', detail: "The 'dddd' model is not supported when using Codex with a ChatGPT account.", }); }, ); }); it('uses CODEX_BIN overrides when testing agent connections', async () => { const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'od-conn-test-codex-bin-')); const oldPath = process.env.PATH; try { const bin = path.join(dir, 'codex-next'); await fsp.writeFile( bin, `#!/usr/bin/env node\nconsole.log(JSON.stringify({ type: 'item.completed', item: { type: 'agent_message', text: 'ok' } }));\n`, ); await fsp.chmod(bin, 0o755); process.env.PATH = oldPath ?? ''; const result = await testAgentConnection({ agentId: 'codex', agentCliEnv: { codex: { CODEX_BIN: bin, }, }, }); expect(result).toMatchObject({ ok: true, kind: 'success', agentName: 'Codex CLI', usedExecutableSource: 'configured', configuredExecutablePath: bin, usedExecutablePath: bin, }); expect(result.detail).toContain(`This test used the configured Codex path: ${bin}.`); } finally { process.env.PATH = oldPath; await fsp.rm(dir, { recursive: true, force: true }); } }); it('surfaces when an invalid configured CODEX_BIN was ignored in favor of PATH', async () => { await withFakeCodex( `console.log(JSON.stringify({ type: 'item.completed', item: { type: 'agent_message', text: 'ok' } }));\n`, async () => { const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'od-conn-test-codex-invalid-')); try { const invalidBin = path.join(dir, 'codex-missing'); const result = await testAgentConnection({ agentId: 'codex', agentCliEnv: { codex: { CODEX_BIN: invalidBin, }, }, }); expect(result).toMatchObject({ ok: true, kind: 'success', agentName: 'Codex CLI', sample: 'ok', usedExecutableSource: 'fallback_invalid', configuredExecutablePath: invalidBin, detectedExecutablePath: expect.any(String), usedExecutablePath: expect.any(String), }); expect(result.detail).toContain(`Configured Codex path is invalid or not executable: ${invalidBin}.`); expect(result.detail).toContain('This test used the PATH Codex CLI at'); } finally { await fsp.rm(dir, { recursive: true, force: true }); } }, ); }); it('falls back to PATH Codex during connection tests when a configured CODEX_BIN fails', async () => { await withFakeCodex( `console.log(JSON.stringify({ type: 'item.completed', item: { type: 'agent_message', text: 'ok' } }));\n`, async () => { const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'od-conn-test-codex-fallback-')); try { const bin = path.join(dir, 'codex-bad'); await fsp.writeFile( bin, `#!/usr/bin/env node\nconsole.error('macOS blocked this Codex binary');\nprocess.exit(1);\n`, ); await fsp.chmod(bin, 0o755); const result = await testAgentConnection({ agentId: 'codex', agentCliEnv: { codex: { CODEX_BIN: bin, }, }, }); expect(result).toMatchObject({ ok: true, kind: 'success', agentName: 'Codex CLI', sample: 'ok', usedExecutableSource: 'fallback_failed', configuredExecutablePath: bin, detectedExecutablePath: expect.any(String), usedExecutablePath: expect.any(String), }); expect(result.detail).toContain(`Configured Codex path failed: ${bin}.`); expect(result.detail).toContain('This test succeeded with the PATH Codex CLI at'); expect(result.detail).toContain('Update CODEX_BIN or clear the custom path'); } finally { await fsp.rm(dir, { recursive: true, force: true }); } }, ); }); it('falls back to PATH Codex when a configured shim spawns ENOENT', async () => { await withFakeCodex( `console.log(JSON.stringify({ type: 'item.completed', item: { type: 'agent_message', text: 'ok' } }));\n`, async () => { const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'od-conn-test-codex-stale-shim-')); try { const bin = path.join(dir, 'codex-stale-shim'); await fsp.writeFile( bin, '#!/definitely/missing/node\nconsole.log("never runs");\n', ); await fsp.chmod(bin, 0o755); const result = await testAgentConnection({ agentId: 'codex', agentCliEnv: { codex: { CODEX_BIN: bin, }, }, }); expect(result).toMatchObject({ ok: true, kind: 'success', agentName: 'Codex CLI', sample: 'ok', usedExecutableSource: 'fallback_failed', configuredExecutablePath: bin, detectedExecutablePath: expect.any(String), usedExecutablePath: expect.any(String), }); expect(result.detail).toContain(`Configured Codex path failed: ${bin}.`); expect(result.detail).toContain('This test succeeded with the PATH Codex CLI at'); } finally { await fsp.rm(dir, { recursive: true, force: true }); } }, ); }); it('reports OpenCode structured errors without treating them as raw output', async () => { await withFakeOpenCode( ` const args = process.argv.slice(2); if (args[0] === 'models') { console.log('openai/gpt-5'); process.exit(0); } console.log(JSON.stringify({ type: 'error', error: { data: { message: 'OpenCode auth failed: login required' } } })); setTimeout(() => process.exit(0), 50); `, async () => { const res = await realFetch(`${baseUrl}/api/test/connection`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ mode: 'agent', agentId: 'opencode' }), }); expect(res.status).toBe(200); await expect(res.json()).resolves.toMatchObject({ ok: false, kind: 'agent_spawn_failed', agentName: 'OpenCode', detail: 'OpenCode auth failed: login required', }); }, ); }); it('launches OpenCode connection tests with 1.3-compatible JSON stdin args', async () => { const markerDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'od-opencode-argv-')); const argvFile = path.join(markerDir, 'argv.json'); const stdinFile = path.join(markerDir, 'stdin.txt'); try { await withFakeOpenCode( ` const fs = require('node:fs'); const args = process.argv.slice(2); if (args[0] === 'models') { console.log('github-copilot/gpt-4o'); process.exit(0); } fs.writeFileSync(${JSON.stringify(argvFile)}, JSON.stringify(args)); let stdin = ''; process.stdin.setEncoding('utf8'); process.stdin.on('data', (chunk) => { stdin += chunk; }); process.stdin.on('end', () => { fs.writeFileSync(${JSON.stringify(stdinFile)}, stdin); if (args.includes('--dangerously-skip-permissions') || args.includes('--model')) { console.error('incompatible opencode args'); process.exit(1); return; } console.log(JSON.stringify({ type: 'text', part: { text: 'ok' } })); }); `, async () => { const res = await realFetch(`${baseUrl}/api/test/connection`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ mode: 'agent', agentId: 'opencode', model: 'github-copilot/gpt-4o', }), }); expect(res.status).toBe(200); await expect(res.json()).resolves.toMatchObject({ ok: true, kind: 'success', agentName: 'OpenCode', model: 'github-copilot/gpt-4o', sample: 'ok', }); await expect(fsp.readFile(argvFile, 'utf8')).resolves.toBe( JSON.stringify([ 'run', '--format', 'json', '-m', 'github-copilot/gpt-4o', ]), ); await expect(fsp.readFile(stdinFile, 'utf8')).resolves.toBe('Reply with only: ok'); }, ); } finally { await fsp.rm(markerDir, { recursive: true, force: true }); } }); it('reports Cursor Agent status auth failures before running the smoke prompt', async () => { await withFakeCursorAgent( ` const args = process.argv.slice(2); if (args[0] === '--version') { console.log('2026.05.07-test'); process.exit(0); } if (args[0] === 'models') { console.log('No models available for this account.'); process.exit(0); } if (args[0] === 'status') { console.error("Authentication required. Please run 'agent login' first, or set CURSOR_API_KEY environment variable."); process.exit(1); } console.error('smoke prompt should not run when status reports missing auth'); process.exit(1); `, async () => { const res = await realFetch(`${baseUrl}/api/test/connection`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ mode: 'agent', agentId: 'cursor-agent' }), }); expect(res.status).toBe(200); await expect(res.json()).resolves.toMatchObject({ ok: false, kind: 'agent_auth_required', agentName: 'Cursor Agent', detail: expect.stringContaining('cursor-agent login'), }); }, ); }); it('reports Cursor Agent Not logged in status before running the smoke prompt', async () => { await withFakeCursorAgent( ` const args = process.argv.slice(2); if (args[0] === '--version') { console.log('2026.05.07-test'); process.exit(0); } if (args[0] === 'models') { console.log('No models available for this account.'); process.exit(0); } if (args[0] === 'status') { console.error('Not logged in'); process.exit(1); } console.error('smoke prompt should not run when status reports missing auth'); process.exit(1); `, async () => { const res = await realFetch(`${baseUrl}/api/test/connection`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ mode: 'agent', agentId: 'cursor-agent' }), }); expect(res.status).toBe(200); await expect(res.json()).resolves.toMatchObject({ ok: false, kind: 'agent_auth_required', agentName: 'Cursor Agent', detail: expect.stringContaining('cursor-agent login'), }); }, ); }); it('classifies Cursor Agent runtime auth failures from stderr', async () => { await withFakeCursorAgent( ` const args = process.argv.slice(2); if (args[0] === '--version') { console.log('2026.05.07-test'); process.exit(0); } if (args[0] === 'models') { console.log('auto'); process.exit(0); } if (args[0] === 'status') { console.log('Authenticated'); process.exit(0); } console.error("Authentication required. Please run 'agent login' first, or set CURSOR_API_KEY environment variable."); process.exit(1); `, async () => { const result = await testAgentConnection({ agentId: 'cursor-agent' }); expect(result).toMatchObject({ ok: false, kind: 'agent_auth_required', agentName: 'Cursor Agent', detail: expect.stringContaining('cursor-agent status'), }); }, ); }); it('classifies DeepSeek TUI config guidance from stderr as missing auth', async () => { await withFakeDeepSeek( ` const args = process.argv.slice(2); if (args[0] === '--version') { console.log('deepseek 0.3.0-test'); process.exit(0); } console.error('KEY= deepseek --api-key '); console.error('api_key = "" in ~/.deepseek/config.toml'); process.exit(0); `, async () => { const result = await testAgentConnection({ agentId: 'deepseek' }); expect(result).toMatchObject({ ok: false, kind: 'agent_auth_required', agentName: 'DeepSeek TUI', detail: expect.stringContaining('~/.deepseek/config.toml'), }); expect(result.detail).toContain('DEEPSEEK_API_KEY'); }, ); }); it('keeps non-auth Cursor Agent runtime failures on the generic spawn path', async () => { await withFakeCursorAgent( ` const args = process.argv.slice(2); if (args[0] === '--version') { console.log('2026.05.07-test'); process.exit(0); } if (args[0] === 'models') { console.log('auto'); process.exit(0); } if (args[0] === 'status') { console.log('Authenticated'); process.exit(0); } console.error('workspace path does not exist'); process.exit(1); `, async () => { const result = await testAgentConnection({ agentId: 'cursor-agent' }); expect(result).toMatchObject({ ok: false, kind: 'agent_spawn_failed', agentName: 'Cursor Agent', }); expect(result.detail).toContain('workspace path does not exist'); }, ); }); it('rejects invalid custom model ids before spawning an agent', async () => { const markerDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'od-conn-test-argv-')); const argvFile = path.join(markerDir, 'argv.json'); try { await withFakeCodex( ` const fs = require('node:fs'); const args = process.argv.slice(2); fs.writeFileSync(${JSON.stringify(argvFile)}, JSON.stringify(args)); console.log(JSON.stringify({ type: 'item.completed', item: { type: 'agent_message', text: 'ok' } })); `, async () => { const res = await realFetch(`${baseUrl}/api/test/connection`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ mode: 'agent', agentId: 'codex', model: '--not-a-model', reasoning: 'totally-invalid-effort', }), }); expect(res.status).toBe(200); await expect(res.json()).resolves.toMatchObject({ ok: false, kind: 'invalid_model_id', model: '--not-a-model', agentName: 'Codex CLI', }); await expect(fsp.access(argvFile)).rejects.toThrow(); }, ); } finally { await fsp.rm(markerDir, { recursive: true, force: true }); } }); it('drops invalid agent reasoning options before spawning an agent', async () => { const markerDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'od-conn-test-argv-')); const argvFile = path.join(markerDir, 'argv.json'); try { await withFakeCodex( ` const fs = require('node:fs'); const args = process.argv.slice(2); fs.writeFileSync(${JSON.stringify(argvFile)}, JSON.stringify(args)); console.log(JSON.stringify({ type: 'item.completed', item: { type: 'agent_message', text: 'ok' } })); `, async () => { const res = await realFetch(`${baseUrl}/api/test/connection`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ mode: 'agent', agentId: 'codex', model: 'gpt-5', reasoning: 'totally-invalid-effort', }), }); expect(res.status).toBe(200); await expect(res.json()).resolves.toMatchObject({ ok: true, kind: 'success', model: 'gpt-5', }); const args = JSON.parse(await fsp.readFile(argvFile, 'utf8')) as string[]; expect(args).toEqual(expect.arrayContaining(['--model', 'gpt-5'])); expect(args.some((arg) => arg.includes('model_reasoning_effort'))).toBe(false); expect(args.some((arg) => arg.includes('totally-invalid-effort'))).toBe(false); }, ); } finally { await fsp.rm(markerDir, { recursive: true, force: true }); } }); it('reports unknown when the agent emits only raw schema-drift output', async () => { await withFakeCodex( `console.log(JSON.stringify({ type: 'future.event', payload: { text: 'ok' } }));`, async () => { const result = await testAgentConnection({ agentId: 'codex' }); expect(result).toMatchObject({ ok: false, kind: 'unknown', agentName: 'Codex CLI', }); }, ); }); it('hard-cancels aborted agent probes before cleaning up', async () => { const markerDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'od-conn-test-marker-')); const pidFile = path.join(markerDir, 'pid'); const termFile = path.join(markerDir, 'term'); try { await withFakeCodex( ` const fs = require('node:fs'); fs.writeFileSync(${JSON.stringify(pidFile)}, String(process.pid)); process.on('SIGTERM', () => { fs.writeFileSync(${JSON.stringify(termFile)}, 'term'); }); setInterval(() => {}, 1000); `, async () => { const controller = new AbortController(); const pending = testAgentConnection({ agentId: 'codex', signal: controller.signal, }); await Promise.race([ waitForFile(pidFile, 15_000), pending.then((result) => { throw new Error( `Agent probe finished before fake agent wrote pid: ${JSON.stringify(result)}`, ); }), ]); controller.abort(); await expect(pending).resolves.toMatchObject({ ok: false, kind: 'timeout', }); }, ); if (process.platform !== 'win32') { await expect(fsp.readFile(termFile, 'utf8')).resolves.toBe('term'); } const pid = Number(await fsp.readFile(pidFile, 'utf8')); if (process.platform === 'win32') { process.kill(pid, 'SIGKILL'); await waitForPidToExit(pid); } else { expect(() => process.kill(pid, 0)).toThrow(); } } finally { await fsp.rm(markerDir, { recursive: true, force: true }); } }, 10_000); it('reports agent_not_installed for an unknown agent id', async () => { const res = await realFetch(`${baseUrl}/api/test/connection`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ mode: 'agent', agentId: 'this-agent-does-not-exist' }), }); expect(res.status).toBe(200); const body = (await res.json()) as Record; expect(body.ok).toBe(false); expect(body.kind).toBe('agent_not_installed'); expect(body.model).toBe('default'); }); it('rejects requests missing agentId with HTTP 400', async () => { const res = await realFetch(`${baseUrl}/api/test/connection`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ mode: 'agent' }), }); expect(res.status).toBe(400); }); // Regression coverage for #2248: the daemon must return structured // diagnostics next to the existing `kind`/`detail` strings so Settings // and CLI consumers don't have to scrape the human-readable detail // line to know what phase failed, which binary path was used, or what // the child's exit metadata was. The legacy fields stay unchanged so // older clients keep rendering. it('attaches structured diagnostics on Claude smoke-test success (#2248)', async () => { await withFakeClaude( ` let input = ''; process.stdin.setEncoding('utf8'); process.stdin.on('data', (chunk) => { input += chunk; }); process.stdin.on('end', () => { try { JSON.parse(input.trim()); console.log(JSON.stringify({ type: 'assistant', message: { id: 'msg_1', content: [{ type: 'text', text: 'ok' }], stop_reason: 'end_turn', }, })); } catch (err) { console.error(err instanceof Error ? err.message : String(err)); process.exit(1); } }); `, async () => { const result = await testAgentConnection({ agentId: 'claude' }); expect(result).toMatchObject({ ok: true, kind: 'success' }); expect(result.diagnostics).toBeDefined(); expect(result.diagnostics?.phase).toBe('connection_smoke_test'); // The binary path is whatever fake bin the test harness installed // on PATH (a temp directory). All we want here is that the // daemon actually fills it in, not that it matches an exact path. expect(typeof result.diagnostics?.binaryPath).toBe('string'); expect(result.diagnostics?.binaryPath ?? '').toMatch(/claude/); expect(result.diagnostics?.exitCode).toBe(0); }, ); }); it('attaches structured diagnostics on Claude exit-failed (#2248)', async () => { await withFakeClaude( `console.error('boom-on-stderr'); process.exit(7);`, async () => { const result = await testAgentConnection({ agentId: 'claude' }); expect(result.ok).toBe(false); // Back-compat: existing kind + detail keep their shape. expect(typeof result.kind).toBe('string'); expect(typeof result.detail).toBe('string'); // New: structured fields are attached. expect(result.diagnostics).toBeDefined(); expect(result.diagnostics?.phase).toBe('spawn'); expect(result.diagnostics?.exitCode).toBe(7); expect(result.diagnostics?.stderrTail ?? '').toContain('boom-on-stderr'); expect(result.diagnostics?.binaryPath ?? '').toMatch(/claude/); }, ); }); it('reports an early-phase diagnostics block when the agent CLI is missing (#2248)', async () => { // Isolate every resolver input so the daemon truly cannot locate // `claude`, even on machines that have a pinned CLAUDE_BIN or an // alternate user toolchain home configured. PATH alone is no longer // sufficient because runtime resolution also consults CLI env // overrides and OD_AGENT_HOME-scoped toolchain bins. const oldPath = process.env.PATH; const oldClaudeBin = process.env.CLAUDE_BIN; const oldAgentHome = process.env.OD_AGENT_HOME; const emptyHome = await fsp.mkdtemp(path.join(os.tmpdir(), 'od-missing-claude-home-')); process.env.PATH = ''; delete process.env.CLAUDE_BIN; process.env.OD_AGENT_HOME = emptyHome; try { const result = await testAgentConnection({ agentId: 'claude' }); expect(result.ok).toBe(false); expect(['agent_not_installed', 'agent_spawn_failed']).toContain(result.kind); expect(result.diagnostics).toBeDefined(); expect(['binary_resolution', 'spawn']).toContain(result.diagnostics?.phase); } finally { process.env.PATH = oldPath; if (oldClaudeBin === undefined) delete process.env.CLAUDE_BIN; else process.env.CLAUDE_BIN = oldClaudeBin; if (oldAgentHome === undefined) delete process.env.OD_AGENT_HOME; else process.env.OD_AGENT_HOME = oldAgentHome; await fsp.rm(emptyHome, { recursive: true, force: true }); } }); it('attaches diagnostics when the preflight auth probe reports missing auth (#2248)', async () => { // Cursor Agent's preflight `cursor-agent status` check rejects the // smoke run before the daemon ever spawns the smoke prompt. The // initial #2248 pass forgot to stamp diagnostics on that return // path, which contradicted the "Always set on local agent test // responses" contract in packages/contracts. Lock the contract, // and additionally lock the probe's own stderr/exit metadata — // without those, the diagnostics block would drop the only context // a caller has on a missing-auth failure (no smoke spawn ever ran, // so the smoke sink is empty). await withFakeCursorAgent( ` const args = process.argv.slice(2); if (args[0] === '--version') { console.log('2026.05.07-test'); process.exit(0); } if (args[0] === 'models') { console.log('auto'); process.exit(0); } if (args[0] === 'status') { console.error('Not logged in'); process.exit(1); } console.error('smoke prompt should not run when status reports missing auth'); process.exit(1); `, async () => { const result = await testAgentConnection({ agentId: 'cursor-agent' }); expect(result).toMatchObject({ ok: false, kind: 'agent_auth_required', }); expect(result.diagnostics).toBeDefined(); // Preflight runs after binary resolution but before the smoke // spawn, so phase should still be 'binary_resolution'. expect(result.diagnostics?.phase).toBe('binary_resolution'); expect(result.diagnostics?.binaryPath ?? '').toMatch(/cursor-agent/); // The probe child wrote "Not logged in" on stderr and exited // 1; both must propagate into diagnostics so Settings/CLI can // render the structured auth-failure context. expect(result.diagnostics?.stderrTail ?? '').toContain('Not logged in'); expect(result.diagnostics?.exitCode).toBe(1); }, ); }); }); describe('connection test helpers', () => { it('redacts the exact submitted provider key when it appears in body text', () => { const detail = redactSecrets( 'Incorrect API key provided: sk-test-raw-secret.', ['sk-test-raw-secret'], ); expect(detail).toBe('Incorrect API key provided: [REDACTED].'); expect(detail).not.toContain('sk-test-raw-secret'); }); it('does not resolve the agent smoke test from thinking deltas', async () => { vi.useFakeTimers(); const sink = createAgentSink(); sink.send('agent', { type: 'thinking_delta', delta: 'thinking first' }); let settled = false; sink.result.then(() => { settled = true; }); await vi.advanceTimersByTimeAsync(1_000); expect(settled).toBe(false); sink.send('agent', { type: 'text_delta', delta: 'ok' }); await vi.advanceTimersByTimeAsync(500); await expect(sink.result).resolves.toEqual({ kind: 'text', text: 'ok' }); }); it('rejects the agent smoke test from structured stream errors', async () => { const sink = createAgentSink(); sink.send('agent', { type: 'error', message: "The 'gpt-5.5' model requires a newer version of Codex.", }); await expect(sink.result).resolves.toMatchObject({ kind: 'streamError', error: expect.objectContaining({ message: "The 'gpt-5.5' model requires a newer version of Codex.", }), }); }); it('debounces agent text chunks before resolving', async () => { vi.useFakeTimers(); const sink = createAgentSink(); sink.send('agent', { type: 'text_delta', delta: 'Error:' }); await vi.advanceTimersByTimeAsync(499); sink.send('agent', { type: 'text_delta', delta: ' model not found' }); await vi.advanceTimersByTimeAsync(500); await expect(sink.result).resolves.toEqual({ kind: 'text', text: 'Error: model not found', }); }); it('requires the smoke reply to be exactly ok after whitespace and case', () => { expect(isSmokeOkReply('ok')).toBe(true); expect(isSmokeOkReply(' OK \n')).toBe(true); expect(isSmokeOkReply('ok.')).toBe(false); expect( isSmokeOkReply( "There's an issue with the selected model (abcde). It may not exist.", ), ).toBe(false); }); }); describe('connection test timeout overrides', () => { it('returns the fallback when the override is missing or empty', () => { expect( resolveConnectionTestTimeoutMs('OD_CONNECTION_TEST_PROVIDER_TIMEOUT_MS', 12_000, {}), ).toBe(12_000); expect( resolveConnectionTestTimeoutMs('OD_CONNECTION_TEST_AGENT_TIMEOUT_MS', 45_000, { OD_CONNECTION_TEST_AGENT_TIMEOUT_MS: '', }), ).toBe(45_000); }); it('honors a positive integer override', () => { expect( resolveConnectionTestTimeoutMs('OD_CONNECTION_TEST_PROVIDER_TIMEOUT_MS', 12_000, { OD_CONNECTION_TEST_PROVIDER_TIMEOUT_MS: '30000', }), ).toBe(30_000); expect( resolveConnectionTestTimeoutMs('OD_CONNECTION_TEST_AGENT_TIMEOUT_MS', 45_000, { OD_CONNECTION_TEST_AGENT_TIMEOUT_MS: '120000', }), ).toBe(120_000); }); it('warns and falls back on non-numeric, zero, negative, or non-integer overrides', () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); try { for (const bad of ['fast', '0', '-1', '1.5', 'NaN']) { expect( resolveConnectionTestTimeoutMs('OD_CONNECTION_TEST_PROVIDER_TIMEOUT_MS', 12_000, { OD_CONNECTION_TEST_PROVIDER_TIMEOUT_MS: bad, }), ).toBe(12_000); } expect(warn).toHaveBeenCalled(); } finally { warn.mockRestore(); } }); // Regression: a previous version of resolveConnectionTestTimeoutMs // accepted any positive integer, but Node's setTimeout silently // clamps delays above 2^31-1 to ~1 ms (with a TimeoutOverflowWarning). // An override that meant to extend the budget would instead make // every connection test fail almost immediately — the safety // timeout would be effectively disarmed. it('rejects values above the Node setTimeout maximum (2^31-1)', () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); try { const tooLarge = '3000000000'; // ~50 minutes; exceeds 2_147_483_647 ms expect( resolveConnectionTestTimeoutMs('OD_CONNECTION_TEST_AGENT_TIMEOUT_MS', 45_000, { OD_CONNECTION_TEST_AGENT_TIMEOUT_MS: tooLarge, }), ).toBe(45_000); // The exact maximum is still accepted; anything past it is not. expect( resolveConnectionTestTimeoutMs('OD_CONNECTION_TEST_AGENT_TIMEOUT_MS', 45_000, { OD_CONNECTION_TEST_AGENT_TIMEOUT_MS: '2147483647', }), ).toBe(2_147_483_647); expect( resolveConnectionTestTimeoutMs('OD_CONNECTION_TEST_AGENT_TIMEOUT_MS', 45_000, { OD_CONNECTION_TEST_AGENT_TIMEOUT_MS: '2147483648', }), ).toBe(45_000); expect(warn).toHaveBeenCalled(); } finally { warn.mockRestore(); } }); }); 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(); }); });