diff --git a/src/core/__tests__/api-provider-models.test.ts b/src/core/__tests__/api-provider-models.test.ts index 6043831..aabeab2 100644 --- a/src/core/__tests__/api-provider-models.test.ts +++ b/src/core/__tests__/api-provider-models.test.ts @@ -217,3 +217,25 @@ test("fetchProviderModels redacts provider errors", async () => { assert.equal(result.kind, "auth_failed"); assert.equal(result.detail, "invalid key [redacted]"); }); + +test("fetchProviderModels classifies 403 provider responses as forbidden", async () => { + const result = await fetchProviderModels( + resolveSessionRuntimeAuthConfig({ + session_auth_mode: "anthropic-api-key", + agent_api_protocol: "openai", + agent_model: "restricted-model", + agent_base_url: "https://api.openai.com/v1", + anthropic_api_key: "sk-openai-secret", + }), + { + fetchImpl: async () => + jsonResponse(403, { + error: { message: "model list is not enabled for this account" }, + }), + }, + ); + + assert.equal(result.ok, false); + if (result.ok) return; + assert.equal(result.kind, "forbidden"); +}); diff --git a/src/core/__tests__/api-provider-runtime.test.ts b/src/core/__tests__/api-provider-runtime.test.ts index 763c022..c68bc07 100644 --- a/src/core/__tests__/api-provider-runtime.test.ts +++ b/src/core/__tests__/api-provider-runtime.test.ts @@ -1,6 +1,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { + buildProviderChatRequest, runProviderChatCompletion, validateProviderRuntimeConfig, } from "../api-provider-runtime"; @@ -60,6 +61,45 @@ test("runProviderChatCompletion posts OpenAI-compatible chat history", async () }); }); +test("buildProviderChatRequest preserves existing versioned OpenAI-compatible base paths", () => { + const request = buildProviderChatRequest( + resolveSessionRuntimeAuthConfig({ + session_auth_mode: "anthropic-api-key", + agent_api_protocol: "openai", + agent_model: "proxy-model", + agent_base_url: "https://proxy.example.com/v2", + anthropic_api_key: "sk-proxy-test", + }), + new URL("https://proxy.example.com/v2"), + [{ role: "user", content: "Use proxy" }], + ); + + assert.equal(request.url, "https://proxy.example.com/v2/chat/completions"); +}); + +test("runProviderChatCompletion classifies 403 provider responses as forbidden", async () => { + const result = await runProviderChatCompletion( + resolveSessionRuntimeAuthConfig({ + session_auth_mode: "anthropic-api-key", + agent_api_protocol: "openai", + agent_model: "restricted-model", + agent_base_url: "https://api.openai.com/v1", + anthropic_api_key: "sk-openai-test", + }), + [{ role: "user", content: "Use restricted model" }], + { + fetchImpl: async () => + jsonResponse(403, { + error: { message: "model not enabled for this account" }, + }), + }, + ); + + assert.equal(result.ok, false); + if (result.ok) return; + assert.equal(result.kind, "forbidden"); +}); + test("validateProviderRuntimeConfig allows loopback Ollama without an API key", () => { const result = validateProviderRuntimeConfig( resolveSessionRuntimeAuthConfig({ @@ -133,3 +173,19 @@ test("validateProviderRuntimeConfig allows Azure OpenAI hosts", () => { assert.equal(result.ok, true); }); + +test("validateProviderRuntimeConfig blocks IPv4-mapped IPv6 private hosts", () => { + const result = validateProviderRuntimeConfig( + resolveSessionRuntimeAuthConfig({ + session_auth_mode: "anthropic-api-key", + agent_api_protocol: "openai", + agent_model: "gpt-4o-mini", + agent_base_url: "https://[::ffff:192.168.1.1]/v1", + anthropic_api_key: "sk-test", + }), + ); + + assert.equal(result.ok, false); + if (result.ok) return; + assert.match(result.error, /Internal API hosts are blocked/); +}); diff --git a/src/core/__tests__/api-session-runtime.test.ts b/src/core/__tests__/api-session-runtime.test.ts index 0817ea6..2b45fae 100644 --- a/src/core/__tests__/api-session-runtime.test.ts +++ b/src/core/__tests__/api-session-runtime.test.ts @@ -137,7 +137,7 @@ test("runProviderSessionTurn fails before recording a user message when the brow ); }); -test("runProviderSessionTurn does not save provider output after the session is killed", async () => { +test("runProviderSessionTurn removes the pending user message after the session is killed", async () => { const db = makeTestDb(); const events: ChatStreamEvent[] = []; @@ -181,7 +181,7 @@ test("runProviderSessionTurn does not save provider output after the session is "SELECT role, content FROM session_messages WHERE session_id = ? ORDER BY id ASC", ) .all("session-killed"), - [{ role: "user", content: "Stop before output" }], + [], ); assert.equal( ( diff --git a/src/core/agent-settings.ts b/src/core/agent-settings.ts index ca4e7dc..679a77e 100644 --- a/src/core/agent-settings.ts +++ b/src/core/agent-settings.ts @@ -563,6 +563,7 @@ export function getBrowserApiKeyScope( ): string { const apiProtocol = sanitizeApiProtocol(settings.apiProtocol); const apiBaseUrl = sanitizeApiBaseUrl(settings.apiBaseUrl, apiProtocol); + // Use a delimiter that cannot appear in sanitized protocol names or URLs. return `${apiProtocol}\n${apiBaseUrl}`; } diff --git a/src/core/api-provider-models.ts b/src/core/api-provider-models.ts index a901dbe..0edf03d 100644 --- a/src/core/api-provider-models.ts +++ b/src/core/api-provider-models.ts @@ -182,7 +182,7 @@ function appendVersionedApiPath( const url = new URL(baseUrl.toString()); const basePath = url.pathname.replace(/\/+$/, ""); const normalizedSuffix = suffix.startsWith("/") ? suffix : `/${suffix}`; - url.pathname = basePath.endsWith(`/${version}`) + url.pathname = /\/v\d+(?:beta)?$/.test(basePath) ? `${basePath}${normalizedSuffix}` : `${basePath}/${version}${normalizedSuffix}`; return url.toString(); @@ -260,7 +260,8 @@ function classifyProviderFailure( status: number, detail: string | undefined, ): ProviderRuntimeFailureKind { - if (status === 401 || status === 403) return "auth_failed"; + if (status === 401) return "auth_failed"; + if (status === 403) return "forbidden"; if (status === 429) return "rate_limited"; if (status >= 500) return "upstream_unavailable"; if (status === 404) return "not_found_model"; diff --git a/src/core/api-provider-runtime.ts b/src/core/api-provider-runtime.ts index 2008f3c..4ac74d5 100644 --- a/src/core/api-provider-runtime.ts +++ b/src/core/api-provider-runtime.ts @@ -74,6 +74,36 @@ function parseIpv4(hostname: string): [number, number, number, number] | null { return parsed as [number, number, number, number]; } +function parseIpv4MappedIpv6( + hostname: string, +): [number, number, number, number] | null { + const mappedPrefixes = ["::ffff:", "0:0:0:0:0:ffff:"]; + const prefix = mappedPrefixes.find((candidate) => + hostname.startsWith(candidate), + ); + if (!prefix) return null; + + const suffix = hostname.slice(prefix.length); + const dotted = parseIpv4(suffix); + if (dotted) return dotted; + + const parts = suffix.split(":"); + if (parts.length !== 2) return null; + const words = parts.map((part) => { + if (!/^[0-9a-f]{1,4}$/i.test(part)) return null; + return Number.parseInt(part, 16); + }); + if (words.some((word) => word === null)) return null; + const [high, low] = words as [number, number]; + return [high >> 8, high & 0xff, low >> 8, low & 0xff]; +} + +function parseIpv6FirstGroup(hostname: string): number | null { + const firstGroup = hostname.split(":", 1)[0]; + if (!/^[0-9a-f]{1,4}$/i.test(firstGroup)) return null; + return Number.parseInt(firstGroup, 16); +} + function normalizeHost(hostname: string): string { const stripped = hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) @@ -85,12 +115,15 @@ export function isLoopbackHost(hostname: string): boolean { const host = normalizeHost(hostname); if (host === "localhost" || host === "::1") return true; const ipv4 = parseIpv4(host); - return Boolean(ipv4 && ipv4[0] === 127); + const mappedIpv4 = parseIpv4MappedIpv6(host); + return Boolean( + (ipv4 && ipv4[0] === 127) || (mappedIpv4 && mappedIpv4[0] === 127), + ); } function isBlockedExternalHost(hostname: string): boolean { const host = normalizeHost(hostname); - const ipv4 = parseIpv4(host); + const ipv4 = parseIpv4(host) ?? parseIpv4MappedIpv6(host); if (ipv4) { const [a, b] = ipv4; return ( @@ -103,7 +136,13 @@ function isBlockedExternalHost(hostname: string): boolean { a >= 224 ); } - return host === "::" || /^f[cd][0-9a-f]{2}:/i.test(host) || /^fe[89ab][0-9a-f]:/i.test(host); + const firstGroup = parseIpv6FirstGroup(host); + return ( + host === "::" || + (firstGroup !== null && + ((firstGroup & 0xfe00) === 0xfc00 || + (firstGroup & 0xffc0) === 0xfe80)) + ); } export function validateAgentConnectionBaseUrl(baseUrl: string): ParsedBaseUrl { @@ -419,7 +458,7 @@ function appendVersionedApiPath(baseUrl: URL, suffix: string): string { const url = new URL(baseUrl.toString()); const basePath = url.pathname.replace(/\/+$/, ""); const normalizedSuffix = suffix.startsWith("/") ? suffix : `/${suffix}`; - url.pathname = basePath.endsWith("/v1") + url.pathname = /\/v\d+(?:beta)?$/.test(basePath) ? `${basePath}${normalizedSuffix}` : `${basePath}/v1${normalizedSuffix}`; return url.toString(); @@ -538,7 +577,8 @@ function classifyProviderFailure( status: number, detail: string | undefined, ): ProviderRuntimeFailureKind { - if (status === 401 || status === 403) return "auth_failed"; + if (status === 401) return "auth_failed"; + if (status === 403) return "forbidden"; if (status === 429) return "rate_limited"; if (status >= 500) return "upstream_unavailable"; if (status === 404) return "not_found_model"; diff --git a/src/core/api-session-runtime.ts b/src/core/api-session-runtime.ts index 80b263b..ac5a4c5 100644 --- a/src/core/api-session-runtime.ts +++ b/src/core/api-session-runtime.ts @@ -91,7 +91,7 @@ export async function runProviderSessionTurn({ return { ok: false, error: validated.error }; } - db.prepare( + const insertedUser = db.prepare( "INSERT INTO session_messages (session_id, role, content) VALUES (?, 'user', ?)", ).run(sessionId, message); emit(sessionId, { type: "message", role: "user", content: message }); @@ -107,6 +107,9 @@ export async function runProviderSessionTurn({ }); const postRequestStatus = getStoredSessionStatus(db, sessionId); if (postRequestStatus !== "running") { + db.prepare( + "DELETE FROM session_messages WHERE id = ? AND session_id = ? AND role = 'user'", + ).run(insertedUser.lastInsertRowid, sessionId); return { ok: false, error: `Provider response ignored because the session is ${postRequestStatus ?? "missing"}.`,