From aedaf4dc3cf0604803ef7bf58743571194145164 Mon Sep 17 00:00:00 2001 From: minhn4 Date: Thu, 13 Aug 2026 10:55:24 +0700 Subject: [PATCH] feat(sandbox): auto-approve permissions inside sandboxes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The injected guest config now always carries permission: "allow" (and is injected even without a gateway login — providers skip the broker when the route table is empty). Everything the agent does is confined to the VM and the one mounted project folder, so prompts buy nothing there; the server stops asking, and the app's existing enableConfiguredDirectory seam surfaces it as directory auto-accept, which the user can still toggle off and a project config can override (project merges after CODEV_CONFIG). Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 7 +++ packages/codev-sandbox/src/bwrap/provider.ts | 6 ++- packages/codev-sandbox/src/vz/provider.ts | 6 ++- .../codev-sandbox/src/wsl/provider.test.ts | 21 +++++++++ packages/codev-sandbox/src/wsl/provider.ts | 6 ++- packages/desktop/src/main/sandbox/egress.ts | 43 ++++++++++++------- 6 files changed, 71 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2bf372fdf7..b0ebfe6c67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,13 @@ file is the human-readable summary of what changed and why. ### Desktop / Web UI +- **Sandboxes auto-approve permissions** — inside a sandbox, everything the + agent does is confined to the VM and the one mounted project folder, so + permission prompts are skipped by default: sandbox projects behave as if + auto-accept were switched on, which is what lets you hand the sandbox a + task and walk away. The directory-level auto-accept toggle still works to + turn it off for a given project, and a project config inside the workspace + can set stricter rules. - **Sandboxes can now talk to the model gateway** — each running sandbox gets a host-side egress broker: the guest (which has no network access of its own) reaches exactly one brokered path that forwards to the configured LLM diff --git a/packages/codev-sandbox/src/bwrap/provider.ts b/packages/codev-sandbox/src/bwrap/provider.ts index 67cb6cb764..baa6cf5c03 100644 --- a/packages/codev-sandbox/src/bwrap/provider.ts +++ b/packages/codev-sandbox/src/bwrap/provider.ts @@ -105,7 +105,11 @@ export function createBwrapProvider(options: BwrapProviderOptions): SandboxProvi // Listening before boot: the guest-side bridge dials broker.sock per // connection and drops it when nothing serves the socket. const egress = (await options.resolveEgress?.()) ?? null - const broker = egress ? createBrokerServer({ routes: egress.routes, fetchImpl: egress.fetchImpl ?? fetchImpl }) : null + // No routes means no egress at all — the config injection below can + // still apply without a listener. + const broker = egress?.routes.length + ? createBrokerServer({ routes: egress.routes, fetchImpl: egress.fetchImpl ?? fetchImpl }) + : null if (broker) await broker.listenUnix(join(dirs.runtime, "broker.sock")) if (egress?.guestConfig) await writeFile(join(dirs.bridge, "codev.json"), egress.guestConfig) diff --git a/packages/codev-sandbox/src/vz/provider.ts b/packages/codev-sandbox/src/vz/provider.ts index 35f23105ab..23dd9a5059 100644 --- a/packages/codev-sandbox/src/vz/provider.ts +++ b/packages/codev-sandbox/src/vz/provider.ts @@ -116,7 +116,11 @@ export function createVzProvider(options: VzProviderOptions): SandboxProvider { // dials runtime/broker.sock per guest connection and refuses when // nothing serves it. const egress = (await options.resolveEgress?.()) ?? null - const broker = egress ? createBrokerServer({ routes: egress.routes, fetchImpl: egress.fetchImpl ?? fetchImpl }) : null + // No routes means no egress at all — the config injection below can + // still apply without a listener. + const broker = egress?.routes.length + ? createBrokerServer({ routes: egress.routes, fetchImpl: egress.fetchImpl ?? fetchImpl }) + : null if (broker) await broker.listenUnix(join(dirs.runtime, "broker.sock")) if (egress?.guestConfig) await writeFile(join(dirs.guestConfig, "codev.json"), egress.guestConfig) diff --git a/packages/codev-sandbox/src/wsl/provider.test.ts b/packages/codev-sandbox/src/wsl/provider.test.ts index 9f36a5b12c..6fd5a591a3 100644 --- a/packages/codev-sandbox/src/wsl/provider.test.ts +++ b/packages/codev-sandbox/src/wsl/provider.test.ts @@ -192,6 +192,27 @@ test("egress wires the broker bridge, guest config, and CODEV_CONFIG", async () await rm(dir, { recursive: true, force: true }) }) +test("routeless egress still injects the guest config but starts no broker", async () => { + const dir = await mkdtemp(join(tmpdir(), "wsl-test-")) + const provider = createWslProvider( + await preparedOptions(dir, { + resolveEgress: async () => ({ routes: [], guestConfig: `{"permission":"allow"}` }), + }), + ) + await provider.create(config) + const sandbox = await provider.start(config) + + const state = JSON.parse(await Bun.file(join(dir, "state.json")).text()) + expect(state.bootScript).not.toContain("socat") + expect(state.bootScript).toContain("/codev-config") + expect(state.codevConfigEnv).toBe("/codev-config/codev.json") + expect(await Bun.file(join(dir, "data", config.name, "config", "codev.json")).text()).toBe(`{"permission":"allow"}`) + + sandbox.stop() + await provider.destroy(config) + await rm(dir, { recursive: true, force: true }) +}) + test("create then start reaches an authenticated health check, and destroy unregisters", async () => { const dir = await mkdtemp(join(tmpdir(), "wsl-test-")) const provider = createWslProvider(await preparedOptions(dir)) diff --git a/packages/codev-sandbox/src/wsl/provider.ts b/packages/codev-sandbox/src/wsl/provider.ts index db51ed3bdc..4e48f5c7d3 100644 --- a/packages/codev-sandbox/src/wsl/provider.ts +++ b/packages/codev-sandbox/src/wsl/provider.ts @@ -144,7 +144,11 @@ export function createWslProvider(options: WslProviderOptions): SandboxProvider const port = await reserveLoopbackPort() const egress = (await options.resolveEgress?.()) ?? null - const broker = egress ? createBrokerServer({ routes: egress.routes, fetchImpl: egress.fetchImpl ?? fetchImpl }) : null + // No routes means no egress at all — the config injection below can + // still apply without a listener. + const broker = egress?.routes.length + ? createBrokerServer({ routes: egress.routes, fetchImpl: egress.fetchImpl ?? fetchImpl }) + : null // The guest dials the host at the NAT default gateway address, so the // broker cannot bind loopback here. const brokerPort = broker ? await broker.listen("0.0.0.0") : null diff --git a/packages/desktop/src/main/sandbox/egress.ts b/packages/desktop/src/main/sandbox/egress.ts index 132ba0bfde..e3a6c55568 100644 --- a/packages/desktop/src/main/sandbox/egress.ts +++ b/packages/desktop/src/main/sandbox/egress.ts @@ -9,25 +9,38 @@ import type { SandboxEgress } from "@codev/sandbox" const GUEST_BROKER_BASE = "http://127.0.0.1:8788/llm" // Resolved at every sandbox start so a rotated gateway token is picked up by -// the next start rather than requiring an app restart. Null (no gateway login -// on this machine) leaves the sandbox with no egress at all: the guest boots -// and serves, but model calls fail — the same behavior as before the broker. -export async function resolveSandboxEgress(): Promise { +// the next start rather than requiring an app restart. Without a gateway +// login the routes are empty (no broker, no egress at all), but the guest +// config is still injected for the permission default below. +export async function resolveSandboxEgress(): Promise { const creds = loadApiKey() - if (!creds?.baseUrl || !creds.model) return null + const gateway = creds?.baseUrl && creds.model ? creds : null return { - routes: [ + 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}` }, + }, + ] + : [], + guestConfig: JSON.stringify( { - prefix: "/llm/v1", - upstream: normalizeGatewayBaseUrl(creds.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 ${creds.apiKey}` }, + ...(gateway + ? buildGatewayConfig({ apiKey: "sandbox-broker", baseUrl: GUEST_BROKER_BASE, model: gateway.model! }) + : {}), + // Everything the agent does is confined to the VM and the one mounted + // project folder, so permission prompts buy nothing here — auto-allow + // is what makes "hand it a task and walk away" work. The app surfaces + // this as directory auto-accept (enableConfiguredDirectory), which the + // user can still toggle off per directory, and a project config inside + // the workspace can override it (project merges after CODEV_CONFIG). + permission: "allow", }, - ], - guestConfig: JSON.stringify( - buildGatewayConfig({ apiKey: "sandbox-broker", baseUrl: GUEST_BROKER_BASE, model: creds.model }), null, 2, ),