diff --git a/CHANGELOG.md b/CHANGELOG.md index a528383bcd..d87daffd9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,14 @@ file is the human-readable summary of what changed and why. ### Desktop / Web UI +- **Named egress rules for sandboxes** — administrators can grant sandboxes + extra brokered destinations beyond the model gateway by creating + `sandbox-egress.json` in the app's data directory: an object of name → + upstream URL (for example `{"npm": "https://registry.npmjs.org"}`), each + reachable inside every sandbox at `http://127.0.0.1:8788//...`. It + is an allowlist of named upstreams — there is deliberately no general + proxy mode — and a malformed file fails the sandbox start with the parse + error rather than silently granting less than intended. - **Reset now upgrades a sandbox to the current image** — each sandbox records which image it was provisioned from. When a new image is published, Reset re-provisions from it (previously the only way to pick diff --git a/packages/codev-sandbox/src/broker.test.ts b/packages/codev-sandbox/src/broker.test.ts index 43ce9088c4..f1ef99fb13 100644 --- a/packages/codev-sandbox/src/broker.test.ts +++ b/packages/codev-sandbox/src/broker.test.ts @@ -2,7 +2,7 @@ import { expect, test } from "bun:test" import { mkdtemp, rm } from "node:fs/promises" import { tmpdir } from "node:os" import { dirname, join } from "node:path" -import { createBrokerServer, forwardableRequestHeaders, resolveBrokerTarget } from "./broker" +import { createBrokerServer, forwardableRequestHeaders, parseBrokerRules, resolveBrokerTarget } from "./broker" const routes = [{ prefix: "/llm", upstream: "https://gateway.test/api/" }] @@ -91,6 +91,19 @@ test("serves the same routes over a unix socket for netns-isolated guests", asyn await rm(dirname(socketPath), { recursive: true, force: true }) }) +test("parses named egress rules and rejects malformed ones", () => { + expect(parseBrokerRules({ npm: "https://registry.npmjs.org", pypi: "http://mirror.corp/simple" })).toEqual([ + { prefix: "/npm", upstream: "https://registry.npmjs.org/" }, + { prefix: "/pypi", upstream: "http://mirror.corp/simple" }, + ]) + expect(parseBrokerRules({})).toEqual([]) + expect(() => parseBrokerRules(["npm"])).toThrow("object of name") + expect(() => parseBrokerRules({ "Bad Name": "https://x.test" })).toThrow("invalid sandbox egress rule name") + expect(() => parseBrokerRules({ llm: "https://x.test" })).toThrow("reserved") + expect(() => parseBrokerRules({ npm: "ftp://x.test" })).toThrow("http(s) upstream") + expect(() => parseBrokerRules({ npm: 5 })).toThrow("http(s) upstream") +}) + test("route headers replace the guest's placeholder credential", async () => { const upstream = Bun.serve({ port: 0, diff --git a/packages/codev-sandbox/src/broker.ts b/packages/codev-sandbox/src/broker.ts index df4963dd0e..84986e41b8 100644 --- a/packages/codev-sandbox/src/broker.ts +++ b/packages/codev-sandbox/src/broker.ts @@ -24,6 +24,26 @@ export type SandboxEgress = { fetchImpl?: typeof fetch } +// Named egress rules: the admin-editable shape for extra routes beyond the +// LLM gateway — an object of name → upstream URL, e.g. +// { "npm": "https://registry.npmjs.org" }. Each becomes /name on the guest +// broker stub (http://127.0.0.1:8788/npm/...). Deliberately an allowlist of +// named upstreams, never a general proxy. Throws on anything malformed so a +// bad config file fails the sandbox start with a message instead of silently +// granting nothing. +export function parseBrokerRules(input: unknown): BrokerRoute[] { + if (!input || typeof input !== "object" || Array.isArray(input)) + throw new Error("sandbox egress rules must be an object of name → upstream URL") + return Object.entries(input as Record).map(([name, upstream]) => { + if (!/^[a-z0-9][a-z0-9-]*$/.test(name)) + throw new Error(`invalid sandbox egress rule name: ${JSON.stringify(name)} (use lowercase letters, digits, dashes)`) + if (name === "llm") throw new Error(`sandbox egress rule name "llm" is reserved for the model gateway`) + if (typeof upstream !== "string" || !/^https?:\/\//.test(upstream)) + throw new Error(`sandbox egress rule ${JSON.stringify(name)} needs an http(s) upstream URL`) + return { prefix: `/${name}`, upstream: new URL(upstream).toString() } + }) +} + export function resolveBrokerRoute(routes: BrokerRoute[], requestUrl: string) { const url = new URL(requestUrl, "http://sandbox-broker.local") const route = routes.find( diff --git a/packages/desktop/src/main/sandbox/egress.ts b/packages/desktop/src/main/sandbox/egress.ts index e3a6c55568..59cad8035a 100644 --- a/packages/desktop/src/main/sandbox/egress.ts +++ b/packages/desktop/src/main/sandbox/egress.ts @@ -1,5 +1,8 @@ -import { net } from "electron" +import { readFile } from "node:fs/promises" +import { join } from "node:path" +import { app, net } from "electron" import { buildGatewayConfig, loadApiKey, normalizeGatewayBaseUrl } from "@codev/gateway" +import { parseBrokerRules } from "@codev/sandbox" import type { SandboxEgress } from "@codev/sandbox" // Guest-side view of the broker: every backend bridges guest 127.0.0.1:8788 @@ -16,18 +19,21 @@ export async function resolveSandboxEgress(): Promise { const creds = loadApiKey() const gateway = creds?.baseUrl && creds.model ? creds : null return { - routes: gateway - ? [ - { - prefix: "/llm/v1", - upstream: normalizeGatewayBaseUrl(gateway.baseUrl!), - // The guest config carries a placeholder key; the broker swaps in - // the real one, so the token never enters the guest and rotation - // stays host-side. - headers: { authorization: `Bearer ${gateway.apiKey}` }, - }, - ] - : [], + routes: [ + ...(gateway + ? [ + { + prefix: "/llm/v1", + upstream: normalizeGatewayBaseUrl(gateway.baseUrl!), + // The guest config carries a placeholder key; the broker swaps in + // the real one, so the token never enters the guest and rotation + // stays host-side. + headers: { authorization: `Bearer ${gateway.apiKey}` }, + }, + ] + : []), + ...(await namedEgressRules()), + ], guestConfig: JSON.stringify( { ...(gateway @@ -51,3 +57,14 @@ export async function resolveSandboxEgress(): Promise { fetchImpl: (input, init) => net.fetch(input instanceof URL ? input.toString() : input, init), } } + +// Optional admin-editable allowlist of extra egress routes (a mirror registry, +// an internal API): userData/sandbox-egress.json, an object of name → +// upstream URL. Reachable in the guest at http://127.0.0.1:8788//... +// A malformed file throws, so the sandbox start fails with the parse message +// instead of silently granting less than the admin intended. +async function namedEgressRules() { + const raw = await readFile(join(app.getPath("userData"), "sandbox-egress.json"), "utf8").catch(() => null) + if (raw === null) return [] + return parseBrokerRules(JSON.parse(raw)) +}