Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions src/core/__tests__/api-provider-models.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
56 changes: 56 additions & 0 deletions src/core/__tests__/api-provider-runtime.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import {
buildProviderChatRequest,
runProviderChatCompletion,
validateProviderRuntimeConfig,
} from "../api-provider-runtime";
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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/);
});
4 changes: 2 additions & 2 deletions src/core/__tests__/api-session-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];

Expand Down Expand Up @@ -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(
(
Expand Down
1 change: 1 addition & 0 deletions src/core/agent-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
}

Expand Down
5 changes: 3 additions & 2 deletions src/core/api-provider-models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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";
Expand Down
50 changes: 45 additions & 5 deletions src/core/api-provider-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 (
Expand All @@ -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 {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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";
Expand Down
5 changes: 4 additions & 1 deletion src/core/api-session-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand All @@ -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"}.`,
Expand Down
Loading