The image installs the LSP plugins under /root/.claude, but the launcher
symlinks /root/.claude -> /agent-vm-state/claude for persistence, which
shadows the baked tree — the booted guest's `claude plugin list` was empty.
Stash the plugins + settings to /opt/agent-vm/claude-seed (not shadowed) and
ship seed-claude-plugins.sh; the launch prelude runs it before exec'ing the
agent, copying the stash into the state dir once and merging enabledPlugins.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The old AGENT_INSTALL_CACHEBUST=<hourly-timestamp> build-arg re-ran
all three agent-installer layers (+ the sanity check) on every hourly
cron build. Because `curl install.sh | bash` isn't reproducible, each
rebuild emitted fresh layer blobs -> a new ~200 MiB-of-agent-layers
image every hour even when no agent version changed, so :latest
consumers re-downloaded those layers hourly for nothing. (Confirmed
on published images: consecutive hourly builds share layers 1-9 but
get fresh digests for every agent layer, incl. the no-fs-change
sanity layer.)
Replace it with a per-agent version cache key: the workflow resolves
each agent's current upstream version (codex -> openai/codex
releases/latest, opencode -> sst/opencode releases/latest, claude ->
npm @anthropic-ai/claude-code latest) and passes AGENT_VERSION_{CODEX,
OPENCODE,CLAUDE}. Each installer RUN references its arg so BuildKit
rebuilds that layer ONLY when the version string changes; an unchanged
hourly build is now a pure cache hit (identical layer digests ->
nothing to re-pull). Resolution failure fails the build rather than
shipping a stale key and skipping a real update.
Also correct the layer-order rationale. #12 ordered the installers
"smallest first"; the real published sizes are codex 95 / opencode 50
/ claude 68 MiB compressed, and codex is also the most frequently
released (multiple stable cuts/day). Since a lower-layer change
cascades up through every layer above it, the topmost agent layer
re-emits on essentially every change -> the biggest/most-frequent
agent (codex) belongs at the BOTTOM. The existing order
(codex -> opencode -> claude) is already optimal across the plausible
frequency range; only the (wrong) comment is fixed, no reorder.
Follow-up to #12.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016gtxsKVWPrR8K1M1Te57MG
- network/diag tools (ssh, ping, ifconfig, sshpass, mtr, dig, nc, tcpdump,
net-tools, dnsutils, …) merged into the base apt layer (~+38 MiB)
- Python lint pinned to codestyle/python/config/requirements.txt:
black 24.2.0, isort 5.13.2, pylint 3.3.9, pytest-cov 2.10.1, attrs
23.1.0, j2cli (~5 MiB)
- wirenboard/codestyle cloned at build to /opt/wirenboard/codestyle
(--depth=1, ref pinnable via `--build-arg CODESTYLE_REF=…` for
reproducible builds; default `master`) so the per-project
`bash ../codestyle/.../linux-devenv.sh` idiom works in-VM without
re-cloning (<1 MiB)
- agent installers split into three separate RUNs (codex → opencode →
claude, smallest to largest) so a single release only invalidates one
layer (~10–70 MiB) instead of three
- agent installers HARD-FAIL by default (production CI must not silently
ship an image missing an agent). `AGENT_INSTALL_SOFT_FAIL=1` build-arg
+ matching `AGENT_VM_BUILD_SOFT_FAIL_AGENTS` env-var on build.sh
decouple soft-fail from the CA-detection path — auto-enabled on
TLS-intercept dev hosts, overridable in either direction. The
codestyle clone follows the same policy
- shared agent-vm-install helper (curl + run + soft-fail policy in one
place) replaces three near-identical RUN blocks; the per-installer
RUNs become one-line invocations
- sanity check exercises every binary directly (`tool --version
>/dev/null`) instead of `command -v` + echo-substitution which masked
broken-but-on-PATH binaries; pipe-into-head removed so failures
propagate
- build-time host CA shim (Dockerfile + images/build.sh): when
/usr/local/share/ca-certificates/microsandbox-ca.crt exists on the
host, build.sh passes it as a buildx --secret (content NOT baked into
the image) plus a CA_SHIM_CACHEBUST=mtime build-arg so the
conditional Dockerfile shim invalidates correctly; CI builds without
the host CA file are unchanged (the shim no-ops)
DEFERRED (commented out in Dockerfile for the initial roll-out;
uncomment + restore the corresponding sanity-check probes when WB
engineers actually need the in-VM cross-compile path):
- WB build essentials: build-essential, debhelper/devscripts/equivs/
dpkg-cross, pkg-config, clang-format/tidy, libgtest/gmock/curl/
modbus/systemd-dev, gcovr, gdb-multiarch, cmake/ninja-build,
python3-{cups,libgpiod,pycurl}. ~208 MiB compressed.
- WB cross toolchains: crossbuild-essential-{armhf,arm64},
qemu-user-static, debootstrap/schroot/sbuild. ~258 MiB compressed.
Smoke-tested in a real microVM (locally-built image, soft-fail path,
image-API contract v1 intact): in-build sanity check passes (`==
sanity OK ==`), all enabled tools present and functional. Final image:
593 MiB compressed, +~38 MiB vs baseline (the diag CLIs added to the
base apt layer; codestyle clone <1 MiB; python pins ~5 MiB; everything
else byte-identical to original).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
agent-vm mirrors the host cwd into the guest at the same absolute path,
but a Cyrillic-named directory crashed the VMM at boot: libkrun packs the
guest workdir + env into a printable-ASCII-only kernel command line and
.unwrap()s a non-ASCII byte into a SIGABRT. Three changes make the real
path work instead of falling back to /workspace:
1. Mount specs travel off the cmdline. The bumped submodule moves
MSB_DIR_MOUNTS/FILE/DISK to a boot-params file on the runtime virtiofs
share (agentd reads it at init). run.rs hands libkrun a '/' workdir
placeholder when the real path isn't cmdline-safe and pins the agent's
real cwd via the byte-safe vsock exec request (attach_with/.cwd on both
the TTY and streaming paths). A mandatory post-boot fs().exists(real)
probe fails loud if the bind didn't materialize (chdir failure is
otherwise silent). resolve_project_guest_path now mirrors the real path
for non-ASCII/whitespace; only a tmpfs-overlap or a control-char path
(unframable for boot-params) still falls back to /workspace.
2. UTF-8 locale. The base image ships with no locale (C/POSIX), which
renders a Cyrillic cwd as `M-P…` meta-escapes in bash/readline AND
makes filesystem encodings default to ASCII so Python/Node/ripgrep
mis-handle the path even when it mounted. Pin LANG=C.UTF-8 for every
guest (GUEST_ALWAYS_ENV in run.rs) and in images/Dockerfile.
3. Ship producer and consumer in lockstep. agentd is embedded in msb and
was downloaded as a release prebuilt; the new producer needs the new
consumer, so release-npm.yml's build-msb now builds agentd from the
pinned source (musl) and links msb with --no-default-features (drops
the `prebuilt` download). A stale prebuilt would have dropped every
mount for every user.
Verified: bash/readline renders the real Cyrillic path as glyphs under
C.UTF-8 (pty capture) and as the reported M-P escapes without it; the
boot-params channel carries the path byte-intact and a normal launch
mounts correctly. Full nested-guest agent runs hit this dev box's
overlay-on-virtiofs limit (the probe reports it); validated on a real
host via the path mirror.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Why
---
In-VM Claude has no code intelligence for the languages it edits most:
the guest image ships toolchains but no Language Server Protocol servers,
so completion, diagnostics, hover, and go-to-definition are unavailable
for C/C++, Python, TypeScript, and Go work done inside the sandbox. The
original Bash agent-vm closed this gap in its `setup` step
(claude-vm.sh:353-361), but the microsandbox rewrite — whose image is
Docker-built once rather than provisioned per-setup — dropped it.
This is the D2 item from PLAN.md ("LSP plugins in the image"): port the
four language servers the original's `setup` installed so that an agent
running in the guest gets the same editor-grade intelligence the original
provided, without any host round-trip.
How
---
images/Dockerfile gains an image-build step that installs the four
language servers the original declared — clangd-lsp (C/C++),
pyright-lsp (Python), typescript-lsp (TypeScript), and
gopls-lsp@claude-plugins-official (Go) — and pre-warms them so the first
in-VM invocation is not paying first-run download/extraction cost. Doing
this at build time (rather than per-setup or on first launch) keeps the
fresh-VM-per-launch model fast: the servers are baked into the image
layer and ready the moment the microVM boots, matching PLAN.md's
"Just Dockerfile + pre-warm" scope for this item.
No Rust, run-path, or version changes are involved; the feature is
entirely contained in the image definition, so existing launch behavior
is unchanged except that the language servers are now present.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Why
---
Until now the sandbox only knew how to launch Claude inside the microVM.
PLAN.md item D1 ("Copilot agent") calls for first-class support for GitHub
Copilot so that users who work with Copilot get the same isolated,
credential-scoped VM treatment Claude already enjoys, rather than running
Copilot on the host with broad ambient access. Running Copilot inside the VM
puts its network egress, filesystem view, and credential exposure under the
existing sandbox policy instead of the host environment.
How
---
The change spans the image, the credential layer, and the launch path:
- images/Dockerfile: provision the GitHub Copilot CLI in the guest image
next to the existing agent tooling, so a pulled image can run Copilot with
no extra in-VM setup.
- crates/agent-vm/src/host_paths.rs: resolve the host-side location of
Copilot's credential/config files so secrets discovery knows where to read
them from.
- crates/agent-vm/src/secrets.rs: teach the secrets layer to discover
Copilot's credentials on the host and forward them into the guest the same
way the other agents' tokens are handled, keeping the credential surface
scoped to the VM. This is the largest part of the change.
- crates/agent-vm/src/run.rs: wire Copilot into the launch path so it can be
chosen as the agent to run, threading the new credential material through to
the guest invocation.
- crates/agent-vm/src/main.rs: surface the new agent choice at the CLI layer.
Together these complete PLAN.md item D1.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Profiling (LAUNCH-PROFILE.md) found the launch was dominated by PRE-BOOT host
work, not the VM boot. Three fixes, ~2.5s -> ~1.8s on a warm launch:
- secrets.rs: cache the host git identity (24h TTL, validated strings only).
`gh api user` was an uncached ~1.29s HTTPS round-trip on every launch's
critical path (added in v0.1.14) — the main 1.3s->2.5s regression.
- run.rs: spawn the ghcr.io update-banner check instead of awaiting it
(~0.9s off the hot path; banner still fires, just doesn't block boot).
- run.rs + images/Dockerfile: move the chrome-MCP MITM-CA `certutil` import
out of the per-launch in-guest prelude into the agent-vm-chrome-mcp wrapper.
It now runs once at MCP startup (as the chrome user that owns the NSS DB),
off the launch path and skipped when chrome is unused (~270ms). Verified
end-to-end: the proxy serves a `microsandbox CA`-signed cert that validates
against the CA the wrapper imports.
Also: kernel A/B showed the nested-virt libkrunfw rebuild adds only ~190ms
(KVM ~100, conntrack ~62); DEFERRED_STRUCT_PAGE_INIT and split_irqchip have no
wall-clock effect. Build/measure scripts under profiling/.
NOTE: the Dockerfile (wrapper) and binary (prelude removal) must ship together
— the image must be rebuilt to :latest with the new wrapper before this binary
is published, or chrome MCP loses its CA.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Companion to #8. The hourly cron checks out rewrite-microsandbox's
Dockerfile (see the `ref:` override that lives on main's workflow
file), so the `ARG AGENT_INSTALL_CACHEBUST` placeholder right before
the three installer RUNs has to land here for the bust to bite. The
workflow-file edit is also bundled so workflow_dispatch from this
branch and any future merge-back to main behave consistently.
See PR #8 on main for the longer rationale.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The hourly cron's whole point per its header comment is to "pick up
new claude-code/codex/opencode releases", but `cache-from: type=gha`
matches each `RUN curl … install.sh | bash` by its (invariant)
instruction string, so the GHA layer cache served the same baked-in
binaries indefinitely — scheduled runs were ~30-60s of cache replay
that never refreshed the agents. claude-code 2.1.156 had been out
for a while and `:latest` was still on an older release.
Pass the per-build timestamp (`steps.ts.outputs.tag`, already
computed for the date-tagged image) as `AGENT_INSTALL_CACHEBUST`,
and reference it via an `ARG` placed right before the three
installer RUNs. That invalidates exactly those layers (and the
sanity-check RUN that follows) while keeping the heavy apt /
chromium / docker / nodejs layers above the ARG cached. Local
`images/build.sh` builds leave the empty default and behave as
before.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous fix symlinked /usr/sbin/{dockerd,runc} into
/usr/local/bin so users could invoke `dockerd` directly, but that
was whack-a-mole — dockerd itself does PATH lookup at runtime for
its helper binaries:
- `iptables` (from `iptables` pkg, /usr/sbin/iptables): dockerd
aborts with "failed to register \"bridge\" driver: failed to
create NAT chain DOCKER: iptables not found".
- `docker-proxy` (from docker.io pkg, /usr/sbin/docker-proxy):
daemon.json validation fails with "userland-proxy is enabled,
but userland-proxy-path is not set" (the daemon resolves a
default path via PATH lookup before parsing the config).
- `runc` (also /usr/sbin/runc): containerd-shim PATH-looks it up
when starting containers.
Symlinking each one would be unending whack-a-mole as new helpers
get added in future docker.io revs. Adding /usr/sbin to PATH covers
all of them in one line; drop the redundant dockerd/runc symlinks
from the previous fix.
Verified end-to-end inside an agent-vm session built from the
broken (v0.1.11) image: with `PATH=$PATH:/usr/sbin dockerd &` the
daemon starts cleanly, `docker run hello-world` prints the welcome
message, `docker run alpine sh -c 'echo running uid=$(id -u)'`
runs a real container (uid=0, kernel 6.12.68), iptables NAT chain
DOCKER is populated, docker0 bridge comes up.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
debian ships dockerd at /usr/sbin/dockerd and runc at /usr/sbin/runc,
neither of which are on the image's minimal PATH
(/root/.local/bin:/root/.claude/local/bin:/root/.opencode/bin:/usr/local/bin:/usr/bin:/bin).
Without the symlinks:
- the build-time sanity check `dockerd --version` exits 127 (broke
CI on the v0.1.11 image build);
- users invoking `dockerd &` from the in-VM shell would get
"dockerd: command not found";
- containerd's PATH-lookup of runc when spawning containers would
also miss (containerd binary is at /usr/bin/containerd which IS on
PATH, but it inherits PATH from its parent).
Cheapest fix: two symlinks into /usr/local/bin, leaving /usr/sbin off
PATH to keep sysadmin binaries from polluting the agent's namespace.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Pairs with the libkrunfw-overrides commits (POSIX_MQUEUE +
netfilter + nf_tables) — those make the kernel container-capable;
this gives the guest image the userspace to drive it.
What's installed: docker.io, docker-cli, containerd, runc,
fuse-overlayfs, iptables. No docker-buildx — the +20 MB compressed
isn't worth it for the small in-VM builds an agent would do, and
the legacy builder is a fine fallback. Storage driver pinned to
fuse-overlayfs in /etc/docker/daemon.json because overlay2 fails
on overlay-on-overlay (libkrun rootfs is already overlay).
Cost on top of the current shipped template (568 MB compressed,
1.81 GB on-disk per `docker manifest inspect`):
+87 MB compressed pull (+15.4%)
+281 MB on-disk (+15.5%)
Daemon is NOT auto-started; users invoke `dockerd &` themselves
(or wire it into `.agent-vm.runtime.sh`). Most sessions don't
touch docker, so paying ~50 MB RSS at every boot would be wasteful.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
`agent-vm setup` calls `images/build.sh`, which built and pushed via
the legacy `docker build` + `docker push` path — leaving every layer
gzip-compressed. The OCI→EROFS conversion on the user's first launch
then spends ~95% of its CPU time in single-threaded gzip decode.
Bench against /usr/lib (856 MB) — see crates/agent-vm/examples/
erofs_bench.rs:
gzip layers: ingest = 143.08s (6.0 MB/s effective uncompressed)
zstd layers: ingest = 5.01s (171.0 MB/s effective uncompressed)
≈24× faster end-to-end pull pipeline with no microsandbox changes —
`tar_ingest.rs:427` already accepts `application/vnd.oci.image.layer
.v1.tar+zstd` automatically.
Switch the script to `docker buildx build --output type=registry,
compression=zstd,compression-level=3,force-compression=true,push=
true`:
- `--output type=registry,push=true` replaces the build + separate
push, because buildx with `--push` cannot also carry `--output`
attributes, but `type=registry,push=true` is equivalent and lets
us attach the compression options to the same step.
- `force-compression=true` re-emits base-image layers as zstd too;
without it, only the layers we add get re-compressed and the rest
stay gzip, halving the win.
- `compression-level=3` is zstd's default — bench shows diminishing
returns past that for binary-heavy layers.
- `registry.insecure=true` is needed because we push to a loopback
HTTP registry; `--push`-via-buildx doesn't inherit the daemon's
`insecure-registries` config.
Adds preflight checks: bail with a clear message if `docker` or
`docker buildx` aren't present (modern Docker bundles buildx; older
installs need an upgrade).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The name `agent-vm` was overloaded: it's both the runtime tool
(Rust binary on npm) AND the OCI image the tool boots inside each
microVM. Users seeing `ghcr.io/wirenboard/agent-vm:latest` would
reasonably guess that's where the *runtime* lives, but it's
actually the guest rootfs.
Rename the ghcr package to `agent-vm-template` and add OCI labels
(`org.opencontainers.image.title` / `description`) that explicitly
say "guest template image — install the runtime via npm". The
description shows on the ghcr.io package page.
Also rename the local-registry tag (`localhost:5000/agent-vm-template:latest`)
for consistency with the dev workflow.
Touches:
- DEFAULT_IMAGE_REF and every help/doctring referencing the ghcr
image (setup.rs, run.rs, pull.rs, image_api_version.rs,
image_check.rs).
- images/Dockerfile — new header + OCI labels.
- images/build.sh — local registry tag.
- .github/workflows/build-image.yml — IMAGE env.
- README.md + npm-dist/README.md.
- image_check tests updated to assert the new name.
Bumps to v0.1.4 so npm + ghcr converge on the new name.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Folds in 13 findings from the second-pass max-effort review of
ce745ec. (The 14th — "var_os().is_some() opt-out semantics flipped" —
is actually a false positive: both old and new code treat
AGENT_VM_NO_CHROME_MCP=anything as opt-out. The unconventional
"empty value also opts out" behavior is consistent with
AGENT_VM_PROFILE / AGENT_VM_DEBUG_CONFIG elsewhere; documented
inline so the next reviewer doesn't re-flag it.)
**run.rs**
- `sandbox_log_dir()` replaces `runtime_log_path()`. Returns the log
*directory* (containing `runtime.log` + `kernel.log` +
`boot-error.json` — verified all three are written by vendor's
`vm.rs::setup_kernel_log` and `boot_error.rs`). The previous helper
pointed at `runtime.log` only, which is zero bytes for the most
common boot-stage failures where the cause sits in kernel.log or
boot-error.json instead. The doc comment now lists all three files
and acknowledges that the helper deliberately diverges from
upstream's `microsandbox_utils::resolve_home()` in one place: when
`MSB_HOME` and `HOME` are both unset, we canonicalize the relative
`./.microsandbox` fallback against the current working directory
so the hint string is absolute (upstream's fallback ships into a
subprocess where CWD is more controlled; embedding `.`-prefixed
paths into launcher-side error messages is confusing).
- Error-context wording changed from `(see <path>/runtime.log for the
in-VM tail)` to `(full logs: <dir>)`. Vendor's `Sandbox::create`
already inlines the runtime.log tail into BootError messages via
`tail_runtime_log`; the prior wording duplicated the suggestion
("look at the file you just saw the tail of"). The new wording
positions the directory pointer as the *full* log location, useful
in both the boot-failure case (vendor already showed you a tail)
and the post-boot attach failure case (vendor didn't).
- Certutil prelude no longer silently swallows failures. Sudo /
certutil stderr is captured to a per-invocation tempfile; on
non-zero exit the launcher prints a 4-line warning that names the
user-visible symptom (`HTTPS in chrome-devtools MCP will fail with
ERR_CERT_AUTHORITY_INVALID`) and tail-prefixes the stderr so the
user can see the actual sudoers / NSS error. Without this, every
failure mode (dropped sudoers, world-writable sudoers, locked NSS
DB, missing chrome user, unreadable CA file) was invisible from
the launcher — chrome MCP boots normally, then every navigate
silently fails with an opaque CDP error.
- AGENT_VM_NO_CHROME_MCP opt-out semantics commented inline at the
point where the env-var is consulted, so the "set with any value
including empty = opt out" rule (consistent with the rest of the
agent-vm env-var family) doesn't re-trip future reviewers.
**secrets.rs**
- `OPENCODE_OPENAI_ACCESS_PLACEHOLDER` JWT signature length fixed:
was `msb-opencode-placeholder-a-v2` (29 chars, `len % 4 == 1`
which is structurally impossible for unpadded base64url and would
trip any strict JWT parser); now `msb-opencode-placeholder-av2`
(28 chars, `% 4 == 0`, valid). Sibling `OPENAI_ID_PLACEHOLDER` sig
is already 28 chars — the rename in the previous commit produced a
29-char OPENCODE sig by accident. `placeholders_are_pairwise_distinct`
test continues to pass.
- Always-own-the-mcpServers-key block now tolerates a pre-existing
`mcpServers: null` (resets to `{}` rather than bailing) and cleans
up an empty `mcpServers: {}` left over after opt-out. With this,
`AGENT_VM_NO_CHROME_MCP=1` from a fresh state dir produces a
`claude.json` with the `mcpServers` key absent (the same shape it
had pre-Phase-7), rather than the empty placeholder map that
surprised users opting out from day one.
- Pinned `chrome-devtools-mcp@1.0.1` in the MCP config (was
`@latest`). The image's pre-warm step now also pins the same
version; without coupled pins, `npx -y @latest` re-resolves
against the registry on every launch and invalidates the pre-warm
cache as soon as upstream cuts a release. Bump both together when
you want to track a newer release.
**Dockerfile**
- Wrapper script: replace `cd /home/chrome` with `cd /home/chrome
2>/dev/null || { echo "...image misconfigured?" >&2; exit 1; }`.
Without this, a missing /home/chrome (derivative image strips it,
runtime mount fails) silently exits the wrapper from claude's MCP
transport perspective with no actionable breadcrumb.
- Wrapper script doc: drop the false claim that agentd boot-sets
`HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` (it doesn't — vendor
agentd/lib/tls.rs sets only the CA env vars). The proxy env vars
remain in the preserve list because user MCP `env:` blocks or
`.agent-vm.runtime.sh` may set them, but the *reason* is
documented honestly.
- Wrapper script doc: explicitly note that USER and LOGNAME are NOT
in the preserve list, because sudo's default `env_reset` policy
re-derives them from the target passwd entry; adding them would
forward root's USER into the chrome session.
- Pre-warm RUN now `chrome-devtools-mcp@1.0.1 ... 2>&1 || echo
"==> pre-warm skipped"`. Was an unguarded `npx -y @latest --help
>/dev/null` that hard-failed the entire `agent-vm setup` on any
npm registry blip — the opposite of its stated intent of
insulating launches from registry availability.
Verified e2e in a fresh sandbox:
- pre-warm cache at /home/chrome/.npm/_npx/... is hit by the runtime
npx invocation (same hash for matching @1.0.1 pin); reports
app-version=1.0.1
- chromium runs as chrome (UID 9999) with its sandbox active; NSS
DB trusts only the microsandbox CA with attributes `C,,`
- navigate_page https://example.com + take_snapshot return real
"Example Domain" content
- AGENT_VM_NO_CHROME_MCP=1: jq 'has("mcpServers")' returns false
(key entirely absent, not empty {})
- 73 cargo unit tests pass (placeholders_are_pairwise_distinct
green with the new 28-char OPENCODE sig)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Folds in fourteen findings from the code review of the previous two
commits. Most are 'the commit advertised something it didn't actually
deliver' — straightforward to make honest.
**Observability commit was a no-op — revert it.**
- `main.rs` filter went back to the simple `warn` default. The previous
`microsandbox_network::proxy=info,microsandbox_network::tls::proxy=info`
targeted modules that (a) only emit `tracing::debug!` (so info
filtered them out) and (b) run in the `msb sandbox` subprocess whose
own tracing init reads no env at all. Net behaviour: zero proxy
logs surfaced; the doc-comment claim was wishful thinking.
- `run.rs` post-mortem hint trimmed to one accurate line. Dropped the
pointers to `msb.stderr.log` and `msb-exit.log` (vendor `spawn.rs`
inherits stderr instead of teeing; `handle.rs::wait` writes no exit
log). What remains: a single `(see <runtime.log> for the in-VM tail)`
appended via `with_context`. Avoids the duplicated error output the
old code produced — anyhow's main termination prints the chain once
rather than the previous "eprintln before return then re-print on
exit" combo.
- `runtime_log_path()` replaces `msb_sandbox_log_dir`; comment no
longer claims to "mirror" upstream, and the final fallback matches
`microsandbox_utils::resolve_home()` (`./.microsandbox`) rather than
the previous incorrect `/var/lib/microsandbox`. Hint now also fires
in the non-TTY branch's `with_context` and `anyhow::bail!` paths,
not just TTY — CI users are the ones most likely to need the
pointer.
**Chrome MCP cleanups.**
- `certutil` trust string `-t TC` → `-t C,,`. The previous form set
the SSL column to `TC` (T = trusted issuer of *client* certs, C =
trusted issuer of server certs). We only want server-cert trust;
the T silently broadened scope. `C,,` is the textbook idiom.
- Wrapper's `--preserve-env` allow-list expanded to forward the env
vars chrome-devtools-mcp itself documents
(`CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS`, `CI`, `DEBUG`), agentd-
set proxy config (`HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` plus
lowercase variants), and basic locale (`TZ`, `LANG`, `LC_ALL`,
`TMPDIR`). Without these, user MCP-config `env:` blocks and
documented opt-outs were silently stripped by sudo's `env_reset`.
- Wrapper now `cd /home/chrome` before exec'ing. Inherited cwd was
`/workspace` (the project bind-mount), which the chrome user (UID
9999) cannot write — any MCP tool emitting a relative path
(`./trace.json`, `./screenshot.png`, `--logFile=./debug.log`)
EACCES'd silently. `/home/chrome` is chrome-owned and writable.
- Pre-warm `chrome-devtools-mcp@latest` into chrome's npm cache at
image build time (`/home/chrome/.npm/_npx/...`). Previously every
first MCP call paid a multi-second `npx -y` fetch and broke
entirely when registry.npmjs.org was unreachable. The image gains
~21 MiB; cold launches drop the fetch.
- Bash prelude's certutil block is now gated on `AGENT_VM_NO_CHROME_MCP`
not being set. Previously the env-var opt-out only suppressed the
MCP entry in `claude.json`; the sudo→chrome→certutil round-trip
ran on every launch regardless, exposing the opt-out user to the
exact sudoers/NSS failure modes they were trying to avoid.
**Sticky opt-out fixed.** `write_default_claude_root_state` now
always owns the `chrome-devtools` key: `AGENT_VM_NO_CHROME_MCP=1`
explicitly `mcp.remove("chrome-devtools")`s a previously-written
entry instead of leaving it sticky in the on-disk merged JSON.
**OPENCODE placeholder rename completed.** `OPENCODE_OPENAI_ACCESS_PLACEHOLDER`
JWT signature is now `msb-opencode-placeholder-a-v2` (was
`MSB_OPENCODE_v1`), matching the rename rationale spelled out on
`OPENAI_ID_PLACEHOLDER`: non-token-shaped sentinels so Anthropic
doesn't flag transcripts that contain them.
**Stale literals swept.** `ARCHITECTURE.md` reads the new names at
all five reference sites. Six tests in `intercept_hook.rs` that
hardcoded `MSB_PLACEHOLDER_GH_TOKEN_v1[_xxxxxxxx]` as a standalone
fixture now use `secrets::GH_TOKEN_PLACEHOLDER` directly — so a
future placeholder shape change is exercised by the existing
regression assertions instead of slipping past them.
End-to-end verified inside a fresh sandbox:
- `claude mcp list` → ✓ Connected
- `navigate_page` https://example.com + `take_snapshot` returns the
real "Example Domain" content
- NSS DB lists only the microsandbox CA with trust `C,,` (not `CT,,`)
- `chrome-devtools-mcp` package present in /home/chrome/.npm/_npx/
out of the box (no per-launch fetch)
- `AGENT_VM_NO_CHROME_MCP=1`: claude.json mcpServers is `{}` (entry
removed, not just absent on new files); NSS DB is empty (certutil
prelude skipped)
- Wrapper preserves CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS, CI,
DEBUG, HTTP_PROXY, TZ, NODE_EXTRA_CA_CERTS across the sudo hop
- 73 cargo unit tests pass (incl. the intercept_hook tests now using
the constant)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the build-from-source quickstart (Rust toolchain + docker +
local registry container + manual `images/build.sh`) with a single
`npm install -g @wirenboard/agent-vm` and a `ghcr.io/wirenboard/
agent-vm:latest` image pulled from CI.
## What ships via npm
`npm-dist/` scaffolds the established per-platform-subpackage
pattern (esbuild / ruff / biome):
- `@wirenboard/agent-vm` — JS launcher that detects
`${platform}-${arch}` at runtime and execs the right native binary
from the matching subpackage. `optionalDependencies` lists each
platform.
- `@wirenboard/agent-vm-linux-x64` — bundles a prebuilt `agent-vm`,
the patched `msb`, and `lib/libkrunfw.so.5.2.1`.
Smoke-tested locally via `npm link`: launcher resolves the subpackage
correctly and execs the bundled binary; `MSB_PATH=/usr/bin/cat
agent-vm pull` correctly rejects an unpatched msb with the
actionable "reinstall agent-vm" hint.
## msb discovery (msb_install.rs rewrite)
`point_at_workspace_msb` → `point_at_msb` with a fallback chain:
1. `MSB_PATH` env (explicit override).
2. `<exe-dir>/msb` (npm bundle layout — sibling of agent-vm).
3. `<workspace>/vendor/microsandbox/target/release/msb` (dev mode).
`msb --version` is verified to contain `+agent-vm` — refuses to
launch if a user's separate microsandbox install has somehow ended
up on our discovered path. Catches the bug where shadow installs
would silently lose SecretValue::File and the request-interceptor
hook, producing inscrutable in-VM failures instead of one clear
"reinstall" message.
## Image flow (separate cadence)
- Default image ref: `ghcr.io/wirenboard/agent-vm:latest`.
- `images/build.sh` and local-registry flow stay as the
source-checkout dev path (pass `--image localhost:5000/...`).
- `agent-vm setup` becomes "pull + verify"; no more docker/registry
dependency for end users.
- New `image_api_version` module reads `/etc/agent-vm-image-version`
inside the booted sandbox and requires it within
`MIN_SUPPORTED_IMAGE_API..=MAX_SUPPORTED_IMAGE_API` (both 1
initially). Out-of-range → actionable error pointing at the right
side (binary vs image) to update.
- Image registry hostname heuristic (`is_plain_http_registry`)
keeps `.insecure()` for localhost-style refs but drops it for
ghcr.io / docker.io / public registries that require HTTPS.
## CI workflows
- `.github/workflows/release-npm.yml` — on `v*.*.*` tag, cross-
compile binaries per platform (linux-x64 only initially; arm64 /
darwin commented out, ready to enable when microsandbox grows
those backends), bundle into subpackages, publish all packages
to npm with provenance. Subpackages publish before the main
package so optionalDependencies resolve.
- `.github/workflows/build-image.yml` — hourly cron + push-to-
images/ trigger. Builds Dockerfile, tags `latest` + timestamped
`YYYY-MM-DDTHH` + `sha-<sha>`, pushes to ghcr.io. Retention
policy prunes hourly tags older than 14 days.
## README
Quickstart leads with `npm install -g`. Source-build instructions
moved to a dedicated section. Image-cadence section explains the
hourly rebuild + pinnable date tags + image-API contract.
## Tests (84/84 pass)
- msb_install: 4 (version-marker accept/reject + env-override behaviour).
- pull: 2 (local-registry detection: localhost/127.0.0.1/0.0.0.0/
*.local/*.localhost vs ghcr.io/docker.io/example.com).
- image_api_version: 5 (parse trim, parse rejects garbage,
in-range accept, too-new with actionable hint, too-old with hint).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Phase 7 chrome-devtools MCP entry (npx ... --headless --isolated)
reported `✓ Connected` to `claude mcp list` but every tool call
returned either `Protocol error (Target.setDiscoverTargets): Target
closed` or `net::ERR_CERT_AUTHORITY_INVALID`. Two distinct causes:
1. Chromium's user-namespace sandbox refuses to initialize as root
(the guest's only user), so the browser dies before the CDP pipe
is read.
2. Chromium on Linux ignores `/etc/ssl/certs/ca-certificates.crt`
and only honours its built-in root store + the per-user NSS DB,
so the microsandbox MITM CA isn't trusted by chromium even though
curl/openssl trust it via the bundle.
Both of these had naïve fixes (`--no-sandbox` and `--acceptInsecureCerts`)
but we'd rather keep chromium's nested sandbox active (defence in depth
against untrusted content the agent navigates to) and only trust *our*
one CA (not every untrusted cert).
Approach:
- **Dedicated `chrome` user (UID 9999)** baked into the image via
direct /etc/passwd + /etc/shadow + /etc/group edits (debian-slim
has no `useradd`; the shadow entry is needed because `su` calls
PAM and rejects users without one). Sudoers rule grants password-
less `root -> chrome`.
- **Wrapper `/usr/local/bin/agent-vm-chrome-mcp`** that re-execs the
MCP under chrome via `sudo -u chrome -H -n`. The env allow-list
preserves agentd-set NODE_EXTRA_CA_CERTS / SSL_CERT_FILE /
CURL_CA_BUNDLE / REQUESTS_CA_BUNDLE / PATH; without these, sudo's
default env_reset strips them and node fails `npx -y` with
SELF_SIGNED_CERT_IN_CHAIN fetching from registry.npmjs.org.
- **Pre-initialized empty NSS DB** at /home/chrome/.pki/nssdb (built
at image time with `certutil -N --empty-password`) plus
`libnss3-tools` package so the launcher's runtime `certutil -A`
has something to add to.
- **Launcher prelude (run.rs)** runs `certutil -A -t TC -n
microsandbox -i /usr/local/share/ca-certificates/microsandbox-ca.crt`
on every boot. Can't bake the CA into the image — agentd writes it
into the guest at boot time, and the cert is per-boot. Add-by-name
is idempotent so re-running on every launch is fine.
- **MCP config (secrets.rs)** swaps `command: npx` for `command:
/usr/local/bin/agent-vm-chrome-mcp` with the original npx + args
passed through. No more `--no-sandbox`, `--disable-dev-shm-usage`,
or `--acceptInsecureCerts`.
Verified e2e inside a fresh sandbox: `take_snapshot` after
`navigate_page https://example.com` returns the real "Example
Domain" heading text; `ps -ef | grep chrome` shows chromium running
as `chrome`, not root; NSS DB lists only `microsandbox` with `CT,,`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
DX additions from the original Bash agent-vm that the user wants in
v1. Four independent pieces; one commit because they all touch the
same layer (launcher + image + a new subcommand).
**`--mount HOST[:GUEST]`** (repeatable).
- New `Args::mount: Vec<String>` parsed via `parse_extra_mounts` into
`(host, guest)` pairs. `HOST` alone defaults `GUEST` to mirror.
Both must be absolute; the host path is canonicalized (follows
symlinks) and checked to exist before the launcher proceeds.
- For each mount we call `.volume(guest, |m| m.bind(host))` and
`mkdir_chain(guest)` in the patch builder, mirroring what the
project bind already does. The launcher prints a per-mount line.
- Note: libkrun's virtio IRQ pool is *tight* on this build —
empirically even one extra `--mount` on top of project + state +
network + agentd trips `RegisterNetDevice(IrqsExhausted)` at boot,
with or without secrets registered. So the feature ships with a
warning eprintln rather than a client-side cap (libkrun configs
vary) and the error message is clear when the cap is hit. See
discovered upstream issue #3 in PLAN — the right long-term fix
is to raise libkrun's IRQ budget upstream.
**Clipboard bridge.**
- New `agent-vm clipboard {get,put}` subcommand (`clipboard.rs`).
`put` reads stdin (or `VALUE` arg) into `<state>/clipboard.txt`;
`get` prints the file content to stdout. `--sys` on either side
also exchanges with the host system clipboard via
`wl-copy/wl-paste`, `xclip`, or `pbcopy/pbpaste` — first one on
PATH wins.
- The launcher's existing `/agent-vm-state` bind exposes the file at
`/agent-vm-state/clipboard.txt` inside the guest. The in-VM agent
reads/writes that path directly; no in-guest helper script
needed.
**`agent-vm-ccusage` wrapper.**
- Standalone shell script at `bin/agent-vm-ccusage` that resolves
the state root via the same precedence the Rust launcher uses
(`$AGENT_VM_STATE_DIR` → `$XDG_STATE_HOME/agent-vm` →
`$HOME/.local/state/agent-vm`), enumerates per-project
`<hash>/claude` session dirs, and runs
`npx ccusage@latest "$@"` with `CLAUDE_CONFIG_DIR` set to the
comma-joined union of host + every project. Ported 1:1 from the
original.
**Chrome DevTools MCP.**
- `images/Dockerfile` adds `chromium` + `fonts-liberation` from the
Debian repo and the usual `google-chrome` / `google-chrome-stable`
/ `/opt/google/chrome/chrome` symlink dance so tools that hard-
code those paths find the binary.
- `secrets::write_default_claude_root_state` force-sets the
`mcpServers["chrome-devtools"]` entry in the guest's
`~/.claude.json` (`{"command": "npx", "args": ["-y",
"chrome-devtools-mcp@latest", "--headless=true",
"--isolated=true"]}`). User-set MCP servers are preserved
(merge semantics on the surrounding map). Opt out with
`AGENT_VM_NO_CHROME_MCP=1`.
Verified:
- `agent-vm clipboard put` then `agent-vm clipboard get` round-trips
on the host.
- Inside the guest with the rebuilt image: `which chromium` →
`/usr/bin/chromium`, `which google-chrome` →
`/usr/bin/google-chrome`, `chromium --version` → Chromium 148.
- `~/.claude.json` in the guest contains the chrome-devtools MCP
entry.
- `agent-vm-ccusage --help` exits cleanly, ccusage CLI loads.
- `--mount` parsed correctly; print + fail-on-missing work; boot
fails with the libkrun IRQ error as documented.
- 6/6 cargo tests still green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
In-VM agents need to push commits and create PRs to be useful for
actual development. Original Bash agent-vm did this with the host's
gh auth and a credential proxy. The rewrite has been silent on it.
This wires it end-to-end with a per-launch GitHub repo allow-list
enforced at the proxy hook.
**Credential injection.**
- `secrets::refresh_gh` shells out to `gh auth token` and stashes the
result in the host-only `<state>.secrets/gh` (mode 0600). Returns
None gracefully if `gh` isn't installed or the user isn't logged in
— no warning, just no gh substitution.
- New constant `GH_TOKEN_PLACEHOLDER` ("MSB_PLACEHOLDER_GH_TOKEN_v1")
is what the guest's gh / git tools see; the proxy substitutes it
for the real bearer on outbound traffic.
- `secrets::write_guest_gh_config` writes `<state>/gitconfig` (with a
credential helper that echoes the placeholder so `git push` sends
it as Basic auth) and `<state>/gh-config/hosts.yml` (for the gh
CLI). The launcher's patch builder symlinks both into the standard
guest paths (`/root/.gitconfig`, `/root/.config/gh`).
**Allow-list.**
- `run::detect_github_repos_in_cwd` parses `git remote -v` in the
project dir and pulls out `owner/repo` slugs from any
github.com remote (https, ssh, ssh:// — `parse_github_slug`
covers each form).
- New CLI flags on `claude/codex/opencode/shell`: `--no-git` (opt
out entirely) and `--repo OWNER/NAME` (repeatable, widens the
list beyond the cwd remote).
- The launcher gates `use_github` on having at least one allowed
repo + `--no-git` not set; passes that into
`secrets::refresh(state_dir, project_guest_path, use_github)`
(now takes a 3rd arg).
**Proxy enforcement (the new bit).**
- New secret entry in `run.rs` for the gh token, with
`inject_basic_auth(true)` so git's Basic-auth wire format
(base64(x-access-token:placeholder)) is substituted correctly.
Allowed hosts: api.github.com, github.com, codeload.github.com,
raw.githubusercontent.com, objects.githubusercontent.com.
- New intercept rules for api.github.com on every method gh CLI
uses (GET/POST/PATCH/PUT/DELETE), all with path "/" (prefix
match — catches everything). Only added when a gh token was
captured; otherwise the hook has nothing to forward with.
- New hook path in `intercept_hook::forward_github_api` (hook is
now `async fn`): parses the buffered HTTP request, calls
`github_path_allowed(path, allowed_repos)`, and either
synthesizes a 403 (denied) or forwards via `reqwest` after
replacing `GH_TOKEN_PLACEHOLDER` in Authorization with the real
bearer from `<state>.secrets/gh`. Response is rebuilt as
HTTP/1.1 bytes for the proxy to encrypt back to the guest.
- `github_path_allowed` covers `/repos/<owner>/<repo>/...` and
`/user[/...]` (gh CLI needs the latter for auth-status and
`gh repo list`; doesn't expose specific-repo state).
**Image (`images/Dockerfile`).**
- Adds gh from the official apt repo (gh.io packages signed
by /etc/apt/keyrings/githubcli-archive-keyring.gpg).
**Verified end-to-end on this host:**
- `agent-vm shell` from a project with `origin =
github.com/wirenboard/agent-vm`:
- `gh api repos/wirenboard/agent-vm` reaches GitHub (Bad
credentials — outer-bridge nested limit; placeholder is
actually reaching GitHub via our forwarder).
- `gh api repos/octocat/Hello-World` returns our clean 403 with
the allow-list named in the message.
- `gh api user` allowed (gh auth-status etc. work).
- With `--repo octocat/Hello-World`, the allow-list grows; the
octocat path now reaches GitHub, anthropics/claude-code still
returns 403 listing both allowed repos.
- No host token in the guest mount (`grep -ras "<real prefix>"
/agent-vm-state /root` empty).
**Out of scope (per user direction):** GitHub App device flow,
per-repo scoped tokens, Copilot subcommand. `github.com` smart-
protocol git push is NOT path-filtered — token is substituted but
agents could in principle push to other repos via the git wire
protocol. Filtering that requires stream-aware intercept hooks
upstream.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`docker inspect` of a missing container emits a stray blank line to
stdout before exiting non-zero on Docker 29.x, so
`state=$(... || echo missing)` captured "\nmissing" and never matched
the `missing` case — ensure_registry then tried to `docker start` a
container that doesn't exist and aborted setup.
Strip whitespace and treat empty as missing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`rm -rf /var/lib/apt/lists/*` got removed from two RUN lines as collateral
damage from the local marker-test loop in the previous test session
(backup-and-restore via cp). Bring them back so the image stays small.
User feedback on the two-bar layout:
- "Two lines instead of one"
- "Doesn't work" — the download bar sat at 0 B for a long time on a
local-registry pull because microsandbox usually elides
LayerDownloadProgress events when the source is fast, leaving the
old code with nothing to inc(). The bar looked frozen even while
layers were actually being downloaded.
New design: a single bar of length 2*layer_count. Each layer ticks once
when its download finishes (LayerDownloadComplete now always credits the
bar, even with no prior progress events) and once when its materialize
finishes. The bar's message text reflects the current phase:
"Resolving... -> Downloading layer N/M -> Materializing layer N/M ->
Stitching -> Writing fsmeta -> Writing VMDK -> Rootfs ready". On
completion the bar is wiped (finish_and_clear) so subsequent output
starts on a clean line — no leftover "Manifest resolved" leak from the
old finish_with_message path.
Verified end-to-end under a pty (script -qc): single line that advances
0/14 -> 14/14 over ~2 min, never stuck at zero, no second row.
Two related papercuts: the user could neither tell when a long-running
step had started, nor recover from a registry container created (in some
past session) without `-p 127.0.0.1:5000:5000`.
build.sh
- Rewrite ensure_registry as a real state machine. Running-but-
unresponsive triggers a remove-and-recreate after dumping diagnostics;
stopped/etc start, then recreate if start didn't help. The "missing"
path stays the same. Confirmed against a deliberately mis-bound
container (registry on 5000/tcp only, no host port): script detects,
recreates, push succeeds — no manual `docker rm -f` needed.
- Single poll_registry helper so the cheap-once-then-poll pattern lives
in one place, with an explicit "==> Waiting for registry on ..." banner
the first time we don't get an immediate response.
setup.rs (verify path)
- Print before each phase: "Booting throwaway sandbox", "Running
claude/opencode/codex --version inside the sandbox", "Stopping verify
sandbox". The verify step used to disappear for ~3s after the
"Verifying" banner with no indication of progress.
run.rs (launcher)
- Print "Booting sandbox from <image> (<mem> MiB, <cpus> vCPU; first run
pulls layers, otherwise ~3s)" before create(), "Attaching to <cmd>" or
"Running <cmd> in sandbox (no TTY; output appears after exit)" before
the attach/exec branch, and "Stopping sandbox" before stop_and_wait().
When wait_for_registry times out, dump the container state, port bindings,
and last 30 lines of container logs to stderr, plus the one-liner the
user almost certainly needs (`docker rm -f agent-vm-registry && agent-vm
setup`). The bare "did not become reachable" message wasn't actionable.
Previously `ensure_registry` only handled the running and missing cases.
A stopped container plus the implicit ECONNREFUSED race after `docker
start` (registry:2 takes ~100 ms to bind 5000 after the container goes
"running") meant the push fired before the listener was up, even when
the start command succeeded.
- Recognize every non-running state (exited, created, paused, restarting,
dead) and `docker start` from them all.
- Add wait_for_registry: poll /v2/ on 127.0.0.1:5000 for up to 10s before
returning. This is the only thing that actually makes the script safe
to re-run after the container has been stopped.
Add the minimal Debian 13 image with Claude Code, OpenCode, and Codex
preinstalled, and wire `agent-vm setup` to build/push/verify it via a host-
local Docker registry.
- images/Dockerfile: Debian 13 slim + node22 + ripgrep/fd/jq/git + the three
agent CLIs installed via their canonical installer scripts so we track
upstream release channels instead of pinning npm package versions.
- images/build.sh: ensure an `agent-vm-registry` (registry:2) container is
running on 127.0.0.1:5000, docker build, docker push.
- agent-vm setup: clap-based subcommand that shells to images/build.sh, then
boots `localhost:5000/agent-vm:latest` under microsandbox (insecure
registry) and runs the three `--version` commands. --no-verify and
--image/AGENT_VM_IMAGE_TAG escape hatches included.
- ARCHITECTURE.md: Phase 1 section with the registry-vs-Bind/qcow2 decision,
the deliberately minimal package list, and why the build step is shelled
out to Bash while verification stays in Rust.
End-to-end run: image builds, pushes to the local registry, sandbox boots
from it, and reports Claude 2.1.143 / OpenCode 1.15.3 / codex-cli 0.130.0.