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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>/...`. 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
Expand Down
15 changes: 14 additions & 1 deletion packages/codev-sandbox/src/broker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/" }]

Expand Down Expand Up @@ -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,
Expand Down
20 changes: 20 additions & 0 deletions packages/codev-sandbox/src/broker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>).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(
Expand Down
43 changes: 30 additions & 13 deletions packages/desktop/src/main/sandbox/egress.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -16,18 +19,21 @@ export async function resolveSandboxEgress(): Promise<SandboxEgress> {
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
Expand All @@ -51,3 +57,14 @@ export async function resolveSandboxEgress(): Promise<SandboxEgress> {
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/<name>/...
// 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))
}
Loading