From b7ebbd93855378927c12a7c4f87132f6f5617799 Mon Sep 17 00:00:00 2001 From: Nikita Galaiko Date: Sat, 10 Oct 2026 12:04:05 +0000 Subject: [PATCH 1/2] Isolate backup credentials and pin the assistant runtime --- README.md | 42 ++++++- flake.lock | 60 +++++++++- flake.nix | 19 +++- hosts/exedev/users/amp.nix | 183 +++++++++++++++++++++++------- modules/exedev/backup.nix | 20 +++- modules/exedev/s6.nix | 40 ++++++- tests/assistant-runtime.nix | 55 +++++++++ tests/service-environment.nix | 39 +++++++ tests/test_service_environment.py | 42 +++++++ 9 files changed, 445 insertions(+), 55 deletions(-) create mode 100644 tests/assistant-runtime.nix create mode 100644 tests/service-environment.nix create mode 100644 tests/test_service_environment.py diff --git a/README.md b/README.md index 2052444..f8eb183 100644 --- a/README.md +++ b/README.md @@ -211,20 +211,45 @@ nodes; use an ephemeral key so retired ones auto-clean (see step 3). Amp and its coding tools are installed for the unprivileged `amp` user. The runner checks out into `~amp/assistant` (`/var/lib/amp/assistant`) and starts there, making that repository its default - project root. Existing files are preserved; a checkout conflict stops + project root. New checkouts use the assistant revision in `flake.lock`, not + the latest remote branch. Existing files are preserved; a checkout conflict stops startup rather than overwriting them. Subsequent starts do not reset or pull - the working tree. Amp config, credentials and project files are covered by + the working tree. Deployed tools and global skills use the locked input, + independently of checkout edits. Amp config, credentials and project files are covered by the amp home backup. The existing token and settings paths are unchanged; no - account migration is needed. The service retries every 30s until the env file + account migration is needed. User-owned shell settings are loaded as `amp`, + not root. The service retries every 30s until the env file exists. Add other checkouts with `amp runner dirs add ` as the `amp` user. + Runtime defaults are in `/etc/amp/runtime.sh`: timezone `Europe/Stockholm`, + memory `/var/lib/amp/memory`, vault `/home/nikita/Vault`, and external mail/SOPS + configuration paths. Optional private overrides live in + `/var/lib/amp/.config/assistant/runtime.sh`; runner threads and the packaged + `icloud-mail`, `icloud-calendar`, and `gh-app` launchers load them. Secrets, + keys, account configuration, and the GitHub App helper remain external and + must be restored or provisioned separately. The launchers do not create accounts. + + Boot and deployment seed `~amp/.config/amp/AGENTS.md` and `skills` as links + to `/etc/amp/AGENTS.md` and `/etc/amp/skills`. Amp therefore discovers runtime + instructions and pinned skills in every directory on this runner, not only + inside the assistant checkout. Existing files, directories, and links are + preserved. If a custom global `AGENTS.md` exists, add `Follow @/etc/amp/AGENTS.md`; + if a custom skills directory exists, link the desired `/etc/amp/skills/*` entries. + Private parent-directory instructions can override these defaults. Orbs and + other machines do not inherit this host's configuration or access. + + Update the deployed assistant with `nix flake update assistant`, review the + lock diff, run checks, and deploy through the normal workflow. Updating the + editable checkout is a separate operation; deployment never resets it. + The runner enables `--remote-control-terminal`, allowing terminal access from ampcode.com for its threads. Terminals run as the unprivileged `amp` user; this uses Amp's connection, not a public Caddy route. Chromium and `agent-browser` are installed for `amp`. The runner exports `AGENT_BROWSER_EXECUTABLE_PATH` pointing to the packaged Chromium wrapper, - so browser tools use it rather than downloading a separate browser. + and `FONTCONFIG_FILE` supplies pinned DejaVu fonts. Browser tools need no + separate browser download or host font installation. Amp can serve HTTP under `https://computer..ts.net/amp/` through its own Caddy on port 8084. As the `amp` user, edit `~/.caddy/Caddyfile` to @@ -368,6 +393,15 @@ no existing Vault, run `ob login`, `ob sync-list-remote`, and We have to store it outside of the machine to be able to restore everything else on startup. +Backup environment variables are removed from other service processes, including +loggers and oneshot hooks. Only `backup-restore` and `backup-cron` opt in through +`s6.services..passEnvironment`. This preserves non-secret container settings +and service-specific private env files. `/run/s6/container_environment` is root-only +on both boot paths and after activation; it must not be made readable to tenants. +This isolates the documented backup variables, not arbitrary credentials placed +in the container environment. Add other sensitive names to `s6.sensitiveEnvironment` +with explicit per-service opt-ins. Root still has access to the container credentials. + | Variable | Description | | ------------------- | ---------------------------------------- | | `RESTIC_REPOSITORY` | B2 restic repo, e.g. `b2:backups:exedev` | diff --git a/flake.lock b/flake.lock index 22ed5c8..a7e1666 100644 --- a/flake.lock +++ b/flake.lock @@ -1,5 +1,24 @@ { "nodes": { + "assistant": { + "inputs": { + "nixpkgs": "nixpkgs", + "nixpkgs-mail": "nixpkgs-mail" + }, + "locked": { + "lastModified": 1791631494, + "narHash": "sha256-xqqvb9vpkG8hG6zrbUoE6a6uaZPZq0c/nSuRAQCK2ec=", + "owner": "ngalaiko", + "repo": "assistant", + "rev": "f036ce9639952e1e61325e95520ff17d08044744", + "type": "github" + }, + "original": { + "owner": "ngalaiko", + "repo": "assistant", + "type": "github" + } + }, "brew-src": { "flake": false, "locked": { @@ -175,11 +194,27 @@ }, "nixpkgs": { "locked": { - "lastModified": 1791426274, - "narHash": "sha256-MWhktfFtFE8Jl7bf9Pkp7TR5MSdYCjCswSo0Gkb4ywc=", + "lastModified": 1791336361, + "narHash": "sha256-o9ZUW4SlEAvFPIyJWnqChhOT/VHpHg8TAvEgmqK1fSI=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "7c8764b7c7b09b34f632464276218ef9090eaa11", + "rev": "7dd199b0e2993e37b4775ed66b1c291699608c9f", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs-mail": { + "locked": { + "lastModified": 1791254437, + "narHash": "sha256-qMSsgG+9tIdx282dMqGuJAUK3t6F36clM6efd0rXfes=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "b25309931cfda5f0b8805f462a29897eeae50168", "type": "github" }, "original": { @@ -205,6 +240,22 @@ "type": "github" } }, + "nixpkgs_2": { + "locked": { + "lastModified": 1791426274, + "narHash": "sha256-MWhktfFtFE8Jl7bf9Pkp7TR5MSdYCjCswSo0Gkb4ywc=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "7c8764b7c7b09b34f632464276218ef9090eaa11", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-26.05", + "repo": "nixpkgs", + "type": "github" + } + }, "nixvim": { "inputs": { "flake-parts": "flake-parts", @@ -230,13 +281,14 @@ }, "root": { "inputs": { + "assistant": "assistant", "dash0-cli": "dash0-cli", "encore": "encore", "encore-agents-setup": "encore-agents-setup", "home-manager": "home-manager", "nix-darwin": "nix-darwin", "nix-homebrew": "nix-homebrew", - "nixpkgs": "nixpkgs", + "nixpkgs": "nixpkgs_2", "nixpkgs-unstable": "nixpkgs-unstable", "nixvim": "nixvim", "treefmt-nix": "treefmt-nix" diff --git a/flake.nix b/flake.nix index da37b7d..ae71a47 100644 --- a/flake.nix +++ b/flake.nix @@ -4,6 +4,7 @@ inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05"; nixpkgs-unstable.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; + assistant.url = "github:ngalaiko/assistant"; nix-darwin = { url = "github:nix-darwin/nix-darwin/nix-darwin-26.05"; inputs.nixpkgs.follows = "nixpkgs"; @@ -122,8 +123,20 @@ # `nix flake check` fails if the tree isn't treefmt-clean. CI relies on this # instead of a bespoke `nix fmt && git diff` step. - checks = lib.genAttrs allSystems (system: { - formatting = (treefmtFor system).config.build.check self; - }); + checks = lib.genAttrs allSystems ( + system: + { + formatting = (treefmtFor system).config.build.check self; + } + // lib.optionalAttrs (lib.elem system linuxSystems) { + service-environment = import ./tests/service-environment.nix { + pkgs = nixpkgs.legacyPackages.${system}; + }; + assistant-runtime = import ./tests/assistant-runtime.nix { + pkgs = nixpkgs.legacyPackages.${system}; + inherit inputs; + }; + } + ); }; } diff --git a/hosts/exedev/users/amp.nix b/hosts/exedev/users/amp.nix index 555dd88..84cd192 100644 --- a/hosts/exedev/users/amp.nix +++ b/hosts/exedev/users/amp.nix @@ -1,8 +1,75 @@ -{ pkgs, inputs, ... }: +{ + pkgs, + inputs, + lib, + ... +}: let home = "/var/lib/amp"; project = "${home}/assistant"; - agent-browser = import ../../../packages/agent-browser { inherit pkgs; }; + assistantTools = inputs.assistant.devShells.${pkgs.stdenv.hostPlatform.system}.default; + assistantPkgs = inputs.assistant.inputs.nixpkgs.legacyPackages.${pkgs.stdenv.hostPlatform.system}; + runtimeEnvironment = { + TZ = "Europe/Stockholm"; + ASSISTANT_MEMORY_DIR = "${home}/memory"; + OBSIDIAN_VAULT_PATH = "/home/nikita/Vault"; + ASSISTANT_SECRETS_FILE = "${home}/.config/assistant/secrets.json"; + SOPS_AGE_KEY_FILE = "${home}/.config/assistant/age-key.txt"; + HIMALAYA_CONFIG = "${home}/.config/himalaya/config.toml"; + AGENT_BROWSER_EXECUTABLE_PATH = assistantTools.AGENT_BROWSER_EXECUTABLE_PATH; + FONTCONFIG_FILE = assistantPkgs.makeFontsConf { + fontDirectories = [ assistantPkgs.dejavu_fonts ]; + }; + }; + runtimeSettings = pkgs.writeText "amp-runtime.sh" ( + lib.concatStrings ( + lib.mapAttrsToList (name: value: "export ${name}=${lib.escapeShellArg value}\n") runtimeEnvironment + ) + + '' + if [ -f ${home}/.config/assistant/runtime.sh ]; then + set -a + . ${home}/.config/assistant/runtime.sh + set +a + fi + '' + ); + mail = pkgs.writeShellScriptBin "icloud-mail" '' + set -eu + . ${runtimeSettings} + exec ${ + lib.getExe (lib.findFirst (p: p.pname or "" == "himalaya") null assistantTools.nativeBuildInputs) + } "$@" + ''; + calendar = pkgs.writeShellScriptBin "icloud-calendar" '' + set -eu + . ${runtimeSettings} + exec ${ + inputs.assistant.packages.${pkgs.stdenv.hostPlatform.system}.calendar + }/bin/assistant-calendar "$@" + ''; + github = pkgs.writeShellApplication { + name = "gh-app"; + runtimeInputs = [ + pkgs.python3 + pkgs.openssl + ]; + text = '' + # shellcheck source=/dev/null + . ${runtimeSettings} + exec python3 ${home}/.config/gh-app/auth.py "$@" + ''; + }; + setup = pkgs.writeShellScriptBin "amp-runtime-setup" '' + set -eu + umask 077 + mkdir -p "$HOME/.config/amp" "$HOME/memory" + for name in AGENTS.md skills; do + target=$HOME/.config/amp/$name + if [ ! -e "$target" ] && [ ! -L "$target" ]; then + ln -s /etc/amp/$name "$target" + fi + done + ''; # amp-cli is unfree; allow just it. unstable for a fresher build. amp = (import inputs.nixpkgs-unstable { @@ -11,6 +78,22 @@ let }).amp-cli; runner = pkgs.writeShellScript "assistant-amp-runner" '' set -eu + # Load user-owned configuration after dropping privileges. + envfile=${home}/.config/amp-runner/env + if [ ! -f "$envfile" ]; then + echo "amp-runner: $envfile missing; place AMP_API_KEY=... (see README). Retrying." >&2 + sleep 30 + exit 1 + fi + set -a + . "$envfile" + set +a + if [ -z "''${AMP_API_KEY:-}" ]; then + echo "amp-runner: AMP_API_KEY unset in $envfile. Retrying." >&2 + sleep 30 + exit 1 + fi + . ${runtimeSettings} mkdir -p ${project} cd ${project} # Preserve existing files and local edits; never force checkout. @@ -23,10 +106,8 @@ let *) echo "amp-runner: ${project} is not the assistant repository; refusing to start." >&2; exit 1 ;; esac if ! git rev-parse --verify HEAD >/dev/null 2>&1; then - git fetch origin - git remote set-head origin --auto - branch=$(git symbolic-ref --short refs/remotes/origin/HEAD) - git checkout -b "''${branch#origin/}" --track origin/HEAD + git fetch origin ${inputs.assistant.rev} + git checkout -b main FETCH_HEAD fi # Amp uses its starting directory as the default project root. exec ${amp}/bin/amp --no-tui --runner-id computer --remote-control-terminal @@ -43,23 +124,54 @@ in createHome = true; shell = "/bin/sh"; description = "Amp runner"; - packages = with pkgs; [ - amp - agent-browser - chromium - nodejs_22 - git - jujutsu - gh - jq - ripgrep - fd - curl - coreutils - ]; + environment = lib.mapAttrs (_: toString) runtimeEnvironment; + packages = + assistantTools.nativeBuildInputs + ++ (with pkgs; [ + amp + assistantPkgs.chromium + mail + calendar + github + setup + nodejs_22 + git + jujutsu + fd + ]); }; users.groups.amp.gid = 2002; + environment.etc."amp/runtime.sh".source = runtimeSettings; + environment.etc."amp/AGENTS.md".text = '' + # Assistant runtime on computer + + Follow @${inputs.assistant}/AGENTS.md and each repository's instructions. + Skills are deployed from the same locked assistant revision. + + - User timezone: Europe/Stockholm; the host clock is UTC. + - Assistant memory: ${home}/memory. Read relevant notes before asking for + information already recorded. The shared Obsidian vault is /home/nikita/Vault; + it is distinct from assistant memory. Sync and backups are infrastructure. + - Runtime defaults: /etc/amp/runtime.sh. Private overrides: + ${home}/.config/assistant/runtime.sh. Source the defaults to discover overridden + paths; runner threads inherit them automatically. + - Mail: /etc/profiles/per-user/amp/bin/icloud-mail (Himalaya arguments). + - Calendar: /etc/profiles/per-user/amp/bin/icloud-calendar (assistant-calendar arguments). + - GitHub: /etc/profiles/per-user/amp/bin/gh-app (gh arguments). Authentication + uses the existing private ${home}/.config/gh-app/auth.py and GitHub App key. + - Tools on PATH are deployment-pinned, independent of edits in ${project}. + + Never print credentials, keys, tokens, or whole environment/configuration dumps. + Access does not authorize sending, changing events, pushing, or deploying. + ''; + image.rootPaths = [ + (pkgs.runCommand "amp-skills" { } '' + mkdir -p $out/etc/amp + ln -s ${inputs.assistant}/.agents/skills $out/etc/amp/skills + '') + ]; + # /amp/* on the public port. The ingress module puts caddy on Amp's PATH # and seeds a user-owned ~/.caddy/Caddyfile, reloadable without root. services.ingress.tenants.amp = { @@ -69,30 +181,26 @@ in # checkouts, amp config, and the runner env file all live under the home. services.backup.paths = [ home ]; + s6.services.amp-runtime-setup = { + type = "oneshot"; + reactivate = true; + dependencies = [ + "base" + "backup-restore" + ]; + run = '' + exec /command/s6-setuidgid amp env HOME=${home} ${setup}/bin/amp-runtime-setup + ''; + }; + s6.services.amp-runner = { dependencies = [ "base" # wait for the restore so the env file and checkouts are present. "backup-restore" + "amp-runtime-setup" ]; run = '' - # AMP_API_KEY stays out of the repo and the Nix store: it lives in a - # runtime env file under the backed-up home (see README), exported so it - # never appears in argv. - envfile=${home}/.config/amp-runner/env - if [ ! -f "$envfile" ]; then - echo "amp-runner: $envfile missing; place AMP_API_KEY=... (see README). Retrying." >&2 - sleep 30 - exit 1 - fi - set -a - . "$envfile" - set +a - if [ -z "''${AMP_API_KEY:-}" ]; then - echo "amp-runner: AMP_API_KEY unset in $envfile. Retrying." >&2 - sleep 30 - exit 1 - fi exec /command/s6-setuidgid amp \ env \ HOME=${home} \ @@ -102,7 +210,6 @@ in SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt \ NIX_SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt \ NODE_EXTRA_CA_CERTS=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt \ - AGENT_BROWSER_EXECUTABLE_PATH=${pkgs.chromium}/bin/chromium \ ${runner} ''; }; diff --git a/modules/exedev/backup.nix b/modules/exedev/backup.nix index 38ee131..8fc6db5 100644 --- a/modules/exedev/backup.nix +++ b/modules/exedev/backup.nix @@ -8,6 +8,13 @@ let inherit (lib) mkOption types; cfg = config.services.backup; restic = "${pkgs.restic}/bin/restic"; + backupEnvironment = [ + "RESTIC_REPOSITORY" + "RESTIC_PASSWORD" + "RESTIC_CACHE_DIR" + "B2_ACCOUNT_ID" + "B2_ACCOUNT_KEY" + ]; # Every restic op is scoped to this machine's host. Without it, a repo shared # with another machine breaks two ways: `restore latest` grabs whichever box # wrote the globally-newest snapshot (so a foreign snapshot silently replaces @@ -168,6 +175,7 @@ in }; config = lib.mkIf cfg.enable { + s6.sensitiveEnvironment = backupEnvironment; image.packages = [ pkgs.restic pkgs.supercronic @@ -175,15 +183,19 @@ in s6.services.backup-restore = { type = "oneshot"; + passEnvironment = backupEnvironment; run = '' mkdir -p /var/cache/restic timeout 300 ${restoreScript} || echo "restore: timed out — continuing." >&2 ''; }; - s6.services.backup-cron.run = '' - mkdir -p /var/cache/restic - exec ${pkgs.supercronic}/bin/supercronic ${crontab} - ''; + s6.services.backup-cron = { + passEnvironment = backupEnvironment; + run = '' + mkdir -p /var/cache/restic + exec ${pkgs.supercronic}/bin/supercronic ${crontab} + ''; + }; }; } diff --git a/modules/exedev/s6.nix b/modules/exedev/s6.nix index 2e412b9..9907539 100644 --- a/modules/exedev/s6.nix +++ b/modules/exedev/s6.nix @@ -12,12 +12,20 @@ let longruns = lib.filterAttrs (_: s: s.type == "longrun") enabledServices; oneshots = lib.filterAttrs (_: s: s.type == "oneshot") enabledServices; + scrubEnvironment = + svc: + let + names = lib.subtractLists svc.passEnvironment cfg.sensitiveEnvironment; + in + lib.optionalString (names != [ ]) "unset ${lib.escapeShellArgs names}"; + # longrun stdout+stderr flow to its logger. runFile = name: svc: pkgs.writeScript "s6-${name}-run" '' #!/command/with-contenv sh exec 2>&1 + ${scrubEnvironment svc} ${svc.run} ''; # oneshots have no logger pipe; self-redirect to the same log path. @@ -25,6 +33,7 @@ let name: svc: pkgs.writeScript "s6-${name}-up" '' #!/bin/sh + ${scrubEnvironment svc} mkdir -p ${cfg.logDir}/${name} exec >>${cfg.logDir}/${name}/current 2>&1 ${svc.run} @@ -33,6 +42,7 @@ let name: svc: pkgs.writeScript "s6-${name}-down" '' #!/bin/sh + ${scrubEnvironment svc} mkdir -p ${cfg.logDir}/${name} exec >>${cfg.logDir}/${name}/current 2>&1 ${svc.down} @@ -41,6 +51,7 @@ let name: pkgs.writeScript "s6-${name}-log" '' #!/bin/sh + ${scrubEnvironment { passEnvironment = [ ]; }} mkdir -p ${cfg.logDir}/${name} exec ${cfg.package}/command/s6-log -b T n${toString cfg.logKeep} s${toString cfg.logSize} ${cfg.logDir}/${name} ''; @@ -49,6 +60,7 @@ let name: svc: pkgs.writeScript "s6-${name}-finish" '' #!/bin/sh + ${scrubEnvironment svc} exec >>${cfg.logDir}/${name}/current 2>&1 ${svc.finish} ''; @@ -155,7 +167,8 @@ let fi mkdir -p /run/s6 - /command/s6-dumpenv /run/s6/container_environment + (umask 077; /command/s6-dumpenv /run/s6/container_environment) + chmod 700 /run/s6/container_environment # exe.dev's minimal /dev omits /dev/fd etc.; provide the conventional symlinks # so bash process substitution (and tools that open /dev/fd/N) work — e.g. @@ -205,6 +218,12 @@ in description = "Unpacked s6-overlay tree (/init, /command, /package)."; }; + sensitiveEnvironment = mkOption { + type = types.listOf (types.strMatching "[A-Za-z_][A-Za-z0-9_]*"); + default = [ ]; + description = "Container environment names removed from service processes unless explicitly listed in passEnvironment."; + }; + logDir = mkOption { type = types.str; default = "/var/log"; @@ -260,7 +279,12 @@ in }; run = mkOption { type = types.lines; - description = "Shell body, run with the container env (with-contenv)."; + description = "Shell body, run with the container env except unapproved sensitiveEnvironment names."; + }; + passEnvironment = mkOption { + type = types.listOf (types.strMatching "[A-Za-z_][A-Za-z0-9_]*"); + default = [ ]; + description = "Sensitive container environment names this service may inherit."; }; down = mkOption { type = types.lines; @@ -298,6 +322,18 @@ in config = { image.cmd = lib.mkDefault [ "/init-wrapper" ]; + image.activationFixups = '' + if [ -d "$root/run/s6/container_environment" ]; then + chmod 700 "$root/run/s6/container_environment" + fi + ''; + environment.etc."cont-init.d/00-environment-permissions" = { + mode = "0755"; + text = '' + #!/bin/sh + chmod 700 /run/s6/container_environment + ''; + }; image.rootPaths = [ cfg.package serviceGraph diff --git a/tests/assistant-runtime.nix b/tests/assistant-runtime.nix new file mode 100644 index 0000000..cffa8cb --- /dev/null +++ b/tests/assistant-runtime.nix @@ -0,0 +1,55 @@ +{ pkgs, inputs }: +let + config = + (import ../modules/exedev { + inherit pkgs; + specialArgs = { inherit inputs; }; + }).eval + ../hosts/exedev; + profile = pkgs.buildEnv { + name = "amp-runtime-check-profile"; + paths = config.users.users.amp.packages; + pathsToLink = [ "/bin" ]; + }; +in +pkgs.runCommand "assistant-runtime-check" + { + nativeBuildInputs = [ + profile + pkgs.bash + ]; + } + '' + set -euo pipefail + export HOME="$TMPDIR/home" + amp-runtime-setup + test "$(readlink "$HOME/.config/amp/AGENTS.md")" = /etc/amp/AGENTS.md + test "$(readlink "$HOME/.config/amp/skills")" = /etc/amp/skills + test "$(stat -c %a "$HOME/memory")" = 700 + # Custom instructions and skill directories survive repeated setup. + rm "$HOME/.config/amp/AGENTS.md" "$HOME/.config/amp/skills" + printf 'private instructions\n' > "$HOME/.config/amp/AGENTS.md" + mkdir "$HOME/.config/amp/skills" + touch "$HOME/.config/amp/skills/custom" + amp-runtime-setup + test "$(cat "$HOME/.config/amp/AGENTS.md")" = 'private instructions' + test -f "$HOME/.config/amp/skills/custom" + + . ${config.environment.etc."amp/runtime.sh".source} + test "$TZ" = Europe/Stockholm + test "$ASSISTANT_MEMORY_DIR" = /var/lib/amp/memory + test "$OBSIDIAN_VAULT_PATH" = /home/nikita/Vault + himalaya --version | grep -q 'himalaya v1.2.0 ' + icloud-mail --version | grep -q 'himalaya v1.2.0 ' + icloud-calendar --help >/dev/null + sops --version >/dev/null + age --version >/dev/null + bash -n "$(command -v gh-app)" + + export AGENT_BROWSER_SESSION="runtime-check-$$" + trap 'agent-browser close >/dev/null' EXIT + agent-browser open 'data:text/html,Runtime ready

Pinned browser works

' + test "$(agent-browser get title)" = 'Runtime ready' + test "$(agent-browser get text body)" = 'Pinned browser works' + touch "$out" + '' diff --git a/tests/service-environment.nix b/tests/service-environment.nix new file mode 100644 index 0000000..56559c2 --- /dev/null +++ b/tests/service-environment.nix @@ -0,0 +1,39 @@ +{ pkgs }: +let + probe = '' + exec ${pkgs.python3}/bin/python -c 'import json, os; print(json.dumps({k: v for k, v in os.environ.items() if k.startswith(("RESTIC_", "B2_", "TEST_"))}))' + ''; + config = (import ../modules/exedev { inherit pkgs; }).eval { + nix.enable = false; + services.backup.enable = true; + s6.logDir = "$TMPDIR/service-logs"; + s6.services = { + probe = { + run = probe; + finish = probe; + }; + probe-once = { + type = "oneshot"; + run = probe; + }; + backup-cron.run = pkgs.lib.mkForce probe; + backup-restore.run = pkgs.lib.mkForce probe; + }; + }; +in +pkgs.runCommand "service-environment-check" + { + nativeBuildInputs = [ + pkgs.python3 + pkgs.bash + ]; + } + '' + root="$TMPDIR/root" + mkdir -p "$root/run/s6/container_environment" + chmod 755 "$root/run/s6/container_environment" + ${config.image.activationFixups} + test "$(stat -c %a "$root/run/s6/container_environment")" = 700 + python ${./test_service_environment.py} ${config.s6.build.serviceTree} ${config.s6.build.oneshots} + touch "$out" + '' diff --git a/tests/test_service_environment.py b/tests/test_service_environment.py new file mode 100644 index 0000000..e53593a --- /dev/null +++ b/tests/test_service_environment.py @@ -0,0 +1,42 @@ +import json +import os +from pathlib import Path +import subprocess +import sys + + +services, oneshots = map(Path, sys.argv[1:]) +credentials = { + "RESTIC_REPOSITORY": "b2:dummy:backup", + "RESTIC_PASSWORD": "dummy backup password", + "B2_ACCOUNT_ID": "dummy account", + "B2_ACCOUNT_KEY": "dummy key", + "RESTIC_CACHE_DIR": "/tmp/dummy cache", +} +environment = os.environ | credentials | {"TEST_NON_SECRET": "safe value"} +safe = {"TEST_NON_SECRET": "safe value"} + + +def run(script): + result = subprocess.run( + ["bash", str(script)], env=environment, text=True, capture_output=True + ) + assert result.returncode == 0, result.stderr + return result.stdout + + +assert json.loads(run(services / "probe/run")) == safe +log = Path(os.environ["TMPDIR"]) / "service-logs/probe/current" +log.parent.mkdir(parents=True) +run(services / "probe/finish") +assert json.loads(log.read_text()) == safe + +assert json.loads(run(services / "backup-cron/run")) == credentials | safe + +for name, expected in (("probe-once", safe), ("backup-restore", credentials | safe)): + script = next(oneshots.glob(f"*-{name}")) + run(script) + log = Path(os.environ["TMPDIR"]) / "service-logs" / name / "current" + assert json.loads(log.read_text()) == expected, name + +print("Service credential isolation passed") From 59e58d080f536339096332d15c2dd30935553f4d Mon Sep 17 00:00:00 2001 From: Nikita Galaiko Date: Sat, 10 Oct 2026 14:58:55 +0000 Subject: [PATCH 2/2] Drop added runtime tests and retain formatting checks --- flake.nix | 18 ++-------- tests/assistant-runtime.nix | 55 ------------------------------- tests/service-environment.nix | 39 ---------------------- tests/test_service_environment.py | 42 ----------------------- 4 files changed, 3 insertions(+), 151 deletions(-) delete mode 100644 tests/assistant-runtime.nix delete mode 100644 tests/service-environment.nix delete mode 100644 tests/test_service_environment.py diff --git a/flake.nix b/flake.nix index ae71a47..b508dc0 100644 --- a/flake.nix +++ b/flake.nix @@ -123,20 +123,8 @@ # `nix flake check` fails if the tree isn't treefmt-clean. CI relies on this # instead of a bespoke `nix fmt && git diff` step. - checks = lib.genAttrs allSystems ( - system: - { - formatting = (treefmtFor system).config.build.check self; - } - // lib.optionalAttrs (lib.elem system linuxSystems) { - service-environment = import ./tests/service-environment.nix { - pkgs = nixpkgs.legacyPackages.${system}; - }; - assistant-runtime = import ./tests/assistant-runtime.nix { - pkgs = nixpkgs.legacyPackages.${system}; - inherit inputs; - }; - } - ); + checks = lib.genAttrs allSystems (system: { + formatting = (treefmtFor system).config.build.check self; + }); }; } diff --git a/tests/assistant-runtime.nix b/tests/assistant-runtime.nix deleted file mode 100644 index cffa8cb..0000000 --- a/tests/assistant-runtime.nix +++ /dev/null @@ -1,55 +0,0 @@ -{ pkgs, inputs }: -let - config = - (import ../modules/exedev { - inherit pkgs; - specialArgs = { inherit inputs; }; - }).eval - ../hosts/exedev; - profile = pkgs.buildEnv { - name = "amp-runtime-check-profile"; - paths = config.users.users.amp.packages; - pathsToLink = [ "/bin" ]; - }; -in -pkgs.runCommand "assistant-runtime-check" - { - nativeBuildInputs = [ - profile - pkgs.bash - ]; - } - '' - set -euo pipefail - export HOME="$TMPDIR/home" - amp-runtime-setup - test "$(readlink "$HOME/.config/amp/AGENTS.md")" = /etc/amp/AGENTS.md - test "$(readlink "$HOME/.config/amp/skills")" = /etc/amp/skills - test "$(stat -c %a "$HOME/memory")" = 700 - # Custom instructions and skill directories survive repeated setup. - rm "$HOME/.config/amp/AGENTS.md" "$HOME/.config/amp/skills" - printf 'private instructions\n' > "$HOME/.config/amp/AGENTS.md" - mkdir "$HOME/.config/amp/skills" - touch "$HOME/.config/amp/skills/custom" - amp-runtime-setup - test "$(cat "$HOME/.config/amp/AGENTS.md")" = 'private instructions' - test -f "$HOME/.config/amp/skills/custom" - - . ${config.environment.etc."amp/runtime.sh".source} - test "$TZ" = Europe/Stockholm - test "$ASSISTANT_MEMORY_DIR" = /var/lib/amp/memory - test "$OBSIDIAN_VAULT_PATH" = /home/nikita/Vault - himalaya --version | grep -q 'himalaya v1.2.0 ' - icloud-mail --version | grep -q 'himalaya v1.2.0 ' - icloud-calendar --help >/dev/null - sops --version >/dev/null - age --version >/dev/null - bash -n "$(command -v gh-app)" - - export AGENT_BROWSER_SESSION="runtime-check-$$" - trap 'agent-browser close >/dev/null' EXIT - agent-browser open 'data:text/html,Runtime ready

Pinned browser works

' - test "$(agent-browser get title)" = 'Runtime ready' - test "$(agent-browser get text body)" = 'Pinned browser works' - touch "$out" - '' diff --git a/tests/service-environment.nix b/tests/service-environment.nix deleted file mode 100644 index 56559c2..0000000 --- a/tests/service-environment.nix +++ /dev/null @@ -1,39 +0,0 @@ -{ pkgs }: -let - probe = '' - exec ${pkgs.python3}/bin/python -c 'import json, os; print(json.dumps({k: v for k, v in os.environ.items() if k.startswith(("RESTIC_", "B2_", "TEST_"))}))' - ''; - config = (import ../modules/exedev { inherit pkgs; }).eval { - nix.enable = false; - services.backup.enable = true; - s6.logDir = "$TMPDIR/service-logs"; - s6.services = { - probe = { - run = probe; - finish = probe; - }; - probe-once = { - type = "oneshot"; - run = probe; - }; - backup-cron.run = pkgs.lib.mkForce probe; - backup-restore.run = pkgs.lib.mkForce probe; - }; - }; -in -pkgs.runCommand "service-environment-check" - { - nativeBuildInputs = [ - pkgs.python3 - pkgs.bash - ]; - } - '' - root="$TMPDIR/root" - mkdir -p "$root/run/s6/container_environment" - chmod 755 "$root/run/s6/container_environment" - ${config.image.activationFixups} - test "$(stat -c %a "$root/run/s6/container_environment")" = 700 - python ${./test_service_environment.py} ${config.s6.build.serviceTree} ${config.s6.build.oneshots} - touch "$out" - '' diff --git a/tests/test_service_environment.py b/tests/test_service_environment.py deleted file mode 100644 index e53593a..0000000 --- a/tests/test_service_environment.py +++ /dev/null @@ -1,42 +0,0 @@ -import json -import os -from pathlib import Path -import subprocess -import sys - - -services, oneshots = map(Path, sys.argv[1:]) -credentials = { - "RESTIC_REPOSITORY": "b2:dummy:backup", - "RESTIC_PASSWORD": "dummy backup password", - "B2_ACCOUNT_ID": "dummy account", - "B2_ACCOUNT_KEY": "dummy key", - "RESTIC_CACHE_DIR": "/tmp/dummy cache", -} -environment = os.environ | credentials | {"TEST_NON_SECRET": "safe value"} -safe = {"TEST_NON_SECRET": "safe value"} - - -def run(script): - result = subprocess.run( - ["bash", str(script)], env=environment, text=True, capture_output=True - ) - assert result.returncode == 0, result.stderr - return result.stdout - - -assert json.loads(run(services / "probe/run")) == safe -log = Path(os.environ["TMPDIR"]) / "service-logs/probe/current" -log.parent.mkdir(parents=True) -run(services / "probe/finish") -assert json.loads(log.read_text()) == safe - -assert json.loads(run(services / "backup-cron/run")) == credentials | safe - -for name, expected in (("probe-once", safe), ("backup-restore", credentials | safe)): - script = next(oneshots.glob(f"*-{name}")) - run(script) - log = Path(os.environ["TMPDIR"]) / "service-logs" / name / "current" - assert json.loads(log.read_text()) == expected, name - -print("Service credential isolation passed")