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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion packages/codev-sandbox/src/bwrap/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
6 changes: 5 additions & 1 deletion packages/codev-sandbox/src/vz/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
21 changes: 21 additions & 0 deletions packages/codev-sandbox/src/wsl/provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
6 changes: 5 additions & 1 deletion packages/codev-sandbox/src/wsl/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
43 changes: 28 additions & 15 deletions packages/desktop/src/main/sandbox/egress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SandboxEgress | null> {
// 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<SandboxEgress> {
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,
),
Expand Down
Loading