Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
fb5e9e7
core/types: add SandboxConfigSchema for docker sandbox slice
lec77 May 27, 2026
8327a87
core/config: load + Zod-validate sandbox slice; expose defaults.sandbox
lec77 May 27, 2026
f44c837
test/core: restore SKVM_CACHE + invalidate cache in afterEach for get…
lec77 May 27, 2026
904b886
core/config: env-fallback for route apiKey (SKVM_ROUTE_<id>_KEY) for …
lec77 May 27, 2026
c79dd60
launcher: path-flag enumeration + resolver
lec77 May 27, 2026
35b08e9
launcher/path-flags: complete enumeration sweep (add 9 missed flags +…
lec77 May 27, 2026
e648d62
launcher: compose default + dynamic bind mounts with path-prefix dedup
lec77 May 27, 2026
bbc4583
launcher/mounts: default existsSync to fs.existsSync so required-path…
lec77 May 27, 2026
508dd69
launcher/mounts: fix stale comments + remove dead computeInnerPath
lec77 May 27, 2026
799a3f7
launcher: compose env (proxy passthrough + SKVM_ROUTE_<id>_KEY + sand…
lec77 May 27, 2026
a87f7c4
launcher: write sanitized in-container skvm.config.json (keys stripped)
lec77 May 27, 2026
3fd5f33
launcher: resolve image ref + ensureImagePresent (pull fallback to bu…
lec77 May 27, 2026
8f8422e
launcher: build docker run argv (hardening + mounts + env + labels)
lec77 May 27, 2026
b361f9e
launcher: reap leaked containers + tmp dirs by host pid label
lec77 May 27, 2026
e3fb704
launcher: orchestrate sandbox dispatch (compose + exec docker run)
lec77 May 27, 2026
6e9e3a4
cli: --sandbox flag + SKVM_IN_SANDBOX re-entry detection + launcher d…
lec77 May 27, 2026
104b8d9
cli: hard-error on --sandbox + config commands (assertSandboxCompatib…
lec77 May 27, 2026
e44b9dd
launcher: add --mount-extra (repeatable) + --debug-sandbox + config e…
lec77 May 27, 2026
b0590f3
docker: add skvm-sandbox image (Ubuntu 24.04 + Bun + opencode + claud…
lec77 May 27, 2026
c5e751a
cli-config: init prompt for defaults.sandbox
lec77 May 27, 2026
e677933
cli-config: show renders sandbox slice (defaults + image + resources …
lec77 May 27, 2026
c0827de
cli-config: doctor checks docker presence + image present (sev scales…
lec77 May 27, 2026
5660024
docs: README section for --sandbox + image build + cleanup recipes
lec77 May 27, 2026
21d1455
launcher/image: lowercase ghcr.io org name (sjtu-ipads) per docker re…
lec77 May 27, 2026
0f06c5b
cli-flags: add sandbox to GLOBAL_FLAGS so --sandbox=false opt-out pas…
lec77 May 27, 2026
8ed16ee
launcher/env: set SKVM_CACHE=/skvm-cache (+SKVM_DATA_DIR) so in-conta…
lec77 May 28, 2026
81918d1
cli: derive sandbox guard command from positionals so --sandbox befor…
lec77 May 28, 2026
c95b9e8
launcher/config-sanitize: point apiKeyEnv at injected env var instead…
lec77 May 28, 2026
09f5ab7
core/config: enforce --sandbox + native incompatibility at resolveAda…
lec77 May 29, 2026
a2b4882
cli: --sandbox=<bad-value> hard-errors instead of silently running un…
lec77 May 29, 2026
347a555
launcher: redact secret env values in --debug-sandbox output
lec77 May 29, 2026
3d6af89
launcher/env: detect route-match collisions and fail loud instead of …
lec77 May 29, 2026
98414ee
launcher: deny --mount-extra of docker socket / host root; refuse roo…
lec77 May 29, 2026
45dea4c
sandbox: validate memory/cpus in schema; reject env values containing…
lec77 May 29, 2026
729d367
launcher/stale-reap: timeout docker calls + swallow errors so a hung …
lec77 May 29, 2026
aefe989
cli: print clean error message by default (full stack under --verbose…
lec77 May 29, 2026
2212fad
launcher: mount comma-list path flags per element
lec77 Jun 1, 2026
6b360cc
launcher: validate config extra mounts and pre-create cache root
lec77 Jun 1, 2026
1e66982
launcher: forward SKVM_AUTO_PROBE into the sandbox
lec77 Jun 1, 2026
b04d611
launcher: fix root-prefix precedence and pre-create rw mount sources
lec77 Jun 2, 2026
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
64 changes: 64 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,70 @@ mkdir -p ~/.skvm/profiles
cp -R skvm-data/profiles/. ~/.skvm/profiles/
```

## Sandbox (Docker)

Pass `--sandbox` to any skvm command to run the entire skvm process inside an
ephemeral Docker container. Default behaviour is unchanged — without
`--sandbox`, skvm runs on the host as before.

```bash
skvm run --sandbox --skill=./my-skill --task=./task.json
skvm bench --sandbox --suite=...
skvm jit-optimize --sandbox --skill=./foo --target-model=openrouter/...
```

### Image

The launcher pulls
`ghcr.io/sjtu-ipads/skvm-sandbox:<your-skvm-version>` from GitHub Container
Registry. If the pull fails (offline, no auth, image not published yet for
your version), build the image locally:

```bash
bun run build:binary
docker build -f docker/skvm-sandbox.Dockerfile \
-t ghcr.io/sjtu-ipads/skvm-sandbox:$(bun run skvm --version) .
```

### Cleaning up leaked containers

The launcher reaps containers automatically on the next invocation, but you
can force-clean any time:

```bash
docker ps -a --filter label=skvm-sandbox=1 -q | xargs docker rm -f
```

### Mounts

Three host paths are bind-mounted into the container:

| Host | Inner | Mode |
| --- | --- | --- |
| `$(pwd)` | `/workspace` (container `WORKDIR`) | rw |
| `$SKVM_CACHE` (default `~/.skvm`) | `/skvm-cache` | rw |
| `$SKVM_DATA_DIR` (if set) | `/skvm-data` | ro |

Path-shaped CLI flags whose values fall outside these roots get a dynamic
per-flag mount under `/extra/`. The launcher rewrites the value to the
inner path automatically.

### Making sandbox the default

Set `defaults.sandbox = true` in `~/.skvm/skvm.config.json` (or via
`skvm config init`). With the default on, every command runs in sandbox
unless you pass `--sandbox=false`.

### Limits

Default `--cap-drop=ALL`, `--security-opt no-new-privileges`,
`--network=bridge`, `--memory=2g`, `--cpus=2`, `--pids-limit=512`. Override
any of these in `sandbox.docker.*` in `skvm.config.json`.

`--sandbox` is incompatible with `native` adapter mode (which imports host
credentials by design) and with `skvm config init|show|doctor` (which
manage host-side state).

## Learn more

- **[docs/usage.md](docs/usage.md)** — full command reference: `profile`, `aot-compile`, `run`, `bench`, `jit-optimize`, `proposals`, and more
Expand Down
48 changes: 48 additions & 0 deletions docker/skvm-sandbox.Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# syntax=docker/dockerfile:1.7
FROM ubuntu:24.04

ENV DEBIAN_FRONTEND=noninteractive \
LANG=C.UTF-8 \
SKVM_IN_SANDBOX=1

RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates curl git python3 python3-pip nodejs npm jq unzip \
&& rm -rf /var/lib/apt/lists/*

# Bun
RUN curl -fsSL https://bun.sh/install | bash \
&& mv /root/.bun/bin/bun /usr/local/bin/bun \
&& chmod +x /usr/local/bin/bun

# opencode — not published on npm; installed from GitHub release binary.
# Pinned to v1.4.3 (anomalyco/opencode). Matches skvm install/opencode-version.json.
# SHA-256 verified for linux-x64 asset; bump deliberately and update the hash.
ARG OPENCODE_VERSION=v1.4.3
ARG OPENCODE_SHA256=34d503ebb029853293be6fd4d441bbb2dbb03919bfa4525e88b1ca55d68f3e17
RUN set -e \
&& curl -fsSL \
"https://github.com/anomalyco/opencode/releases/download/${OPENCODE_VERSION}/opencode-linux-x64.tar.gz" \
-o /tmp/opencode.tar.gz \
&& echo "${OPENCODE_SHA256} /tmp/opencode.tar.gz" | sha256sum -c - \
&& tar -xzf /tmp/opencode.tar.gz -C /tmp \
&& mv /tmp/opencode /usr/local/bin/opencode \
&& chmod +x /usr/local/bin/opencode \
&& rm /tmp/opencode.tar.gz

# @anthropic-ai/claude-code — published on npm. Pin at known version; bump deliberately.
RUN npm install -g @anthropic-ai/claude-code@2.1.152

# pi / hermes / openclaw: install paths to be filled in when the image is
# first built; TODO follow-ups will add them. For now bare-agent + opencode +
# claude-code is the minimum useful image.

# Baked skvm binary. Build host-side with `bun run build:binary` against the
# matching skvm version, then copy. (The Dockerfile expects dist/skvm to be a
# Linux x86_64 binary — cross-compile on the host before docker build.)
COPY dist/skvm /usr/local/bin/skvm
RUN chmod +x /usr/local/bin/skvm

WORKDIR /workspace

# Do not bake a USER; the launcher passes -u host-uid:host-gid at run time so
# bind-mounted writes are owned by the invoking user.
129 changes: 122 additions & 7 deletions src/cli-config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ import { spawnSync } from "node:child_process"
import path from "node:path"
import { stdin } from "node:process"

import pkgJson from "../../package.json" with { type: "json" }
import { resolveImageRef } from "../launcher/image.ts"

import { checkbox, confirm, input, password, select } from "@inquirer/prompts"
import { createPrompt, isEnterKey, useKeypress, useState } from "@inquirer/core"

Expand All @@ -37,6 +40,8 @@ import {
getAdapterRepoDir,
getAdapterSettings,
getDefaultAdapterConfigMode,
getSandboxConfig,
getDefaultSandboxMode,
detectLegacyHeadlessFields,
invalidateConfigCache,
resolveConfigWritePath,
Expand Down Expand Up @@ -80,7 +85,7 @@ interface AdapterDraft {
interface ConfigDraft {
adapters: Partial<Record<AdapterName, AdapterDraft>>
providers: { routes: RouteDraft[] }
defaults?: { adapterConfigMode?: AdapterConfigMode }
defaults?: { adapterConfigMode?: AdapterConfigMode; sandbox?: boolean }
/**
* Preserved as an opaque passthrough on re-init — the wizard doesn't
* configure these fields (credentials and endpoints come from
Expand Down Expand Up @@ -231,6 +236,23 @@ async function runShow(): Promise<void> {
const defMode = getDefaultAdapterConfigMode() ?? "(unset → managed)"
printRow("Adapter mode", String(defMode), "defaults.adapterConfigMode")

console.log(c.bold("\nSandbox (Docker):"))
try {
const sandboxSlice = getSandboxConfig()
const sandboxDefaultsOn = getDefaultSandboxMode()
console.log(` Default for new invocations: ${sandboxDefaultsOn ? c.green("on") : c.dim("off")}`)
console.log(` Image: ${sandboxSlice.docker.image ?? c.dim("(built-in default)")}`)
console.log(` Network: ${sandboxSlice.docker.network}`)
console.log(` Resources: memory=${sandboxSlice.docker.memory} cpus=${sandboxSlice.docker.cpus} pids=${sandboxSlice.docker.pidsLimit}`)
const xm = sandboxSlice.docker.extraMounts
if (xm.length > 0) {
console.log(` Extra mounts:`)
for (const m of xm) console.log(` ${m.host} → ${m.inner} (${m.mode})`)
}
} catch (e) {
console.log(` ${c.red("✗")} could not parse: ${String(e)}`)
}

console.log(c.bold("\nAdapters"))
const labelW = Math.max(...ALL_ADAPTERS.map(a => a.length))
for (const a of ALL_ADAPTERS) {
Expand Down Expand Up @@ -409,6 +431,7 @@ async function runInit(): Promise<void> {
if (action.section === "providers") await stepProviders(draft)
else if (action.section === "mode") await stepDefaultMode(draft)
else if (action.section === "adapters") await stepAdapters(draft)
else if (action.section === "sandbox") await stepSandbox(draft)
}

tuiClear()
Expand Down Expand Up @@ -533,9 +556,14 @@ function loadExistingDraft(): ConfigDraft {
}
if (raw.defaults && typeof raw.defaults === "object") {
const d = raw.defaults as Record<string, unknown>
const restored: ConfigDraft["defaults"] = {}
if (d.adapterConfigMode === "native" || d.adapterConfigMode === "managed") {
draft.defaults = { adapterConfigMode: d.adapterConfigMode }
restored.adapterConfigMode = d.adapterConfigMode
}
if (typeof d.sandbox === "boolean") {
restored.sandbox = d.sandbox
}
if (Object.keys(restored).length > 0) draft.defaults = restored
}
if (raw.providers && typeof raw.providers === "object") {
const routes = (raw.providers as { routes?: unknown }).routes
Expand Down Expand Up @@ -1066,9 +1094,35 @@ async function pickNativeAgent(opts: {
})).trim() || def
}

// --- Step 4: sandbox (Docker) ------------------------------------------------

async function stepSandbox(draft: ConfigDraft): Promise<void> {
try {
console.log(c.bold("Sandbox (Docker)"))
console.log(c.dim(" When --sandbox is set on a command, skvm re-execs itself inside an"))
console.log(c.dim(" ephemeral Docker container. You can opt in per-invocation or make"))
console.log(c.dim(" sandbox the default for every command."))

const sandboxDefault = await confirm({
message: "Make --sandbox the default for every command?",
default: draft.defaults?.sandbox ?? false,
})

draft.defaults = draft.defaults ?? {}
draft.defaults.sandbox = sandboxDefault

if (sandboxDefault) {
console.log(c.dim(" (You can opt out of any single invocation with --sandbox=false.)"))
}
} catch (e) {
if (isExit(e)) return
throw e
}
}

// --- TUI section pager -------------------------------------------------------

type SectionId = "providers" | "mode" | "adapters" | "write"
type SectionId = "providers" | "mode" | "adapters" | "sandbox" | "write"

interface Section {
id: SectionId
Expand All @@ -1079,6 +1133,7 @@ const SECTIONS: Section[] = [
{ id: "providers", label: "Providers" },
{ id: "mode", label: "Default mode" },
{ id: "adapters", label: "Adapters" },
{ id: "sandbox", label: "Sandbox" },
{ id: "write", label: "✓ Write & exit" },
]

Expand Down Expand Up @@ -1130,11 +1185,15 @@ function renderSectionBody(draft: ConfigDraft, index: number): string {
case "adapters":
return indent(summarizeAdapters(draft).trimStart())
+ "\n\n " + c.dim("Press Enter to configure adapters.")
case "sandbox":
return indent(summarizeSandbox(draft).trimStart())
+ "\n\n " + c.dim("Press Enter to configure sandbox defaults.")
case "write": {
const full = [
summarizeProviders(draft),
summarizeDefaultMode(draft),
summarizeAdapters(draft),
summarizeSandbox(draft),
].join("\n")
const target = shortenPath(CONFIG_WRITE_PATH)
return indent(full.trimStart())
Expand Down Expand Up @@ -1185,6 +1244,11 @@ function summarizeAdapters(draft: ConfigDraft): string {
return lines.join("\n")
}

function summarizeSandbox(draft: ConfigDraft): string {
const on = draft.defaults?.sandbox === true
return `\n${c.bold("Sandbox (Docker):")} default --sandbox ${on ? c.green("on") : c.dim("off")}`
}

// ---------------------------------------------------------------------------
// `doctor` — environment health check
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -1391,6 +1455,48 @@ async function runDoctor(): Promise<void> {
})
}

// Sandbox (Docker) checks — rendered as a separate section after the main
// results table so the output groups clearly. `sandboxOk` participates in
// the overall exit code only when sandbox is the default for all invocations.
let sandboxOk = true
const sandboxSectionLines: string[] = []
let sandboxSlice
try {
sandboxSlice = getSandboxConfig()
} catch (e) {
sandboxSectionLines.push(` ${c.red("✗")} sandbox slice malformed: ${e}`)
sandboxOk = false
}

const sandboxDefaultsOn = getDefaultSandboxMode()
sandboxSectionLines.push(` default --sandbox: ${sandboxDefaultsOn ? "on" : "off"}`)

const dockerCheck = spawnSync("docker", ["--version"], { encoding: "utf-8" })
if (dockerCheck.status === 0) {
sandboxSectionLines.push(` ${c.green("✓")} docker available (${dockerCheck.stdout.trim()})`)
} else {
const sev = sandboxDefaultsOn ? "✗" : "(info)"
sandboxSectionLines.push(` ${sandboxDefaultsOn ? c.red(sev) : c.dim(sev)} docker not on PATH`)
if (sandboxDefaultsOn) sandboxOk = false
}

if (sandboxSlice) {
const imageRef = resolveImageRef({
cliOverride: null,
configImage: sandboxSlice.docker.image,
skvmVersion: (pkgJson as { version: string }).version,
})
const inspect = spawnSync("docker", ["image", "inspect", imageRef], { stdio: "ignore" })
if (inspect.status === 0) {
sandboxSectionLines.push(` ${c.green("✓")} image present: ${imageRef}`)
} else {
const sev = sandboxDefaultsOn ? "✗" : "(info)"
sandboxSectionLines.push(` ${sandboxDefaultsOn ? c.red(sev) : c.dim(sev)} image not pulled: ${imageRef}`)
sandboxSectionLines.push(` build with: docker build -f docker/skvm-sandbox.Dockerfile -t ${imageRef} .`)
if (sandboxDefaultsOn) sandboxOk = false
}
}

// Print results
console.log()
let fails = 0, warns = 0
Expand All @@ -1406,6 +1512,11 @@ async function runDoctor(): Promise<void> {
}
console.log()

// Sandbox section output
console.log(c.bold("Sandbox (Docker):"))
for (const line of sandboxSectionLines) console.log(line)
console.log()

// Migration note: warn if prior opencode proposals exist but the config
// does not pin headlessAgent.driver (meaning the user may not have noticed
// that the default flipped from opencode to pi).
Expand All @@ -1420,8 +1531,9 @@ async function runDoctor(): Promise<void> {
))
}

if (fails > 0) {
console.log(c.yellow(`${fails} issue(s) to look at.`) + ` See the items above marked ${c.red("✗")}.`)
const totalFails = fails + (sandboxOk ? 0 : 1)
if (totalFails > 0) {
console.log(c.yellow(`${totalFails} issue(s) to look at.`) + ` See the items above marked ${c.red("✗")}.`)
} else if (warns > 0) {
console.log(c.yellow(`${warns} warning(s).`) + " Things should work, but read the notes above.")
} else {
Expand Down Expand Up @@ -1465,8 +1577,11 @@ export { appendDiscoveredRoute } from "../core/config-write.ts"
function serialize(draft: ConfigDraft): string {
// Drop empty optional fields so the output stays minimal.
const out: Record<string, unknown> = {}
if (draft.defaults && draft.defaults.adapterConfigMode !== undefined) {
out.defaults = { adapterConfigMode: draft.defaults.adapterConfigMode }
if (draft.defaults && (draft.defaults.adapterConfigMode !== undefined || draft.defaults.sandbox !== undefined)) {
const d: Record<string, unknown> = {}
if (draft.defaults.adapterConfigMode !== undefined) d.adapterConfigMode = draft.defaults.adapterConfigMode
if (draft.defaults.sandbox !== undefined) d.sandbox = draft.defaults.sandbox
out.defaults = d
}
const adaptersOut: Record<string, unknown> = {}
for (const [k, v] of Object.entries(draft.adapters)) {
Expand Down
1 change: 1 addition & 0 deletions src/core/cli-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export const GLOBAL_FLAGS: ReadonlySet<string> = new Set([
"verbose",
"skvm-cache",
"skvm-data-dir",
"sandbox",
])

/**
Expand Down
Loading