feat(deploy): nix-built Docker image + compose for evie #86

Merged
weiwen merged 6 commits from fm/evie-docker-image into main 2026-07-31 22:09:21 +08:00
Owner

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 0004 sandcastleImage pattern). Carries:
    • the evie package (PATH already wrapped with pi/ffmpeg/sherpa-onnx/bubblewrap),
    • the full agent toolchain under /nix (a port of the live NixOS unit's path: 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),
    • a fakeNss sandbox account evie:x:974:974:... so getpwnam/getpwuid resolve,
    • cacert env, EVIE_SOCKET, UV_PYTHON*,
    • a root entrypoint (chmod 0711 /var/lib/evie /run/evie then exec evie --config /etc/evie/config.toml daemon). User is left unset: the daemon runs as root and drops per session.
  • deploy/docker/: docker-compose.yaml (the two security_opt lines, state volume, env_file, tmpfs /run/evie), config.sample.toml (long-polling variant), secrets/evie.env.example, and a short README.md covering the three footguns (run as root, agent-auth.json 0644/0640+group, state dir 0711).
  • The built-in evie control skill ships with the package ($out/share/evie/skills, resolved at runtime by bundled_skill_dirs), so the image carries it. PR #85's schedule skill is not on origin/main yet; nothing extra needed here for it.

Verification (on matsutake, NixOS, Docker 29.6.1)

1. Image builds + loads

$ nix build .#eviedaemonImage        # exit 0
$ ./result | docker load
evie:latest   87367feb90ed   4.76GB

2. Daemon boots + reads config (HTTP-only config, no creds needed):

INFO evie: Loading config path=/etc/evie/config.toml
INFO evie: Interfaces configured telegram_enabled=false http_enabled=true
INFO evie: State root path=/var/lib/evie
INFO evie::control_plane: Control plane listening path=/run/evie/evie.sock
INFO evie::http: HTTP API listening on 0.0.0.0:3888
# container running=true exit=0; curl reaches the HTTP server (responds, not refused)

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.rs does) -> bwrap with evie's exact flags from pi.rs::bwrap_argv (--unshare-all --unshare-net --die-with-parent --clearenv --proc /proc --dev /dev --tmpfs /tmp --ro-bind /nix /nix ...) -> runs a /nix store node:

$ docker run --rm --security-opt seccomp=unconfined --security-opt systempaths=unconfined \
    --entrypoint python3 -v .../sandbox_verify.py:/verify.py:ro evie:latest /verify.py
SELF_CONTAINED_SANDBOX_OK node v24.18.0 uid 974 cwd /var/lib/evie/homes/verify

4. Negative control (proves the two security_opt lines are load-bearing) - same harness, default Docker seccomp:

bwrap: No permissions to create a new namespace, likely because the kernel does not allow non-privileged user namespaces.

5. Invariants

  • state dir / runtime dir: 711 /var/lib/evie, 711 /run/evie (entrypoint).
  • sandbox account: /etc/passwd -> evie:x:974:974:evie sandbox:/var/lib/evie/homes:/noshell; /etc/group -> evie:x:974:.
  • toolchain resolves under /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 gets pi and every tool.

just check (fmt + clippy + 486 tests) green. No Rust source changed - only flake.nix, deploy/docker/, and a CONTEXT.md pointer.

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 /nix binary 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 pi session driving the provider (needs a bot token + an agent-auth.json login artifact). The sandbox mechanism the provider session relies on is the exact thing proven in step 3.

Security note

seccomp=unconfined + systempaths=unconfined is the minimal working set (no added caps, not --privileged). It matches the NixOS unit's deliberately-omitted SystemCallFilter/RestrictNamespaces and downgraded ProtectSystem. Follow-up hardening: a custom seccomp profile = Docker default + clone(CLONE_NEWUSER)/pivot_root/mount/umount2.

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 0004 `sandcastleImage` pattern). Carries: - the `evie` package (PATH already wrapped with `pi`/`ffmpeg`/`sherpa-onnx`/`bubblewrap`), - the full agent toolchain under `/nix` (a port of the live NixOS unit's `path`: 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), - a `fakeNss` sandbox account `evie:x:974:974:...` so `getpwnam`/`getpwuid` resolve, - cacert env, `EVIE_SOCKET`, `UV_PYTHON*`, - a **root entrypoint** (`chmod 0711 /var/lib/evie /run/evie` then `exec evie --config /etc/evie/config.toml daemon`). `User` is left unset: the daemon runs as root and drops per session. - **`deploy/docker/`**: `docker-compose.yaml` (the two `security_opt` lines, state volume, `env_file`, tmpfs `/run/evie`), `config.sample.toml` (long-polling variant), `secrets/evie.env.example`, and a short `README.md` covering the three footguns (run as root, `agent-auth.json` `0644`/`0640`+group, state dir `0711`). - The built-in evie control skill ships with the package (`$out/share/evie/skills`, resolved at runtime by `bundled_skill_dirs`), so the image carries it. PR #85's schedule skill is not on `origin/main` yet; nothing extra needed here for it. ## Verification (on matsutake, NixOS, Docker 29.6.1) **1. Image builds + loads** ``` $ nix build .#eviedaemonImage # exit 0 $ ./result | docker load evie:latest 87367feb90ed 4.76GB ``` **2. Daemon boots + reads config** (HTTP-only config, no creds needed): ``` INFO evie: Loading config path=/etc/evie/config.toml INFO evie: Interfaces configured telegram_enabled=false http_enabled=true INFO evie: State root path=/var/lib/evie INFO evie::control_plane: Control plane listening path=/run/evie/evie.sock INFO evie::http: HTTP API listening on 0.0.0.0:3888 # container running=true exit=0; curl reaches the HTTP server (responds, not refused) ``` **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.rs` does) -> `bwrap` with evie's exact flags from `pi.rs::bwrap_argv` (`--unshare-all --unshare-net --die-with-parent --clearenv --proc /proc --dev /dev --tmpfs /tmp --ro-bind /nix /nix ...`) -> runs a `/nix` store `node`: ``` $ docker run --rm --security-opt seccomp=unconfined --security-opt systempaths=unconfined \ --entrypoint python3 -v .../sandbox_verify.py:/verify.py:ro evie:latest /verify.py SELF_CONTAINED_SANDBOX_OK node v24.18.0 uid 974 cwd /var/lib/evie/homes/verify ``` **4. Negative control** (proves the two `security_opt` lines are load-bearing) - same harness, default Docker seccomp: ``` bwrap: No permissions to create a new namespace, likely because the kernel does not allow non-privileged user namespaces. ``` **5. Invariants** - state dir / runtime dir: `711 /var/lib/evie`, `711 /run/evie` (entrypoint). - sandbox account: `/etc/passwd` -> `evie:x:974:974:evie sandbox:/var/lib/evie/homes:/noshell`; `/etc/group` -> `evie:x:974:`. - toolchain resolves under `/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 gets `pi` and every tool. `just check` (fmt + clippy + 486 tests) green. No Rust source changed - only `flake.nix`, `deploy/docker/`, and a `CONTEXT.md` pointer. ## 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 `/nix` binary 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 `pi` session driving the provider (needs a bot token + an `agent-auth.json` login artifact). The sandbox *mechanism* the provider session relies on is the exact thing proven in step 3. ## Security note `seccomp=unconfined` + `systempaths=unconfined` is the minimal working set (no added caps, not `--privileged`). It matches the NixOS unit's deliberately-omitted `SystemCallFilter`/`RestrictNamespaces` and downgraded `ProtectSystem`. Follow-up hardening: a custom seccomp profile = Docker default + `clone(CLONE_NEWUSER)`/`pivot_root`/`mount`/`umount2`.
feat(deploy): nix-built Docker image + compose for evie
All checks were successful
CI / check (pull_request) Successful in 1m35s
PR Triage — label changes-requested reviews / triage-review (pull_request) Successful in 1s
eaa5fe00c7
Add packages.eviedaemonImage (dockerTools.streamLayeredImage), mirroring
the ADR 0004 sandcastleImage pattern. It carries the evie package (PATH
wrapped with pi/ffmpeg/bwrap), the full agent toolchain under /nix that
the bwrap sandbox needs (a port of the live NixOS unit's path), a fakeNss
uid-974 sandbox account, cacert, and a root entrypoint that enforces the
0711 state-dir invariant before the daemon drops per session.

The sandbox binds only /nix, so a stock base image leaves the agent with
no tools; the image must be nix-built. Run under seccomp=unconfined +
systempaths=unconfined so bwrap can build its unprivileged user/mount
namespaces (the same posture the NixOS unit already takes by omitting
SystemCallFilter/RestrictNamespaces).

Ship deploy/docker/ (compose, sample long-polling config, env example,
short README covering the three permission footguns).
weiwen force-pushed fm/evie-docker-image from eaa5fe00c7
All checks were successful
CI / check (pull_request) Successful in 1m35s
PR Triage — label changes-requested reviews / triage-review (pull_request) Successful in 1s
to 10f9621fb9
All checks were successful
CI / check (pull_request) Successful in 1m33s
PR Triage — label changes-requested reviews / triage-review (pull_request) Successful in 2s
2026-07-31 01:19:48 +08:00
Compare
A rejected pi `prompt` command (e.g. `No API key for opencode`) has no turn to
run, and pi emits nothing further - no `agent_settled` follows. The parser only
recorded the error and waited for a terminal event, so an auth failure presented
as an opaque 60s idle timeout rather than the real error. Treat a
`response success:false` as terminal and surface its error immediately.

A trailing `agent_settled`, if pi ever emits one, carries neither error nor
response and is skipped as a stray marker, so returning early strands nothing for
the next turn (the reused-process invariant holds). Adds a regression test driving
an open (non-EOF) stream: the error must return well inside a short bound, not
block until the idle timeout.
The unsandboxed `Sudo` spawn (used by every HTTP-API turn and the two sudo cron
schedules) inherited the daemon's `HOME` (`/var/empty` in the Docker image) and
never provided the provider credential, so pi returned `No API key for opencode`
and the turn hung the full idle timeout.

The sandboxed arm binds the credential read-only at `$HOME/.pi/agent/auth.json`;
the sudo arm has no mount namespace to overlay it. Mirror it on disk instead:
`SessionManager::seed_sudo_agent_auth` copies the credential into the session
home before the spawn, and `build_pi_command`'s `Sudo` arm points `HOME` at that
home so pi resolves it. The copy is 0600 and re-copied each spawn so a rotated
credential is picked up and pi can never write back onto the shared secret.
feat(deploy): add weiwen uid-1000 account + nix->docker migration runbook
All checks were successful
CI / check (pull_request) Successful in 1m52s
PR Triage — label changes-requested reviews / triage-review (pull_request) Successful in 2s
e9e474ecce
The 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).
Author
Owner

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/evie to prove them closed. Production was never touched; all copies/secrets were shredded (attestation below).

Fixes (3 atomic commits)

  • Fix 1 — feat(deploy) (blocker #1): image fakeNss now ships weiwen/uid-1000 alongside evie/974, so a user="weiwen" sandboxed session resolves its drop target instead of fail-closing (src/run_as.rs). uid 1000 aligns the migrated homes/<id> owner and the /home/weiwen mount-through.
  • Fix 2 — fix(pi): bind provider credential on the sudo spawn path (blocker #2): build_pi_command's Sudo arm now points HOME at the session home and SessionManager::seed_sudo_agent_auth copies 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 getting No API key and hanging the idle timeout.
  • Fix 3 — fix(pi): surface pi response errors instead of idle-timeout: a response success:false is 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: 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 .#eviedaemonImage succeeds; new store hash 06srh12… (was 9d9vybrww9…), confirming the fakeNss change baked in.
  • just check green: fmt OK, clippy -D warnings clean, 489 tests pass (incl. the fix-3 regression test).
  • Image account resolution in the built image: 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, both security_opt, throwaway volume, own opencode cred — never prod's sops secret)

  • Fix 1 — sandboxed weiwen/uid-1000 resolves + spawns: bwrap probe mirroring bwrap_argv as the dropped uid (id → 1000:1000 (weiwen)), over the migrated homes/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 returns success:false, confirming the binds are what make it work.
  • Fix 2 — sudo/HTTP turn authenticates: POST /api/chat/message (sudo=true) → {"response":"FIX2_SUDO_OK"} in 3s, with the daemon HOME=/var/empty and the seeded cred at sessions/.pi/agent/auth.json (0600). Before: 60s opaque timeout.
  • Fix 3 — auth failure surfaces promptly: with a no-key credential → pi provider error: No API key found for opencode. returned in 1s (not the pre-fix ~60s timeout).
  • Real state loads / no re-bootstrap: clean boot logs Loaded schedules count=3, both interfaces listening, no registry/message-log errors; users.toml sha256 1de244b2… unchanged after all turns; schedules=3, messages.db=176 rows.
  • #85 schedule skill in-sandbox: readable as uid 1000 with only /nix bound.

Production-safety attestation

  • evie.service never stopped/modified: is-active=active, NRestarts=0, ExecMainStartTimestamp=Thu 2026-07-30 20:12:41 +08 — unchanged before and after.
  • Live /var/lib/evie was only read (copied out by path; the 0711 root blocks enumeration). Never bind-mounted into any container; never written. The rehearsal used a throwaway volume populated from a scratch copy.
  • No real Telegram token; telegram disabled; no webhook registered; prod ports 3888/8443 never bound. Provider cred was the operator's own opencode login transformed to pi format, never prod's evieAgentAuth sops secret.
  • Teardown complete: rehearsal containers + throwaway volume removed; the copied prod state and all converted/rehearsal secrets 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, root rsync -aHAX --numeric-ids, real token, webhook re-registration).

## 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/evie` to prove them closed. Production was never touched; all copies/secrets were shredded (attestation below). ### Fixes (3 atomic commits) - **Fix 1 — `feat(deploy)` (blocker #1):** image `fakeNss` now ships `weiwen`/uid-1000 alongside `evie`/974, so a `user="weiwen"` sandboxed session resolves its drop target instead of fail-closing (`src/run_as.rs`). uid 1000 aligns the migrated `homes/<id>` owner and the `/home/weiwen` mount-through. - **Fix 2 — `fix(pi): bind provider credential on the sudo spawn path` (blocker #2):** `build_pi_command`'s `Sudo` arm now points `HOME` at the session home and `SessionManager::seed_sudo_agent_auth` copies 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 getting `No API key` and hanging the idle timeout. - **Fix 3 — `fix(pi): surface pi response errors instead of idle-timeout`:** a `response success:false` is 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:** `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 .#eviedaemonImage` succeeds; new store hash `06srh12…` (was `9d9vybrww9…`), confirming the `fakeNss` change baked in. - `just check` green: fmt OK, clippy `-D warnings` clean, **489 tests pass** (incl. the fix-3 regression test). - Image account resolution in the built image: `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`, both `security_opt`, throwaway volume, own opencode cred — never prod's sops secret) - **Fix 1 — sandboxed weiwen/uid-1000 resolves + spawns:** bwrap probe mirroring `bwrap_argv` as the dropped uid (`id → 1000:1000 (weiwen)`), over the migrated `homes/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 returns `success:false`, confirming the binds are what make it work. - **Fix 2 — sudo/HTTP turn authenticates:** `POST /api/chat/message` (`sudo=true`) → `{"response":"FIX2_SUDO_OK"}` in **3s**, with the daemon `HOME=/var/empty` and the seeded cred at `sessions/.pi/agent/auth.json` (0600). Before: 60s opaque timeout. - **Fix 3 — auth failure surfaces promptly:** with a no-key credential → `pi provider error: No API key found for opencode.` returned in **1s** (not the pre-fix ~60s timeout). - **Real state loads / no re-bootstrap:** clean boot logs `Loaded schedules count=3`, both interfaces listening, no registry/message-log errors; `users.toml` sha256 `1de244b2…` **unchanged** after all turns; `schedules=3`, `messages.db`=176 rows. - **#85 schedule skill in-sandbox:** readable as uid 1000 with only `/nix` bound. ### Production-safety attestation - `evie.service` **never** stopped/modified: `is-active=active`, `NRestarts=0`, `ExecMainStartTimestamp=Thu 2026-07-30 20:12:41 +08` — unchanged before and after. - Live `/var/lib/evie` was only **read** (copied out by path; the `0711` root blocks enumeration). Never bind-mounted into any container; never written. The rehearsal used a throwaway volume populated from a scratch copy. - No real Telegram token; telegram disabled; no webhook registered; prod ports `3888`/`8443` never bound. Provider cred was the operator's **own** opencode login transformed to pi format, never prod's `evieAgentAuth` sops secret. - **Teardown complete:** rehearsal containers + throwaway volume removed; the copied prod state and all converted/rehearsal secrets `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`, root `rsync -aHAX --numeric-ids`, real token, webhook re-registration).
refactor(deploy): declare sandbox accounts at deploy time, keep the image generic
All checks were successful
CI / check (pull_request) Successful in 1m54s
PR Triage — label changes-requested reviews / triage-review (pull_request) Successful in 1s
430564fcad
The image baked a weiwen/uid-1000 account into fakeNss (flake.nix) so the live
users.toml's user="weiwen" drop target would resolve. That made the image
operator-specific. The account is a property of the deployment, not the product:
the image now ships only generic accounts (root, evie/974, nobody).

A deployer whose users.toml pins a session's user= to a host uid declares it at
deploy time via $EVIE_SANDBOX_USERS="name:uid:gid[,name:uid:gid]". The root
entrypoint, before dropping per session, appends any absent entry to /etc/passwd
+ /etc/group (replacing fakeNss's read-only store symlinks with writable copies
first) so getpwnam/getpwuid resolve at session-drop time (src/run_as.rs). It is
optional: a deployment using only the default evie account sets nothing.

Compose sample, README, sample config, and the migration runbook show the
operator declaring EVIE_SANDBOX_USERS=weiwen:1000:1000 alongside the ~/notes
mount-through instead of relying on a baked account.
Author
Owner

Update: image is now generic; sandbox accounts declared at deploy time

Reworked so the image bakes no operator identity. flake.nix fakeNss ships only
product-generic accounts (root, the default evie/974, nobody); the weiwen/uid-1000
account is gone from the image. Extra sandbox accounts a deployment's users.toml pins via
user= are declared at deploy time through EVIE_SANDBOX_USERS="name:uid:gid[,...]", which
the root entrypoint appends to /etc/passwd + /etc/group (replacing fakeNss's read-only
store symlinks with writable copies first) before dropping per session. Optional: a
deployment using only the default evie sets nothing. Compose sample, README, sample config
and the migration runbook updated to show EVIE_SANDBOX_USERS=weiwen:1000:1000 alongside
the ~/notes mount-through.

Fixes 2 and 3 from the cutover-ready work are unchanged (sudo/HTTP/cron credential bind;
response success:false surfaced immediately). Sample model stays deepseek-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-evie streamer; loaded to evie:latest.
  • Bare image is generic (entrypoint overridden, no declaration; real glibc getpwnam):
    root: uid=0 OK   nobody: uid=65534 OK   evie: uid=974 OK
    weiwen: getpwnam KeyError (absent) - OK, image is generic
    
  • Declared account resolves — real entrypoint with EVIE_SANDBOX_USERS=weiwen:1000:1000,
    live getpwnam/getgrnam inside the running container (passwd: files):
    getpwnam(weiwen) -> uid=1000 gid=1000 home=/var/lib/evie/homes shell=/noshell
    getgrnam(weiwen) -> gid=1000
    getpwuid(1000)   -> name=weiwen
    getpwnam(evie)   -> uid=974 (baked default intact)
    
    This is exactly what run_as::resolve (User::from_name/Group::from_name) needs to
    succeed at session-drop for a user="weiwen" session; without the declaration it
    KeyErrors and the session fail-closes.

Production safety: evie.service never touched (active, NRestarts=0,
ExecMainStartTimestamp unchanged before/after); no live /var/lib/evie bind and no
state copied this round; no real secrets used (dummy api-key only); throwaway container +
scratch config removed/shredded; no leftover containers or volumes.

## Update: image is now generic; sandbox accounts declared at deploy time Reworked so the image bakes **no** operator identity. `flake.nix` `fakeNss` ships only product-generic accounts (`root`, the default `evie`/974, `nobody`); the `weiwen`/uid-1000 account is gone from the image. Extra sandbox accounts a deployment's `users.toml` pins via `user=` are declared at deploy time through `EVIE_SANDBOX_USERS="name:uid:gid[,...]"`, which the root entrypoint appends to `/etc/passwd` + `/etc/group` (replacing fakeNss's read-only store symlinks with writable copies first) before dropping per session. Optional: a deployment using only the default `evie` sets nothing. Compose sample, README, sample config and the migration runbook updated to show `EVIE_SANDBOX_USERS=weiwen:1000:1000` alongside the `~/notes` mount-through. Fixes 2 and 3 from the cutover-ready work are unchanged (sudo/HTTP/cron credential bind; `response success:false` surfaced immediately). Sample model stays `deepseek-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-evie` streamer; loaded to `evie:latest`. - **Bare image is generic** (entrypoint overridden, no declaration; real glibc `getpwnam`): ``` root: uid=0 OK nobody: uid=65534 OK evie: uid=974 OK weiwen: getpwnam KeyError (absent) - OK, image is generic ``` - **Declared account resolves** — real entrypoint with `EVIE_SANDBOX_USERS=weiwen:1000:1000`, live `getpwnam`/`getgrnam` inside the running container (`passwd: files`): ``` getpwnam(weiwen) -> uid=1000 gid=1000 home=/var/lib/evie/homes shell=/noshell getgrnam(weiwen) -> gid=1000 getpwuid(1000) -> name=weiwen getpwnam(evie) -> uid=974 (baked default intact) ``` This is exactly what `run_as::resolve` (`User::from_name`/`Group::from_name`) needs to succeed at session-drop for a `user="weiwen"` session; without the declaration it `KeyError`s and the session fail-closes. Production safety: `evie.service` never touched (`active`, `NRestarts=0`, `ExecMainStartTimestamp` unchanged before/after); no live `/var/lib/evie` bind and no state copied this round; no real secrets used (dummy api-key only); throwaway container + scratch config removed/shredded; no leftover containers or volumes.
weiwen merged commit a3d6fd7ad5 into main 2026-07-31 22:09:21 +08:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
weiwen/evie!86
No description provided.