diff --git a/.github/workflows/release-npm.yml b/.github/workflows/release-npm.yml index cd33bbd..6482df8 100644 --- a/.github/workflows/release-npm.yml +++ b/.github/workflows/release-npm.yml @@ -170,10 +170,35 @@ jobs: cache-all-crates: "true" cache-workspace-crates: "true" + - name: Build agentd from source (musl) so msb embeds the matching guest agent + run: | + # agentd (the guest PID-1 agent) is embedded into msb and injected + # at boot. Build it from the pinned source rather than downloading a + # release prebuilt, so the producer (msb) and consumer (agentd) + # always ship in lockstep — e.g. the boot-params side channel needs + # both halves at the same revision; a stale prebuilt agentd would + # silently drop every mount. Static musl: agentd runs before the + # rootfs is fully set up. + rustup target add x86_64-unknown-linux-musl + sudo apt-get install -y --no-install-recommends musl-tools + cargo build --release \ + --manifest-path vendor/microsandbox/crates/agentd/Cargo.toml \ + --target-dir vendor/microsandbox/target \ + --target x86_64-unknown-linux-musl + mkdir -p vendor/microsandbox/build + cp vendor/microsandbox/target/x86_64-unknown-linux-musl/release/agentd \ + vendor/microsandbox/build/agentd + touch vendor/microsandbox/build/agentd + - name: cargo build --release -p microsandbox-cli --bin msb run: | + # --no-default-features drops the `prebuilt` feature, so the + # filesystem crate embeds the build/agentd compiled just above + # instead of downloading a release prebuilt. net + keyring are the + # other CLI defaults and stay on. cargo build --release --target ${{ matrix.cargo_target }} \ --manifest-path vendor/microsandbox/Cargo.toml \ + --no-default-features --features net,keyring \ -p microsandbox-cli --bin msb - name: Upload msb binary diff --git a/crates/agent-vm/src/run.rs b/crates/agent-vm/src/run.rs index b445a11..66c4fe8 100644 --- a/crates/agent-vm/src/run.rs +++ b/crates/agent-vm/src/run.rs @@ -23,6 +23,26 @@ use crate::session::ProjectSession; /// a host project rooted here and fall back to `/workspace` instead. const TMPFS_GUEST_PREFIXES: &[&str] = &["/tmp", "/run", "/dev/shm", "/var/run"]; +/// Environment variables agent-vm injects into *every* guest, regardless of +/// agent or project. Listed in one place so the set is discoverable and +/// guard-testable. +/// +/// - `IS_SANDBOX=1`: Claude Code refuses to run as root with +/// `--dangerously-skip-permissions` unless this is set. The microVM is our +/// security boundary, so the in-guest CLI's extra guard is redundant; same +/// var the original Bash agent-vm used. +/// - `LANG=C.UTF-8`: the base image ships with no locale (`LANG`/`LC_*` +/// empty → C/POSIX). That breaks non-ASCII paths two ways: bash/readline +/// draws a Cyrillic cwd as `M-P…` meta-escapes instead of glyphs, and +/// locale-driven filesystem encodings default to ASCII so Python/Node/ +/// ripgrep mis-handle or error on a non-ASCII path even when it mounted +/// fine. C.UTF-8 is always present in glibc (no `locale-gen`), ASCII- +/// sorting + English-messages but UTF-8-aware — the right neutral sandbox +/// default. We don't propagate the host's `$LANG` (that locale may not +/// exist in the guest image and would silently fall back to C). Also +/// pinned in `images/Dockerfile` for non-agent-vm uses of the image. +const GUEST_ALWAYS_ENV: &[(&str, &str)] = &[("IS_SANDBOX", "1"), ("LANG", "C.UTF-8")]; + fn guest_path_is_safe(project: &Path) -> bool { let s = match project.to_str() { Some(s) => s, @@ -33,6 +53,66 @@ fn guest_path_is_safe(project: &Path) -> bool { .any(|p| s == *p || s.starts_with(&format!("{p}/"))) } +/// Whether `s` is safe to place on the guest **kernel command line**. +/// +/// libkrun packs the guest workdir (`KRUN_WORKDIR=`) into the kernel +/// command line, which its `Cmdline` builder validates as printable ASCII +/// only (`valid_char` accepts `' '..='~'`) and `.unwrap()`s — a non-ASCII +/// byte panics the VMM (`InvalidAscii`) before boot, and a space is +/// mis-tokenized by the guest kernel (`/proc/cmdline` splits on whitespace). +/// So a path that isn't printable, non-space ASCII (`is_ascii_graphic`, +/// `0x21..=0x7e`) can't be the `KRUN_WORKDIR` value. +/// +/// Note the *mount* specs no longer ride the cmdline — they travel via the +/// boot-params side channel (see [`microsandbox`]'s runtime), so the project +/// itself is still mirrored at its real (possibly Cyrillic) path. This +/// predicate only governs the `KRUN_WORKDIR` placeholder: when it fails we +/// hand libkrun `/` and pin the agent's real cwd via the exec request +/// instead (see [`launch`]). +fn guest_path_is_cmdline_safe(s: &str) -> bool { + s.bytes().all(|b| b.is_ascii_graphic()) +} + +/// Whether `s` can be carried into the guest as a mount point at all. +/// +/// Mount specs travel via the boot-params side channel, framed as +/// `KEY\tVALUE\n` lines. That transport is byte-transparent for everything +/// except a control character — a TAB or newline in the path would break +/// the framing, and other control bytes have no business in a mount point. +/// Such a path can't be mirrored and falls back to `/workspace`. +fn guest_path_is_mountable(s: &str) -> bool { + !s.chars().any(|c| c.is_control()) +} + +/// Decide the in-guest path to mirror the project at, plus a one-line +/// reason when we had to fall back to `/workspace` (for the launch +/// notice). The host bind always targets the real `project_dir` +/// regardless; only the *guest-visible* path changes. +/// +/// A non-ASCII or whitespace path is mirrored at its **real** location: +/// the mount spec rides the boot-params side channel (not the cmdline), +/// the mount-point dir is baked byte-for-byte into the rootfs, and the +/// agent's cwd is delivered over the byte-safe exec channel. Only two +/// things still force `/workspace`: a path under a guest tmpfs mount (see +/// [`guest_path_is_safe`]) would be wiped at boot, and a path with control +/// characters (see [`guest_path_is_mountable`]) can't be framed for the +/// side channel. +fn resolve_project_guest_path( + project_dir: &Path, + host_path: &str, +) -> (String, Option<&'static str>) { + if !guest_path_is_safe(project_dir) { + ("/workspace".to_string(), Some("is under a tmpfs mount")) + } else if !guest_path_is_mountable(host_path) { + ( + "/workspace".to_string(), + Some("contains control characters that can't be carried into the guest"), + ) + } else { + (host_path.to_string(), None) + } +} + /// Absolute directories that must exist inside the guest rootfs before /// microsandbox can mount the project at its host path. Returns paths from /// shallowest to deepest, e.g. for `/home/boger/work/foo`: @@ -338,14 +418,11 @@ pub async fn launch(agent: Agent, args: Args) -> Result { .project_dir .to_str() .context("project path contains non-UTF-8 bytes; not supported")?; - let project_guest_path = if guest_path_is_safe(&session.project_dir) { - host_path.to_string() - } else { - eprintln!( - "==> Project path {host_path} is under a tmpfs mount; mounting at /workspace instead" - ); - "/workspace".to_string() - }; + let (project_guest_path, remap_reason) = + resolve_project_guest_path(&session.project_dir, host_path); + if let Some(reason) = remap_reason { + eprintln!("==> Project path {host_path} {reason}; mounting at /workspace instead"); + } let mut patch_builder_steps = mkdir_chain(Path::new(&project_guest_path)); // PullPolicy::IfMissing keeps the slow part (pull + materialize) off // every launch. We separately HEAD the manifest at the registry via @@ -510,13 +587,27 @@ pub async fn launch(agent: Agent, args: Args) -> Result { } let is_local_registry = crate::pull::is_plain_http_registry(&image); + // `.workdir()` becomes libkrun's `KRUN_WORKDIR`, which rides the + // printable-ASCII-only kernel command line. When the real project path + // isn't cmdline-safe (non-ASCII / whitespace) we hand libkrun `/` and + // pin the agent's real cwd via the exec request below instead — the + // exec cwd travels over the byte-safe vsock channel, and the project is + // still bind-mounted (and the agent still runs) at its true path. The + // mount spec itself reaches the guest via the boot-params side channel, + // not the cmdline. (`KRUN_WORKDIR` only sets PID-1's initial chdir, + // which agentd overrides per-exec, so the placeholder is invisible.) + let krun_workdir = if guest_path_is_cmdline_safe(&project_guest_path) { + project_guest_path.clone() + } else { + "/".to_string() + }; let mut builder = Sandbox::builder(&session.sandbox_name) .image(image.as_str()) .registry(|r| if is_local_registry { r.insecure() } else { r }) .pull_policy(PullPolicy::IfMissing) .cpus(cpus) .memory(memory_mib) - .workdir(project_guest_path.clone()) + .workdir(krun_workdir) .volume(project_guest_path.clone(), |m| m.bind(&session.project_dir)) .volume("/agent-vm-state", |m| m.bind(&session.state_dir)); // Phase 7: extra `--mount HOST[:GUEST]` binds. Each gets its own @@ -822,12 +913,12 @@ pub async fn launch(agent: Agent, args: Args) -> Result { "/root/.local/bin:/root/.claude/local/bin:/root/.opencode/bin:/usr/local/bin:/usr/bin:/usr/sbin:/bin", ); - // Claude Code refuses to run as root with --dangerously-skip-permissions - // unless this env var is set. The whole point of running it in a - // microVM is that the sandbox IS our security boundary, so the - // in-guest CLI's extra guard would just block us from getting work - // done. Same env var the original Bash agent-vm used. - builder = builder.env("IS_SANDBOX", "1"); + // Environment injected into every guest regardless of agent/project. + // Kept as one list so the set is discoverable and guard-testable (see + // `guest_always_env_pins_utf8_locale_and_sandbox_flag`). + for (key, value) in GUEST_ALWAYS_ENV { + builder = builder.env(*key, *value); + } let profile = env::var("AGENT_VM_PROFILE").is_ok(); eprintln!( @@ -855,6 +946,27 @@ pub async fn launch(agent: Agent, args: Args) -> Result { if profile { eprintln!("[profile] create: {:?}", t_create.elapsed()); } + // When the project path isn't cmdline-safe we handed libkrun the `/` + // workdir placeholder, so create-time validation only confirmed that + // `/` exists — not that the real (non-ASCII) mount point materialized. + // agentd's per-exec `chdir` ignores failure (it would silently drop the + // agent into `/`), so verify the real path is present now over the + // byte-safe fs channel and fail loudly otherwise. ASCII paths were + // already validated at create time (workdir == the real path). + if !guest_path_is_cmdline_safe(&project_guest_path) + && !sandbox + .fs() + .exists(&project_guest_path) + .await + .unwrap_or(false) + { + let _ = sandbox.stop().await; + anyhow::bail!( + "project path {project_guest_path} did not appear inside the guest \ + (the bind mount failed to materialize); full logs: {}", + sandbox_log_dir(&session.sandbox_name).display() + ); + } // Confirm the image's API contract matches what this binary // expects. Out-of-range → clear actionable error instead of @@ -995,8 +1107,13 @@ pub async fn launch(agent: Agent, args: Args) -> Result { let t_run = Instant::now(); let exit = if std::io::stdin().is_terminal() { eprintln!("==> Attaching to {inner_cmd}"); + // Pin the agent's cwd to the real project path via the exec request + // (vsock, byte-safe), NOT libkrun's `KRUN_WORKDIR` — which may be the + // ASCII `/` placeholder for a non-ASCII project. `attach()` alone + // leaves cwd unset, falling back to that placeholder; `attach_with` + // lets us set it, matching the streaming path below. sandbox - .attach(cmd, agent_args) + .attach_with(cmd, |a| a.args(agent_args).cwd(project_guest_path.clone())) .await .with_context(|| { format!( @@ -1208,6 +1325,7 @@ fn pid_alive(pid: u32) -> bool { } /// One `--mount HOST[:GUEST]` argument resolved into separate paths. +#[derive(Debug)] struct ExtraMount { host: PathBuf, guest: PathBuf, @@ -1372,6 +1490,17 @@ fn parse_extra_mounts(raw: &[String]) -> Result> { if !guest.is_absolute() { anyhow::bail!("--mount guest path {guest_s:?} must be absolute"); } + // The guest mount point reaches agentd via the boot-params side + // channel (not the kernel command line), so non-ASCII and spaces + // are fine — but a control character (TAB/newline) would break the + // `KEY\tVALUE\n` framing, so reject those. See + // `guest_path_is_mountable`. + if !guest_path_is_mountable(&guest_s) { + anyhow::bail!( + "--mount guest path {guest_s:?} contains control characters that can't be \ + carried into the guest; pass a guest path without tabs/newlines" + ); + } // Canonicalize host so we follow symlinks; the bind target // needs to be a real path on the host. let host = host @@ -1753,6 +1882,109 @@ mod tests { assert!(r.is_err()); } + #[test] + fn parse_extra_mounts_allows_non_ascii_guest_rejects_control_chars() { + // A non-ASCII guest mount point now travels via the boot-params + // side channel (not the cmdline), so it is accepted and mirrored. + // Host `/` exists so canonicalize succeeds. + let parsed = parse_extra_mounts(&["/:/монтаж".into()]).expect("non-ASCII guest is ok"); + assert_eq!(parsed[0].guest, std::path::Path::new("/монтаж")); + // Plain ASCII guest path still works. + assert!(parse_extra_mounts(&["/:/mnt/ref".into()]).is_ok()); + // A control char (TAB) would break the KEY\tVALUE boot-params + // framing, so it's rejected with guidance. + let err = parse_extra_mounts(&["/:/mnt/a\tb".into()]) + .expect_err("control char in guest path must be rejected") + .to_string(); + assert!( + err.contains("control characters"), + "error should call out control characters, got: {err}" + ); + } + + // ── guest-path predicates / resolve_project_guest_path ─────── + + #[test] + fn guest_path_is_cmdline_safe_accepts_plain_ascii_only() { + // This predicate now governs only the KRUN_WORKDIR placeholder + // (the one path that still rides the cmdline). Plain ASCII passes. + assert!(guest_path_is_cmdline_safe("/home/boger/work/agent-vm")); + assert!(guest_path_is_cmdline_safe("/workspace")); + // '=' is safe in a path (the kernel splits a KEY=value token only + // on its first '='); only whitespace and non-ASCII are unsafe. + assert!(guest_path_is_cmdline_safe("/home/a=b/c-d.e_f+g")); + // Cyrillic/emoji (non-ASCII), space, tab, DEL are all NOT + // cmdline-safe → the workdir falls back to the "/" placeholder. + assert!(!guest_path_is_cmdline_safe("/home/boger/проект-тест")); + assert!(!guest_path_is_cmdline_safe("/home/boger/😀proj")); + assert!(!guest_path_is_cmdline_safe("/home/My Project")); + assert!(!guest_path_is_cmdline_safe("/home/x\ty")); + assert!(!guest_path_is_cmdline_safe("/home/x\u{7f}y")); + } + + #[test] + fn guest_path_is_mountable_allows_non_ascii_and_space_not_control() { + // Mount points travel via the byte-transparent boot-params side + // channel, so non-ASCII and spaces are mountable. + assert!(guest_path_is_mountable("/home/boger/проект-тест")); + assert!(guest_path_is_mountable("/home/boger/😀proj")); + assert!(guest_path_is_mountable("/home/My Project")); + assert!(guest_path_is_mountable("/home/boger/work")); + // Control characters (TAB/newline/DEL) break the KEY\tVALUE\n + // framing and are rejected. + assert!(!guest_path_is_mountable("/home/x\ty")); + assert!(!guest_path_is_mountable("/home/x\ny")); + assert!(!guest_path_is_mountable("/home/x\u{7f}y")); + } + + #[test] + fn guest_always_env_pins_utf8_locale_and_sandbox_flag() { + let map: std::collections::HashMap<&str, &str> = + GUEST_ALWAYS_ENV.iter().copied().collect(); + // Claude Code's root-guard bypass must stay set. + assert_eq!(map.get("IS_SANDBOX"), Some(&"1")); + // A UTF-8 locale must be pinned: without it the guest is C/POSIX, + // which renders a Cyrillic cwd as `M-P…` escapes and makes the + // agents' filesystem encoding ASCII (mishandling non-ASCII paths). + // Removing or non-UTF-8-ing this regresses Cyrillic-path support. + let lang = map.get("LANG").expect("guest LANG must be pinned to a UTF-8 locale"); + assert!( + lang.to_ascii_lowercase().replace('-', "").contains("utf8"), + "guest LANG must be a UTF-8 locale, got {lang:?}" + ); + } + + #[test] + fn resolve_project_guest_path_mirrors_real_path_and_only_remaps_unmountable() { + // Plain ASCII path is mirrored 1:1 with no remap notice. + let (guest, reason) = + resolve_project_guest_path(Path::new("/home/boger/proj"), "/home/boger/proj"); + assert_eq!(guest, "/home/boger/proj"); + assert!(reason.is_none()); + + // Cyrillic, emoji, and space are now mirrored at their REAL path — + // the mount spec rides the side channel and the cwd the exec + // channel, so there's no /workspace fallback and no remap notice. + for p in ["/home/boger/проект", "/home/boger/😀p", "/home/My Project"] { + let (guest, reason) = resolve_project_guest_path(Path::new(p), p); + assert_eq!(guest, p, "{p:?} should be mirrored at its real path"); + assert!(reason.is_none(), "{p:?} should not report a remap reason"); + } + + // A path under a guest tmpfs mount still remaps (would be wiped at + // boot), regardless of being otherwise ASCII-clean. + let (guest, reason) = resolve_project_guest_path(Path::new("/tmp/proj"), "/tmp/proj"); + assert_eq!(guest, "/workspace"); + assert!(reason.is_some_and(|r| r.contains("tmpfs"))); + + // A control character in the path can't be framed for the side + // channel → defensive /workspace fallback. + let (guest, reason) = + resolve_project_guest_path(Path::new("/home/a\tb"), "/home/a\tb"); + assert_eq!(guest, "/workspace"); + assert!(reason.is_some_and(|r| r.contains("control characters"))); + } + // ── parse_publish_args ─────────────────────────────────────── #[test] diff --git a/images/Dockerfile b/images/Dockerfile index 2f664e8..b27aeeb 100644 --- a/images/Dockerfile +++ b/images/Dockerfile @@ -19,9 +19,18 @@ FROM debian:13-slim LABEL org.opencontainers.image.title="agent-vm guest template" LABEL org.opencontainers.image.description="Guest OCI image that the agent-vm tool (npm: @wirenboard/agent-vm) boots inside each per-project microVM. Contains Debian 13 + Claude Code + OpenCode + Codex + minimal dev tooling. Rebuilt hourly to pick up new agent releases. NOT the agent-vm runtime itself — install that via `npm install -g @wirenboard/agent-vm`." +# LANG=C.UTF-8: debian:slim ships with NO locale set, leaving the guest in +# C/POSIX — which renders a non-ASCII (e.g. Cyrillic) cwd as `M-P…` meta- +# escapes in bash/readline AND makes locale-driven filesystem encodings +# default to ASCII, so Python/Node/ripgrep mis-handle or error on a non-ASCII +# path even when it mounted fine. C.UTF-8 is always present in glibc (no +# locale-gen needed), ASCII-sorting + English-messages but UTF-8-aware. +# agent-vm's launcher also sets this for every exec; having it in the image +# makes any other use of the image correct too. ENV DEBIAN_FRONTEND=noninteractive \ HOME=/root \ - PATH=/root/.local/bin:/root/.claude/local/bin:/root/.opencode/bin:/usr/local/bin:/usr/bin:/usr/sbin:/bin + PATH=/root/.local/bin:/root/.claude/local/bin:/root/.opencode/bin:/usr/local/bin:/usr/bin:/usr/sbin:/bin \ + LANG=C.UTF-8 # Image-API contract version. Bump on BREAKING image changes only # (new required mount points, changed env-var contracts, removed diff --git a/vendor/microsandbox b/vendor/microsandbox index 2dbfa38..62afcdf 160000 --- a/vendor/microsandbox +++ b/vendor/microsandbox @@ -1 +1 @@ -Subproject commit 2dbfa3895ad4cdaa1604e002bb650ab10b834daf +Subproject commit 62afcdfef9e44e4f9008b7c69d733151b930b53e