Merge fix-cyrillic-cwd: non-ASCII (Cyrillic) project path support

Mirrors a Cyrillic/whitespace project path at its real location instead
of crashing the VMM (cmdline InvalidAscii) or falling back to /workspace:
mount specs ride a boot-params side channel, LANG=C.UTF-8 is pinned, and
the release builds agentd from source so producer+consumer ship together.
Bumps vendor/microsandbox to 62afcdf.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Evgeny Boger 2026-06-01 11:28:24 +00:00
commit 3d512fa545
4 changed files with 284 additions and 18 deletions

View file

@ -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

View file

@ -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=<path>`) 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<i32> {
.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<i32> {
}
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<i32> {
"/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<i32> {
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<i32> {
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<Vec<ExtraMount>> {
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]

View file

@ -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

2
vendor/microsandbox vendored

@ -1 +1 @@
Subproject commit 2dbfa3895ad4cdaa1604e002bb650ab10b834daf
Subproject commit 62afcdfef9e44e4f9008b7c69d733151b930b53e