feat(deploy): nix-built Docker image + compose for evie #86
No reviewers
Labels
No labels
epic
in-review
ready-for-agent
ready-for-human
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
weiwen/evie!86
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "fm/evie-docker-image"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Delivers a real, verified nix-built Docker image for evie plus a compose deployment. Not docs-only: the image builds, the daemon boots, and the bwrap sandbox executes inside the container.
Based on current
origin/main(99d48f0). Implements the scout report spec (data/evie-docker-packaging/report.md§2-§5).What's here
flake.nix:packages.eviedaemonImage(dockerTools.streamLayeredImage, mirrors the ADR 0004sandcastleImagepattern). Carries:eviepackage (PATH already wrapped withpi/ffmpeg/sherpa-onnx/bubblewrap),/nix(a port of the live NixOS unit'spath: bash, coreutils, git, python3, nodejs, uv, jq, ripgrep, fd, curl, cacert, bubblewrap, gcc, gnumake, gogcli, imagemagick, jujutsu, nix, openssh, tea, tree, unar/unzip/zip, wget, which, yt-dlp, agent-browser, herdr),fakeNsssandbox accountevie:x:974:974:...sogetpwnam/getpwuidresolve,EVIE_SOCKET,UV_PYTHON*,chmod 0711 /var/lib/evie /run/eviethenexec evie --config /etc/evie/config.toml daemon).Useris left unset: the daemon runs as root and drops per session.deploy/docker/:docker-compose.yaml(the twosecurity_optlines, state volume,env_file, tmpfs/run/evie),config.sample.toml(long-polling variant),secrets/evie.env.example, and a shortREADME.mdcovering the three footguns (run as root,agent-auth.json0644/0640+group, state dir0711).$out/share/evie/skills, resolved at runtime bybundled_skill_dirs), so the image carries it. PR #85's schedule skill is not onorigin/mainyet; nothing extra needed here for it.Verification (on matsutake, NixOS, Docker 29.6.1)
1. Image builds + loads
2. Daemon boots + reads config (HTTP-only config, no creds needed):
3. In-container bwrap sandbox executes (the whole risk). Harness mirrors evie's exact path: root -> setgroups+setgid+setuid to uid 974 (as
run_as.rsdoes) ->bwrapwith evie's exact flags frompi.rs::bwrap_argv(--unshare-all --unshare-net --die-with-parent --clearenv --proc /proc --dev /dev --tmpfs /tmp --ro-bind /nix /nix ...) -> runs a/nixstorenode:4. Negative control (proves the two
security_optlines are load-bearing) - same harness, default Docker seccomp:5. Invariants
711 /var/lib/evie,711 /run/evie(entrypoint)./etc/passwd->evie:x:974:974:evie sandbox:/var/lib/evie/homes:/noshell;/etc/group->evie:x:974:./nix(bwrap, git, python3, node, uv, jq, rg, fd, curl, tea, ...).pi-0.80.6+ ffmpeg + sherpa-onnx + bubblewrap are injected into the daemon PATH by evie's wrapper; the sandbox inherits the daemon PATH (pi.rs), so the agent getspiand every tool.just check(fmt + clippy + 486 tests) green. No Rust source changed - onlyflake.nix,deploy/docker/, and aCONTEXT.mdpointer.Verified vs. needs real creds
Verified in this scratch env: image builds & loads; daemon boots and reads config with HTTP + control-plane socket up; the in-container bwrap sandbox creates its namespaces and runs a
/nixbinary as non-root uid 974 under the two relaxations; the negative control fails without them.Not exercised here (needs real credentials): a full Telegram long-poll / provider boot and a real end-to-end sandboxed
pisession driving the provider (needs a bot token + anagent-auth.jsonlogin artifact). The sandbox mechanism the provider session relies on is the exact thing proven in step 3.Security note
seccomp=unconfined+systempaths=unconfinedis the minimal working set (no added caps, not--privileged). It matches the NixOS unit's deliberately-omittedSystemCallFilter/RestrictNamespacesand downgradedProtectSystem. Follow-up hardening: a custom seccomp profile = Docker default +clone(CLONE_NEWUSER)/pivot_root/mount/umount2.eaa5fe00c710f9621fb9The live users.toml pins the sole vouched user to `user = "weiwen"` (uid 1000), but the image's account database had only `evie`/974, so every non-sudo session resolved its drop target via getpwnam("weiwen")/getpwuid(1000) and fail-closed (src/run_as.rs). Add a `weiwen`/uid-1000 account to the image's fakeNss alongside `evie`/974. uid 1000 is the alignment point: it matches the migrated homes/<id> owner and the host `/home/weiwen` mount-through (notes + gog creds), which are 0700 weiwen and only readable by uid 1000; the daemon's RunAsTarget::own re-chowns the session home so the migrated group self-heals. Adds docs/deploy-docker-migration.md, a nix->Docker migration runbook (state copy, the 8+1 sops secrets, config translation, the ~/notes + gog host mounts as a mount-through per the operator constraint, webhook re-registration, restic->volume backups, voice-model and model-enablement notes, ordered cutover + rollback).Cutover-ready: two migration blockers closed + robustness fix, re-rehearsed on a copy of prod state
Extends this branch with the fixes the migration rehearsal (
data/evie-docker-migration-plan/report.md) found blocking, then re-rehearses on a copy of live/var/lib/evieto prove them closed. Production was never touched; all copies/secrets were shredded (attestation below).Fixes (3 atomic commits)
feat(deploy)(blocker #1): imagefakeNssnow shipsweiwen/uid-1000 alongsideevie/974, so auser="weiwen"sandboxed session resolves its drop target instead of fail-closing (src/run_as.rs). uid 1000 aligns the migratedhomes/<id>owner and the/home/weiwenmount-through.fix(pi): bind provider credential on the sudo spawn path(blocker #2):build_pi_command'sSudoarm now pointsHOMEat the session home andSessionManager::seed_sudo_agent_authcopies the credential there (the on-disk equivalent of the sandboxed arm's read-only bind). HTTP-API turns and the two sudo cron schedules now authenticate instead of gettingNo API keyand hanging the idle timeout.fix(pi): surface pi response errors instead of idle-timeout: aresponse success:falseis now terminal, so an auth failure surfaces immediately instead of an opaque 60s timeout. Regression test drives an open (non-EOF) stream and asserts the error returns well inside a short bound.docs/deploy-docker-migration.md— nix→Docker runbook (state copy, 8+1 sops secrets, config translation,~/notes+gog mount-through, webhook re-registration, restic→volume, voice-model + model-enablement notes, ordered cutover + rollback).Build + gate
nix build .#eviedaemonImagesucceeds; new store hash06srh12…(was9d9vybrww9…), confirming thefakeNsschange baked in.just checkgreen: fmt OK, clippy-D warningsclean, 489 tests pass (incl. the fix-3 regression test).getpwnam("weiwen")→uid 1000 gid 1000,getpwuid(1000)→weiwen;evie/974 intact (before:KeyError).Re-rehearsal (copy of prod state, isolated: telegram off, HTTP on
127.0.0.1:1388x, bothsecurity_opt, throwaway volume, own opencode cred — never prod's sops secret)bwrap_argvas the dropped uid (id → 1000:1000 (weiwen)), over the migratedhomes/113665509(owned 1000) + bound credential +--skill schedule→{"type":"response","command":"prompt","success":true}— authenticated in-sandbox as uid 1000 (the exact path that fail-closed before). The un-bwrapped control returnssuccess:false, confirming the binds are what make it work.POST /api/chat/message(sudo=true) →{"response":"FIX2_SUDO_OK"}in 3s, with the daemonHOME=/var/emptyand the seeded cred atsessions/.pi/agent/auth.json(0600). Before: 60s opaque timeout.pi provider error: No API key found for opencode.returned in 1s (not the pre-fix ~60s timeout).Loaded schedules count=3, both interfaces listening, no registry/message-log errors;users.tomlsha2561de244b2…unchanged after all turns;schedules=3,messages.db=176 rows./nixbound.Production-safety attestation
evie.servicenever stopped/modified:is-active=active,NRestarts=0,ExecMainStartTimestamp=Thu 2026-07-30 20:12:41 +08— unchanged before and after./var/lib/eviewas only read (copied out by path; the0711root blocks enumeration). Never bind-mounted into any container; never written. The rehearsal used a throwaway volume populated from a scratch copy.3888/8443never bound. Provider cred was the operator's own opencode login transformed to pi format, never prod'sevieAgentAuthsops secret.shred -u'd; scratch swept clean (no secret markers remain); operator's own cred left intact.Not merged. Cutover remains the captain's separately-authorized action (per the runbook: stop
evie.service, rootrsync -aHAX --numeric-ids, real token, webhook re-registration).Update: image is now generic; sandbox accounts declared at deploy time
Reworked so the image bakes no operator identity.
flake.nixfakeNssships onlyproduct-generic accounts (
root, the defaultevie/974,nobody); theweiwen/uid-1000account is gone from the image. Extra sandbox accounts a deployment's
users.tomlpins viauser=are declared at deploy time throughEVIE_SANDBOX_USERS="name:uid:gid[,...]", whichthe root entrypoint appends to
/etc/passwd+/etc/group(replacing fakeNss's read-onlystore symlinks with writable copies first) before dropping per session. Optional: a
deployment using only the default
eviesets nothing. Compose sample, README, sample configand the migration runbook updated to show
EVIE_SANDBOX_USERS=weiwen:1000:1000alongsidethe
~/notesmount-through.Fixes 2 and 3 from the cutover-ready work are unchanged (sudo/HTTP/cron credential bind;
response success:falsesurfaced immediately). Sample model staysdeepseek-v4-flash-free.Verification (light, per supervisor — heavy state-copy rehearsal skipped)
just check— green (fmt, clippy-D warnings, 489 tests).nix build .#eviedaemonImage— succeeds →stream-eviestreamer; loaded toevie:latest.getpwnam):EVIE_SANDBOX_USERS=weiwen:1000:1000,live
getpwnam/getgrnaminside the running container (passwd: files): This is exactly whatrun_as::resolve(User::from_name/Group::from_name) needs tosucceed at session-drop for a
user="weiwen"session; without the declaration itKeyErrors and the session fail-closes.Production safety:
evie.servicenever touched (active,NRestarts=0,ExecMainStartTimestampunchanged before/after); no live/var/lib/eviebind and nostate copied this round; no real secrets used (dummy api-key only); throwaway container +
scratch config removed/shredded; no leftover containers or volumes.