mirror of
https://github.com/akitaonrails/ai-memory.git
synced 2026-10-02 03:24:46 +08:00
Merge pull request #989 from rubenlr/nixos-module
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
# Nix packaging and module maintenance.
|
||||
/nix/ @rubenlr
|
||||
/flake.nix @rubenlr
|
||||
/flake.lock @rubenlr
|
||||
/.github/workflows/nix.yml @rubenlr
|
||||
/scripts/check-nix-packaging.sh @rubenlr
|
||||
/scripts/test-nixos-systemd-container.sh @rubenlr
|
||||
@@ -1,6 +1,8 @@
|
||||
name: nix
|
||||
|
||||
# Builds the flake so the Nix packaging cannot rot unnoticed.
|
||||
# Builds the flake so the Nix packaging cannot rot unnoticed. Linux package
|
||||
# and module evaluation are the fast path. Darwin and the privileged NixOS
|
||||
# container smoke run only on schedule, manual dispatch, or a `full-ci` PR.
|
||||
#
|
||||
# ai-memory does not otherwise test on Nix, so without this job `flake.nix`
|
||||
# is source nobody executes: it can break through a dependency bump, a
|
||||
@@ -14,19 +16,26 @@ name: nix
|
||||
# demand.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
branches: [main, release/2.6]
|
||||
paths:
|
||||
- 'flake.nix'
|
||||
- 'nix/**'
|
||||
- 'Cargo.lock'
|
||||
- 'Cargo.toml'
|
||||
- 'rust-toolchain.toml'
|
||||
- 'scripts/check-nix-packaging.sh'
|
||||
- 'scripts/test-nixos-systemd-container.sh'
|
||||
- '.github/workflows/nix.yml'
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, labeled]
|
||||
paths:
|
||||
- 'flake.nix'
|
||||
- 'nix/**'
|
||||
- 'Cargo.lock'
|
||||
- 'Cargo.toml'
|
||||
- 'rust-toolchain.toml'
|
||||
- 'scripts/check-nix-packaging.sh'
|
||||
- 'scripts/test-nixos-systemd-container.sh'
|
||||
- '.github/workflows/nix.yml'
|
||||
schedule:
|
||||
# Mondays 05:17 UTC. Off the hour so it does not pile onto the
|
||||
@@ -38,8 +47,8 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: nix build
|
||||
build-linux:
|
||||
name: nix build (x86_64-linux)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
@@ -49,7 +58,43 @@ jobs:
|
||||
experimental-features = nix-command flakes
|
||||
# `nix flake check` would also evaluate every output; the build is the
|
||||
# claim worth verifying, and it is the expensive half anyway.
|
||||
- run: nix build --print-build-logs
|
||||
# The binary is what the flake promises. Run it, so a package that
|
||||
# builds but cannot execute still fails.
|
||||
- run: ./result/bin/ai-memory --version
|
||||
- run: nix build --print-build-logs '.#packages.x86_64-linux.default'
|
||||
- run: scripts/check-nix-packaging.sh ./result
|
||||
# Eval-only smoke tests for the NixOS module (nixosModules.default):
|
||||
# proves the option wiring evaluates cleanly for both enable = true
|
||||
# and enable = false on a real Linux system, without building a full
|
||||
# NixOS system closure. See nix/nixos-module.nix and the assertions
|
||||
# in flake.nix's checks.x86_64-linux.
|
||||
- run: nix build --print-build-logs '.#checks.x86_64-linux.nixos-module-eval'
|
||||
# One runtime path: packages.x86_64-linux.nixos-ai-memory-docker builds
|
||||
# system.build.toplevel once (via the docker-image tarball) and the
|
||||
# script imports that rootfs, starts systemd, and checks install +
|
||||
# /healthz + dataDir restart/volume persistence.
|
||||
#
|
||||
# Non-goals for this step (do not expand here):
|
||||
# - A→B package upgrade / SQLite-wiki migration matrix
|
||||
# - Exhaustive settings-options.nix key matrix
|
||||
# - Soft fake-systemd without a real unit start
|
||||
# - Claiming the app Docker image covers the NixOS module
|
||||
# - Darwin / multi-arch NixOS OCI (Linux x86_64 only by design)
|
||||
- if: >-
|
||||
github.event_name == 'schedule' ||
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'full-ci'))
|
||||
run: scripts/test-nixos-systemd-container.sh
|
||||
|
||||
build-darwin:
|
||||
name: nix build (aarch64-darwin)
|
||||
if: >-
|
||||
github.event_name == 'schedule' ||
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'full-ci'))
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
|
||||
with:
|
||||
extra_nix_config: |
|
||||
experimental-features = nix-command flakes
|
||||
- run: nix build --print-build-logs '.#packages.aarch64-darwin.default'
|
||||
- run: scripts/check-nix-packaging.sh ./result
|
||||
|
||||
@@ -310,7 +310,11 @@ no tiers.
|
||||
"$HOME\.rustup"`.
|
||||
|
||||
- Shell-level checks: `tests/hooks/test_lib.sh`,
|
||||
`tests/e2e/handoff_smoke.sh`, `scripts/check-native-packaging.sh`.
|
||||
`tests/e2e/handoff_smoke.sh`, `scripts/check-native-packaging.sh`,
|
||||
`scripts/check-nix-packaging.sh` (Nix flake output).
|
||||
- `.github/workflows/nix.yml` runs the Linux package and NixOS module eval for
|
||||
affected changes. Darwin and the privileged systemd-container smoke run on
|
||||
schedule, manual dispatch, or a `full-ci` pull request.
|
||||
- CI additionally runs `cargo build --release --bin ai-memory` on
|
||||
Linux/macOS, a Docker image smoke test, `cargo audit` (with the ignores
|
||||
listed in `ci.yml`), and differential gitleaks scanning.
|
||||
|
||||
+8
-1
@@ -8,6 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- Added a NixOS module exposed as `nixosModules.default`, with a dedicated
|
||||
non-login service user, a hardened systemd unit, declarative non-secret
|
||||
settings, agenix/sops/environment-file secret sources, and opt-in web,
|
||||
firewall, and resource settings. Non-loopback binds require exactly one
|
||||
secret source; expensive Darwin and privileged container checks run only in
|
||||
the full CI tier. (#989)
|
||||
- Added `ai-memory doctor` reporting for Claude Code's default native
|
||||
`memory/` store for the current repository: location, file count, and whether
|
||||
the nearest marker's `ignore_paths` would exclude a read. Repository-root
|
||||
@@ -50,6 +56,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
### Changed
|
||||
- Documented FutureInfra as an endpoint for the existing `openai-compat`
|
||||
provider. (#1026)
|
||||
|
||||
### Fixed
|
||||
- Fixed completed retries and no-op session endings advancing
|
||||
`last_persisted_ms` without a durable write. Recovery still advances the
|
||||
@@ -74,6 +81,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
ai-jail's own dry-run preflight, so a backend that exists but fails ai-jail's
|
||||
trust checks is treated as unavailable instead of producing a broken offer.
|
||||
(#1024)
|
||||
|
||||
## [2.5.2] - 2026-10-01
|
||||
|
||||
### Added
|
||||
@@ -190,7 +198,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
OpenCode/OMP/Pi/OpenClaw TypeScript integrations
|
||||
(`ai-memory-cli` `render_shared.rs`); a genuine Windows/UNC host's UNC
|
||||
candidates are unaffected.
|
||||
|
||||
## [2.5.0] - 2026-09-30
|
||||
|
||||
### Added
|
||||
|
||||
@@ -106,6 +106,7 @@ caveats is in [`docs/support-matrix.md`](docs/support-matrix.md).
|
||||
| Area | Status |
|
||||
| --- | --- |
|
||||
| Linux | Supported |
|
||||
| NixOS module | Supported |
|
||||
| macOS | Supported |
|
||||
| Windows via WSL2 | Supported |
|
||||
| Native Windows | Experimental |
|
||||
@@ -369,6 +370,62 @@ record, so the next managed launch wires that harness again; to keep it
|
||||
unwired, launch with `--no-autowire` or set `AI_MEMORY_RUN_AUTOWIRE=false`. Install commands are idempotent and write
|
||||
timestamped backups next to any file they touch.
|
||||
|
||||
### NixOS
|
||||
|
||||
This flake ships a NixOS module (`nixosModules.default`) with a
|
||||
`systemd.services.ai-memory` unit. It creates a dedicated `ai-memory` system
|
||||
user (`nologin`, no linger) and applies a NixOS-specific systemd sandbox
|
||||
(`ProtectSystem = "strict"`, empty capability sets,
|
||||
`MemoryDenyWriteExecute`, `RestrictAddressFamilies`, and the rest — see
|
||||
[`nix/systemd-sandbox.nix`](nix/systemd-sandbox.nix)).
|
||||
|
||||
```nix
|
||||
{
|
||||
inputs.ai-memory.url = "github:akitaonrails/ai-memory";
|
||||
|
||||
outputs = { nixpkgs, ai-memory, ... }: {
|
||||
nixosConfigurations.myhost = nixpkgs.lib.nixosSystem {
|
||||
system = "x86_64-linux";
|
||||
modules = [
|
||||
ai-memory.nixosModules.default
|
||||
{
|
||||
services.ai-memory = {
|
||||
enable = true;
|
||||
# enableWeb = true; # off by default — the web UI is opt-in
|
||||
settings = {
|
||||
allowed_hosts = [ "localhost" "127.0.0.1" "::1" "homelab.example" ];
|
||||
log_level = "info";
|
||||
};
|
||||
# Loopback (default): secrets optional; missing env file is tolerated.
|
||||
# Non-loopback: set one of ageSecret, sopsSecret, or environmentFile.
|
||||
# ageSecret = "ai-memory-env"; # config.age.secrets.<name> (agenix)
|
||||
# sopsSecret = "ai-memory/env"; # config.sops.secrets.<name> (sops-nix)
|
||||
};
|
||||
}
|
||||
];
|
||||
};
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
Declarative non-secret config lives in `services.ai-memory.settings`. Common
|
||||
keys are typed for discoverability and the TOML freeform type keeps every
|
||||
other current and future `config.toml` key usable without duplicating the Rust
|
||||
schema. The module renders a generated TOML file and passes `--config`;
|
||||
network and web-listener controls stay in the module's top-level options.
|
||||
|
||||
Secrets such as `AI_MEMORY_AUTH_TOKEN` never go in `settings` or the
|
||||
world-readable Nix store. This includes `llm_headers`, which can carry API
|
||||
credentials; set `AI_MEMORY_LLM_HEADERS` in `ageSecret`, `sopsSecret`, or
|
||||
`environmentFile` instead. These three secret sources are mutually exclusive.
|
||||
Non-loopback binds require one; loopback may omit them and tolerates a missing
|
||||
environment file (systemd `EnvironmentFile=-…`). Put TLS in front of a LAN/WAN
|
||||
bind — see [`docs/https-via-proxy.md`](docs/https-via-proxy.md).
|
||||
|
||||
All options and defaults are in [`nix/nixos-module.nix`](nix/nixos-module.nix).
|
||||
Entirely opt-in — `nix build`, `nix run`, `nix develop`, and the CLI are
|
||||
unchanged if you don't import it.
|
||||
|
||||
## Everyday use
|
||||
|
||||
Day to day, you mostly do not think about ai-memory. Hooks capture
|
||||
|
||||
@@ -11,6 +11,8 @@ page covers everything else:
|
||||
(systemd system service or user service)
|
||||
- [Arch Linux native packages (AUR)](#arch-linux-native-packages-aur)
|
||||
(systemd system service or user service)
|
||||
- [Nix / NixOS](#nix--nixos)
|
||||
(flake package, NixOS module, maintainer test ladder)
|
||||
- [macOS menu bar app](#macos-menu-bar-app)
|
||||
(self-contained `.app` + LaunchAgent)
|
||||
- [Configuring other agent CLIs](#configuring-other-agent-clis)
|
||||
@@ -738,6 +740,61 @@ AI_MEMORY_NATIVE_TEST_IMAGE=quay.io/toolbx/arch-toolbox:latest scripts/test-nati
|
||||
|
||||
---
|
||||
|
||||
## Nix / NixOS
|
||||
|
||||
User-facing NixOS module setup lives in the [README NixOS
|
||||
section](../README.md#nixos) (`nixosModules.default`,
|
||||
`services.ai-memory.enable`). This section is the maintainer test ladder
|
||||
for the flake and module — what CI runs, and what each tier proves.
|
||||
|
||||
### Maintainer test ladder
|
||||
|
||||
1. **Package smoke** — `nix build .#packages.<system>.default` then
|
||||
`scripts/check-nix-packaging.sh ./result` (binary `--version`, hooks
|
||||
tree, config template, `nix run`). Linux runs on affected pushes and pull
|
||||
requests. Darwin runs on schedule, manual dispatch, or a `full-ci` PR.
|
||||
2. **Eval contracts** — `nix build .#checks.x86_64-linux.nixos-module-eval`
|
||||
runs cheap Linux-only assertions for enable/bind/`--config`/secrets wiring,
|
||||
literal loopback handling, IPv6 address formatting, mutually exclusive
|
||||
secret sources, secrets in `settings.auth`, forbidden `settings.bind`,
|
||||
escaped `ExecStart`, default `StateDirectory`, custom `dataDir` tmpfiles,
|
||||
`ReadWritePaths`, and the NixOS sandbox keys.
|
||||
3. **Toplevel → OCI → container smoke** — one closure path. Building
|
||||
`packages.x86_64-linux.nixos-ai-memory-docker` builds
|
||||
`system.build.toplevel` once (via the nixpkgs docker-image tarball);
|
||||
there is no separate bare-toplevel CI job. Then:
|
||||
|
||||
```bash
|
||||
scripts/test-nixos-systemd-container.sh
|
||||
```
|
||||
|
||||
That script imports the rootfs, runs a privileged systemd container,
|
||||
checks `systemctl is-active ai-memory` and in-container `curl /healthz`
|
||||
(the module's default loopback bind is unreachable via Docker `-p`),
|
||||
writes a
|
||||
marker under `/var/lib/ai-memory`, restarts the unit, and (by default)
|
||||
remounts a named volume once. It is privileged, so it runs only on schedule,
|
||||
manual dispatch, or a `full-ci` pull request.
|
||||
|
||||
**Non-goals** (do not treat these as covered by the ladder above):
|
||||
|
||||
- A→B flake/package upgrade or SQLite-wiki migration matrices
|
||||
- Exhaustive typed settings coverage (unknown TOML keys use `freeformType`)
|
||||
- Soft/fake systemd without a real unit start
|
||||
- Claiming the published app Docker image covers the NixOS module path
|
||||
- Darwin / multi-arch NixOS OCI (Linux x86_64 only by design)
|
||||
|
||||
Useful knobs for the container smoke:
|
||||
|
||||
```bash
|
||||
AI_MEMORY_NIXOS_TEST_KEEP=1 scripts/test-nixos-systemd-container.sh
|
||||
AI_MEMORY_NIXOS_TEST_VOLUME=0 scripts/test-nixos-systemd-container.sh
|
||||
AI_MEMORY_NIXOS_TEST_IMAGE=ai-memory-nixos-test scripts/test-nixos-systemd-container.sh
|
||||
AI_MEMORY_DOCKER=podman scripts/test-nixos-systemd-container.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## macOS menu bar app
|
||||
|
||||
On a Mac, the self-contained menu bar app is the GUI install: it bundles the
|
||||
|
||||
@@ -55,7 +55,7 @@ boundary not yet built.
|
||||
| 11b | Capture exclusions drop before storage | `ai-memory-hooks` `capture_policy.rs` `inspect`→`Drop` (before semaphore/spawn), including shell commands whose arguments name an ignored path (`match_command`; argv elements matched whole and tokenized); an invalid marker makes file and shell calls metadata-only, and the server admits a metadata-only shell body only under an invalid marker (`router.rs` `metadata_protocol_is_legal`); generated OpenCode/OMP/Pi/OpenClaw integrations mirror it in `render_shared.rs` `ts_capture_policy_v1` (`captureMatchCommand`). Candidate/argument flavor (`Flavor::Posix` vs `Flavor::Windows`, used to pick which `ignore_paths` patterns even apply) is derived from the **host** (the cwd), never from the candidate string alone — a POSIX-host candidate spelled with a leading `//` is collapsed to a single `/` before flavor detection (`normalize_candidate`; TS `captureNormalize`'s `windowsHost` parameter, sourced from `captureHostWindows(cwd)`), fixing GHSA-vh98 (a `//`-prefixed candidate used to self-classify as `Flavor::Windows` regardless of host, matching zero POSIX patterns and being captured instead of dropped); a genuine Windows/UNC host (cwd itself windows-flavored) is unaffected; `hook.rs` `--check-capture` preflight uses the same capture policy before any spool or spawn | `capture_policy.rs` per-agent `…honors_exclusions` tests, `shell_fixture_vectors` (shared `capture-policy.json` `shell` drop/keep vectors: tool aliases incl. OpenClaw/Devin `exec`, `workdir`, glob directories, Windows paths, and a POSIX-host leading-`//` command via `/{root}/docs/adr/x.md`), `shell_tool_shapes_of_every_adapter_honor_exclusions`, `fixture_vectors` (incl. TS-adapter `bash`/`exec` vectors, and `normalization`'s leading-`//` POSIX vectors alongside the kept Windows-cwd UNC vectors as the no-regression control); `shell_matching_is_off_when_inactive_and_fails_closed_when_invalid_or_over_budget`; `router.rs` `capture_protocol_shell_decisions_survive_server_reinspection`, `capture_protocol_invalid_marker_shell_is_metadata_only` (active metadata-only shell refused, older client's invalid-marker keep stripped, commandless control kept), `capture_protocol_unparseable_marker_strips_shell_events` (server fallback for an unparseable marker), `capture_protocol_invalid_shell_metadata_claim_must_be_canonical` (a stripped shell claim with a path count or non-`extracted` state is refused); `capture_policy.rs` `invalid_marker_strips_shell_calls_with_unparseable_commands`, `long_bash_lc_script_in_argv_is_not_dropped_by_the_match_budget`, **`a_leading_double_slash_candidate_does_not_escape_posix_ignore_paths_via_flavor_mismatch`** (GHSA-vh98 adversarial: attempts the `//repo/secret/...` violation on a POSIX host, proves it now drops, with a plain-single-slash control and a Windows-hosted genuine-UNC control both still correct — fails on the pre-fix code); `hook.rs` `shell_command_reading_an_ignored_path_is_dropped_before_spool`; `render_shared.rs` `generated_capture_policy_v1_node_runtime_evidence` (runs the same fixture sections, including the GHSA-vh98 vectors, against the emitted TypeScript; `#[ignore]` locally, run with `--ignored` under Node 24 by the Linux CI test job); `hook.rs` `check_capture_refuses_partial_scope_and_excluded_content` (ignored-path and partial-scope violations refused, complete public-path control passes), `check_capture_bounds_marker_hints_and_refuses_oversized_scope` (513-byte hints refused, 512-byte control passes; native routing preserved) | STRONG |
|
||||
| 11c | Capture hook ≤200ms budget (invariant #5) | `hooks/_lib.sh` capture path `curl --max-time 0.2` (context-fetch 1.0s and background drain 2.0s are separate, larger-budget paths) | none (shell-script timeout; hard to unit-test) — watch on any capture-path change | WATCH |
|
||||
| 11d | Hook server-profile routing: a marker-selected server gets only its own capture and its own token (#992) | `ai-memory-cli/src/server_profiles.rs` `resolve` (validated `ProfileName`, strict `servers.toml` parse, `roots` required once two profiles exist, component-wise root match) and `marker.rs` `find_server_selection` (inherited down the tree, any value shape counts); `commands/hook.rs` `resolve_hook_route` drops a `Rejected` route before spool, handoff and backfill, and hands the drainer no live token for a profile route; `commands/hook_spool.rs` `static_retry_token` (a profile entry retries only with its own stored token), the loopback reroot skip, and chunk splitting on `profile`; generated TS `captureServerRouted` drops a routed repository and gates `fetchHandoff`; `hooks/_lib.sh` `ai_memory_server_routed` (flag refused by `ai_memory_post_hook`/`ai_memory_get_handoff`) and `hooks/lib/ai-memory-hook.ps1` `Test-AiMemoryServerRouted` drop it in the script hooks; `--check-capture` refuses a rejected server-profile selection without printing its token or URL | `hook.rs` `each_repository_spools_to_its_own_profile_with_its_own_token`, `a_selection_that_does_not_resolve_emits_nothing` (unknown / tokenless / outside roots / roots required / invalid name, plus a resolving control), `session_start_handoff_comes_from_the_profile_server_only`, `a_repository_without_a_server_key_keeps_the_install_default`; `hook_spool.rs` `a_profile_entry_is_never_retried_with_the_install_live_token` (server B accepts exactly the install's live token and must still not get it), `a_profile_entry_recovers_with_its_own_rotated_token` (control), `a_profile_entry_on_a_dead_loopback_port_is_not_rerouted_to_the_default`, `profile_and_default_entries_at_one_address_ride_separate_batches`; `server_profiles.rs` roots/registry/name tests; `marker.rs` `nested_markers_without_server_inherit_the_ancestor_selection`; `install_hooks.rs` `generated_integrations_fail_closed_on_a_server_profile_marker`, `openclaw_plugin.rs` `openclaw_plugin_fails_closed_on_a_server_profile_marker`, and the `server-routed-*` checks in `generated_capture_policy_v1_node_runtime_evidence`; `hook.rs` `an_event_without_a_payload_cwd_routes_by_the_process_cwd`, `a_refused_route_prints_nothing_for_kimi_user_prompts`; `hook_spool.rs` `a_profile_entry_is_not_retried_with_a_token_issued_for_a_new_url`; `server_profiles.rs` `changing_the_url_without_a_token_discards_the_old_token`, `omitted_roots_keep_the_registered_ones`; `marker.rs` `outside_home_the_walk_reaches_a_marker_above_the_checkout_root`, `encoding_noise_cannot_hide_a_server_key`, `an_unreadable_marker_is_a_refused_selection`; `backfill.rs` `a_spawned_backfill_authenticates_like_the_hook_that_spawned_it`; `tests/hooks/test_lib.sh` "server profiles (#992)" section; `hook.rs` `a_mixed_spool_drains_each_event_only_to_its_own_server` (two token-gated servers, one spool, one drain: each server receives exactly its own event with exactly its own bearer); `tests/suite/server_profiles.rs` (the built binary: `server add` → `hook` spools to the profile with its token, unknown profile spools nothing, `uninstall` removes the tokens; `two_real_servers_each_receive_only_their_own_repository` runs two real `ai-memory serve` children with different root tokens and checks on each server which repository landed there); `ai-memory-hooks` `powershell_server_routed.rs` `server_routed_guard_mirrors_the_native_walk` (runs `Test-AiMemoryServerRouted` under real PowerShell: inherited, BOM, look-alike keys, the `$HOME` boundary with its control, past a checkout root outside home, current directory); `hook.rs` `a_selection_that_does_not_resolve_emits_nothing` (rejected routes refuse preflight; resolved profile control passes); `tests/hooks/test_lib.sh` and `powershell_home.rs` `a_trailing_separator_on_home_keeps_the_walk_boundary` (a `$HOME` ending in `/` or `\` keeps both script walks at home: a `server` marker above it routes nothing, with a root-home control that does) | STRONG for native hooks, generated TS, `.sh` and `.ps1` hooks. Known gap: an older binary draining a shared spool ignores `profile` |
|
||||
| 12 | Network/auth posture | `config.rs` loopback `DEFAULT_BIND`; `serve.rs` `validate_http_exposure`, `require_allowed_host`; `auth.rs` `require_bearer`; `serve.rs` mounts `/identity` inside the host and machine-auth gates even with web disabled | `serve.rs` host-guard (missing→400 / forged→403), non-loopback-requires-token; `auth.rs` wrong-token 401; `serve.rs` `machine_identity_remains_authenticated_with_web_disabled_and_expired_keys` (wrong and expired keys refused, valid machine key accepted, web remains disabled) | STRONG |
|
||||
| 12 | Network/auth posture | `config.rs` loopback `DEFAULT_BIND`; `serve.rs` `validate_http_exposure`, `require_allowed_host`; `auth.rs` `require_bearer`; `serve.rs` mounts `/identity` inside the host and machine-auth gates even with web disabled; `nix/nixos-module.nix` treats only literal `127.0.0.1`/`::1` as loopback, requires exactly one secret environment source for other binds, keeps secret auth and `llm_headers` values out of the Nix store, and brackets IPv6 in `ExecStart` | `serve.rs` host-guard (missing→400 / forged→403), non-loopback-requires-token; `auth.rs` wrong-token 401; `serve.rs` `machine_identity_remains_authenticated_with_web_disabled_and_expired_keys` (wrong and expired keys refused, valid machine key accepted, web remains disabled); `flake.nix` `nixos-module-eval` refuses `localhost` and `0.0.0.0` without a secret source, refuses two secret sources, `settings.auth` secrets, and `settings.llm_headers`, with literal IPv4/IPv6 loopback and one-source controls | STRONG |
|
||||
| 13 | Managed-run transcript attribution (concurrent launches in one checkout, invariant #16) | `ai-memory-store/src/workstream.rs` `link_native_session` stamps `native_session_linked_at` on its own run only, both `finish` updates drop the stamp when the session changes, `run_status` reports it; `ai-memory-cli/src/commands/run.rs` `resolve_native_session_after_run` takes a linked session only when `ai-memory-workstream` `native_session_in_checkout` holds it for this checkout (OpenCode by recorded directory), never falls back to a link it set aside, and turns an `AmbiguousNativeSession` into a warning with nothing imported; `ai-memory-workstream/src/transcript.rs` `discover_crush` claims only the one top-level session created (or, with `--continue`, touched) during the run, and in a data directory outside the project only one that edited a file in it | `multi_session.rs` `a_session_linked_by_one_managed_run_is_not_another_runs`; `run.rs` `a_session_linked_during_the_run_wins_over_discovery` (concurrent newer session, another checkout's link refused, no fallback to it, unlinked control); `transcript.rs` `native_session_in_checkout_checks_the_opencode_directory`; `store/src/lib.rs` `managed_run_status_reports_a_link_made_during_the_run`; `run.rs` `ambiguous_crush_discovery_keeps_the_run`; `transcript.rs` `crush_discovery_claims_only_the_session_the_run_created`, `crush_discovery_in_a_shared_store_claims_only_an_edit_here` | PARTIAL: a run whose child links nothing still falls back to discovering the newest session in the checkout; Crush, which has no hooks, relies on that discovery alone and claims nothing when it is ambiguous |
|
||||
| 13b | Managed-run lease exclusivity (one active run per workstream, invariant #16) | `ai-memory-store/src/workstream.rs` `prepare_run` expires lapsed leases and refuses any other `active` run on the workstream inside one transaction (`StoreError::WorkstreamBusy`), regardless of the `lease_owner` label; `heartbeat` renews only `active` rows. `ai-memory-cli/src/commands/run.rs` `wait_out_held_lease` (interactive relaunch) only waits for a reported expiry and retries — it never cancels or claims another run, so the server's busy check stays the sole arbiter | `store/src/lib.rs` `managed_workstream_batches_are_idempotent_and_release_the_lease` (second prepare refused while active); `run.rs` `a_renewed_lease_is_reported_as_a_live_owner_not_taken_over` (renewing holder is reported, never displaced), with controls `interactive_launch_waits_out_a_lapsing_lease_then_proceeds` and `ctrl_c_aborts_the_held_lease_wait_immediately` | STRONG for exclusivity. The `lease_owner` label (`host:pid`) is informational only and not unique inside ai-jail (every jailed launcher reports `ai-sandbox:<ns-pid>`), so it must never become an ownership key |
|
||||
| 14 | Per-project authorization (#708) | `ai-memory-store/src/project_authz.rs` `authorize_project` / `ProjectAuthz::authorize` choke point (V68 `project_grants` + `projects.access_mode`, default `open`; V69 `projects.created_by` feeds `is_creator`); `scope.rs` `ScopeResolver::with_project_authz` (reader pool for reads, writer actor for writes) and its free forms `authorize_scope_for` / `*_guarded`, attached for every DB user by `ai-memory-mcp` `scope_resolver_as`, `ai-memory-web` `authorize_read` / `lookup_project`, and `ai-memory-hooks` (`grants.rs` `authorize_resolved` for run/workstream ids, the capture check in `router.rs`); read-shaped mutations resolve at `ProjectAccess::Write` (`resolve_existing_args(.., need)`); unscoped reads filtered before `LIMIT` by `reader.rs` `readable_repository_sql`; `WriterHandle::authorize_project` as defense in depth. Page ids are never taken from a caller (only derived from already-authorized hits), so there is no page-id entry point to guard; incremental `recent` resolves and authorizes the requested project before decoding its scope-bound cursor; batch outcomes do not bypass the capture grant or session owner check | `tests/suite/project_authz.rs` (decision matrix, ship-inert, resolver gate); `tests/suite/access_mode.rs` `every_caller_against_both_modes`, `a_restricted_project_admits_the_team_and_refuses_the_outsider`, `new_projects_follow_the_server_default_and_admit_their_creator`; `scope.rs` `the_argument_shape_does_not_decide_the_level`, `a_user_reaches_only_what_they_were_granted`, `creating_authorizes_against_a_project_that_already_exists`, `a_refused_scope_fails_the_search_instead_of_shortening_it`; `grants.rs` `search_finds_only_what_the_viewer_may_read`, `the_limit_counts_only_what_the_viewer_may_see`, `the_workspace_handoff_comes_only_from_readable_repositories`; `ai-memory-mcp` `server.rs` `a_reader_may_read_everything_and_change_nothing`, `bob_cannot_read_alices_page_in_a_restricted_project`, `bob_cannot_find_alices_page_by_searching_in_a_restricted_project`, `bob_cannot_consolidate_a_session_in_alices_repository`, `a_restricted_projects_queues_need_write`; `ai-memory-hooks` `a_capture_needs_writer_on_the_repository_it_lands_in`, `session_start_delivers_nothing_from_a_repository_the_viewer_cannot_read`, `run_and_workstream_ids_only_answer_someone_who_may_reach_the_repository`; `ai-memory-web` `web_reads_honour_grants_in_a_restricted_project`, `metadata_shows_only_what_the_viewer_may_read` — each with a granted or open-project control, and proven to fail with the choke point (18 tests) or the SQL filter (9 tests) neutralized; `routes.rs` `api_recent_incremental_rechecks_restricted_scope_on_cursor` (a valid cursor cannot bypass a revoked grant; granted control passes), `api_recent_incremental_paginates_over_120_pages_and_binds_cursor`; `mcp_stateless_http.rs` `generic_machine_client_writes_queries_and_claims_a_handoff` (real machine key, foreign scope and partial scope refused); `router.rs` `an_unauthorized_batch_item_is_consumed_rather_than_retried`, `batch_acknowledges_foreign_collision_then_commits_next_item` (refused foreign item followed by a legitimate stored item) | STRONG — slice 3 closed both bypass classes (unscoped reads, raw-id entry points) and added the root-only management surface. Out of scope by design: per-project administrators (granting/restricting is root-only), and access modes, creators and grants live only in SQLite, so `reindex` resets them |
|
||||
|
||||
Generated
+84
@@ -0,0 +1,84 @@
|
||||
{
|
||||
"nodes": {
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1731533236,
|
||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1787070829,
|
||||
"narHash": "sha256-vXNVDVtvfiQuXthP0NHPFdNvvMTkGpx0UP8oddIWbNk=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "0ae2bc1419c3f345984c2629e72e7a631820fa4d",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "0ae2bc1419c3f345984c2629e72e7a631820fa4d",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs",
|
||||
"rust-overlay": "rust-overlay"
|
||||
}
|
||||
},
|
||||
"rust-overlay": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1787108576,
|
||||
"narHash": "sha256-WLhxXPMCzbeufsDZxdzkurLGBq74GX+JgLX8fFy6HZs=",
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"rev": "99607a06c2ea1290cd3258c11d1416dde9201f94",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"rev": "99607a06c2ea1290cd3258c11d1416dde9201f94",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
@@ -44,18 +44,287 @@
|
||||
|
||||
outputs =
|
||||
{
|
||||
self,
|
||||
nixpkgs,
|
||||
flake-utils,
|
||||
rust-overlay,
|
||||
...
|
||||
}:
|
||||
flake-utils.lib.eachDefaultSystem (
|
||||
let
|
||||
linuxPkgs = import nixpkgs {
|
||||
system = "x86_64-linux";
|
||||
overlays = [ (import rust-overlay) ];
|
||||
};
|
||||
inherit (linuxPkgs) lib;
|
||||
|
||||
# Eval-only NixOS module smoke tests. Kept at the top level under
|
||||
# checks.x86_64-linux only — the eval always targets x86_64-linux, so
|
||||
# duplicating these under eachDefaultSystem would add noise on Darwin.
|
||||
ageSopsStub =
|
||||
{ lib, ... }:
|
||||
{
|
||||
options.age.secrets = lib.mkOption {
|
||||
type = lib.types.attrsOf (lib.types.submodule {
|
||||
options.path = lib.mkOption { type = lib.types.path; };
|
||||
});
|
||||
default = { };
|
||||
};
|
||||
options.sops.secrets = lib.mkOption {
|
||||
type = lib.types.attrsOf (lib.types.submodule {
|
||||
options.path = lib.mkOption { type = lib.types.path; };
|
||||
});
|
||||
default = { };
|
||||
};
|
||||
config.age.secrets.ai-memory-env.path = "/run/agenix/ai-memory-env";
|
||||
config.sops.secrets."ai-memory/env".path = "/run/secrets/ai-memory/env";
|
||||
};
|
||||
|
||||
mkNixos = extra: nixpkgs.lib.nixosSystem {
|
||||
system = "x86_64-linux";
|
||||
modules = [
|
||||
self.nixosModules.default
|
||||
ageSopsStub
|
||||
extra
|
||||
];
|
||||
};
|
||||
|
||||
# enableWeb defaults to false, so the enabled-smoke config sets
|
||||
# it explicitly to true — this exercises the --enable-web
|
||||
# wiring, it isn't asserting what the default is.
|
||||
enabled = mkNixos { services.ai-memory = { enable = true; enableWeb = true; }; };
|
||||
disabled = mkNixos { services.ai-memory.enable = false; };
|
||||
withSettings = mkNixos {
|
||||
services.ai-memory = {
|
||||
enable = true;
|
||||
settings.allowed_hosts = [
|
||||
"localhost"
|
||||
"127.0.0.1"
|
||||
"::1"
|
||||
];
|
||||
settings.log_level = "info";
|
||||
};
|
||||
};
|
||||
withAge = mkNixos {
|
||||
services.ai-memory = {
|
||||
enable = true;
|
||||
bind = "0.0.0.0";
|
||||
ageSecret = "ai-memory-env";
|
||||
};
|
||||
};
|
||||
withSops = mkNixos {
|
||||
services.ai-memory = {
|
||||
enable = true;
|
||||
bind = "0.0.0.0";
|
||||
sopsSecret = "ai-memory/env";
|
||||
};
|
||||
};
|
||||
loopbackEnvFile = mkNixos {
|
||||
services.ai-memory = {
|
||||
enable = true;
|
||||
environmentFile = "/run/ai-memory/env";
|
||||
};
|
||||
};
|
||||
nonLoopbackNoSecrets = mkNixos {
|
||||
services.ai-memory = {
|
||||
enable = true;
|
||||
bind = "0.0.0.0";
|
||||
};
|
||||
};
|
||||
localhostNoSecrets = mkNixos {
|
||||
services.ai-memory = {
|
||||
enable = true;
|
||||
bind = "localhost";
|
||||
};
|
||||
};
|
||||
withIpv6Loopback = mkNixos {
|
||||
services.ai-memory = {
|
||||
enable = true;
|
||||
bind = "::1";
|
||||
};
|
||||
};
|
||||
bothAgeAndSops = mkNixos {
|
||||
services.ai-memory = {
|
||||
enable = true;
|
||||
ageSecret = "ai-memory-env";
|
||||
sopsSecret = "ai-memory/env";
|
||||
};
|
||||
};
|
||||
ageAndEnvironmentFile = mkNixos {
|
||||
services.ai-memory = {
|
||||
enable = true;
|
||||
ageSecret = "ai-memory-env";
|
||||
environmentFile = "/run/ai-memory/env";
|
||||
};
|
||||
};
|
||||
secretsInSettings = mkNixos {
|
||||
services.ai-memory = {
|
||||
enable = true;
|
||||
settings.auth.bearer_token = "sekrit";
|
||||
};
|
||||
};
|
||||
headersInSettings = mkNixos {
|
||||
services.ai-memory = {
|
||||
enable = true;
|
||||
settings.llm_headers = [ "Authorization: Bearer sekrit" ];
|
||||
};
|
||||
};
|
||||
bindInSettings = mkNixos {
|
||||
services.ai-memory = {
|
||||
enable = true;
|
||||
settings.bind = "0.0.0.0";
|
||||
};
|
||||
};
|
||||
withCustomDataDir = mkNixos {
|
||||
services.ai-memory = {
|
||||
enable = true;
|
||||
dataDir = "/data/custom ai-memory";
|
||||
};
|
||||
};
|
||||
withFirewall = mkNixos {
|
||||
services.ai-memory = {
|
||||
enable = true;
|
||||
openFirewall = true;
|
||||
};
|
||||
};
|
||||
withLimits = mkNixos {
|
||||
services.ai-memory = {
|
||||
enable = true;
|
||||
memoryMax = "2G";
|
||||
tasksMax = 512;
|
||||
};
|
||||
};
|
||||
|
||||
failingAssertions = sys: lib.filter (a: !a.assertion) sys.config.assertions;
|
||||
nonLoopbackFailing = failingAssertions nonLoopbackNoSecrets;
|
||||
localhostFailing = failingAssertions localhostNoSecrets;
|
||||
ageSopsFailing = failingAssertions bothAgeAndSops;
|
||||
ageEnvironmentFailing = failingAssertions ageAndEnvironmentFile;
|
||||
secretsFailing = failingAssertions secretsInSettings;
|
||||
headersFailing = failingAssertions headersInSettings;
|
||||
bindFailing = failingAssertions bindInSettings;
|
||||
|
||||
enabledSc = enabled.config.systemd.services.ai-memory.serviceConfig;
|
||||
enabledUnit = enabled.config.systemd.services.ai-memory;
|
||||
execStart = enabledSc.ExecStart;
|
||||
settingsExec =
|
||||
withSettings.config.systemd.services.ai-memory.serviceConfig.ExecStart;
|
||||
ipv6Exec = withIpv6Loopback.config.systemd.services.ai-memory.serviceConfig.ExecStart;
|
||||
ageUnit = withAge.config.systemd.services.ai-memory;
|
||||
customSc = withCustomDataDir.config.systemd.services.ai-memory.serviceConfig;
|
||||
customTmpfiles = withCustomDataDir.config.systemd.tmpfiles.settings."10-ai-memory";
|
||||
limitsSc = withLimits.config.systemd.services.ai-memory.serviceConfig;
|
||||
|
||||
# One NixOS system for the container smoke path. Building its
|
||||
# docker-image tarball builds system.build.toplevel once; there is
|
||||
# no separate bare-toplevel check that would rebuild the same closure.
|
||||
containerNixos = nixpkgs.lib.nixosSystem {
|
||||
system = "x86_64-linux";
|
||||
modules = [
|
||||
self.nixosModules.default
|
||||
(
|
||||
{ modulesPath, pkgs, ... }:
|
||||
{
|
||||
imports = [ "${modulesPath}/virtualisation/docker-image.nix" ];
|
||||
system.stateVersion = "25.05";
|
||||
networking.hostName = "ai-memory";
|
||||
# Slim the closure: docs are useless inside the smoke image.
|
||||
documentation.enable = false;
|
||||
documentation.doc.enable = false;
|
||||
documentation.info.enable = false;
|
||||
documentation.man.enable = false;
|
||||
documentation.nixos.enable = false;
|
||||
# In-container /healthz probe (docker published ports cannot
|
||||
# reach the module's default 127.0.0.1 bind).
|
||||
environment.systemPackages = [ pkgs.curl ];
|
||||
services.ai-memory.enable = true;
|
||||
}
|
||||
)
|
||||
];
|
||||
};
|
||||
|
||||
nixosAiMemoryDocker =
|
||||
linuxPkgs.runCommand "nixos-ai-memory-docker"
|
||||
{
|
||||
meta.description = "NixOS rootfs tarball with services.ai-memory for systemd-in-container smoke tests";
|
||||
passthru = {
|
||||
toplevel = containerNixos.config.system.build.toplevel;
|
||||
tarball = containerNixos.config.system.build.tarball;
|
||||
imageName = "ai-memory-nixos-test";
|
||||
};
|
||||
}
|
||||
''
|
||||
mkdir -p "$out"
|
||||
# Building this derivation builds toplevel via the tarball dep —
|
||||
# single closure path for CI (no duplicate bare-toplevel job).
|
||||
ln -s ${containerNixos.config.system.build.tarball}/tarball/*.tar.xz \
|
||||
"$out/rootfs.tar.xz"
|
||||
ln -s ${containerNixos.config.system.build.toplevel} "$out/toplevel"
|
||||
printf '%s\n' "ai-memory-nixos-test" > "$out/image-name"
|
||||
'';
|
||||
|
||||
nixosChecks = {
|
||||
nixos-module-eval =
|
||||
assert lib.hasInfix "--enable-web" execStart;
|
||||
assert lib.hasInfix "--bind 127.0.0.1:49374" execStart;
|
||||
assert lib.hasInfix "--data-dir" execStart;
|
||||
assert lib.hasInfix " serve " (" " + execStart + " ");
|
||||
assert lib.hasInfix "--transport http" execStart;
|
||||
assert !(disabled.config.systemd.services ? ai-memory);
|
||||
assert enabledSc.MemoryDenyWriteExecute == true;
|
||||
assert enabledSc.RestrictNamespaces == true;
|
||||
assert enabledSc.UMask == "0077";
|
||||
assert enabledSc.CapabilityBoundingSet == [ ];
|
||||
assert enabledSc.NoNewPrivileges == true;
|
||||
assert enabledSc.PrivateTmp == true;
|
||||
assert enabledSc.ProtectHome == true;
|
||||
assert enabledSc.ProtectSystem == "strict";
|
||||
assert enabledSc.StateDirectory == "ai-memory";
|
||||
assert lib.elem "/var/lib/ai-memory" enabledSc.ReadWritePaths;
|
||||
assert lib.hasInfix "--config" settingsExec;
|
||||
assert withAge.config.systemd.services.ai-memory.serviceConfig.EnvironmentFile
|
||||
== "/run/agenix/ai-memory-env";
|
||||
assert withSops.config.systemd.services.ai-memory.serviceConfig.EnvironmentFile
|
||||
== "/run/secrets/ai-memory/env";
|
||||
assert loopbackEnvFile.config.systemd.services.ai-memory.serviceConfig.EnvironmentFile
|
||||
== "-/run/ai-memory/env";
|
||||
assert nonLoopbackFailing != [ ];
|
||||
assert lib.any (a: lib.hasInfix "non-loopback bind requires" a.message)
|
||||
nonLoopbackFailing;
|
||||
assert lib.any (a: lib.hasInfix "non-loopback bind requires" a.message)
|
||||
localhostFailing;
|
||||
assert lib.hasInfix "--bind [::1]:49374" ipv6Exec;
|
||||
assert lib.any (a: lib.hasInfix "mutually exclusive" a.message) ageSopsFailing;
|
||||
assert lib.any (a: lib.hasInfix "mutually exclusive" a.message)
|
||||
ageEnvironmentFailing;
|
||||
assert lib.any (a: lib.hasInfix "settings.auth must not contain secrets" a.message)
|
||||
secretsFailing;
|
||||
assert lib.any (a: lib.hasInfix "settings.llm_headers" a.message) headersFailing;
|
||||
assert lib.any (a: lib.hasInfix "settings.bind is not allowed" a.message)
|
||||
bindFailing;
|
||||
assert (customSc.StateDirectory or null) == null;
|
||||
assert lib.elem "/data/custom ai-memory" customSc.ReadWritePaths;
|
||||
assert customTmpfiles."/data/custom ai-memory".d.mode == "0750";
|
||||
assert customTmpfiles."/data/custom ai-memory".d.user == "ai-memory";
|
||||
assert lib.hasInfix "/data/custom\\x20ai-memory" customSc.ExecStart;
|
||||
assert lib.elem "network.target" enabledUnit.after;
|
||||
assert !(lib.elem "network-online.target" (enabledUnit.wants or [ ]));
|
||||
assert lib.elem "network-online.target" ageUnit.after;
|
||||
assert lib.elem "network-online.target" ageUnit.wants;
|
||||
assert lib.elem 49374 withFirewall.config.networking.firewall.allowedTCPPorts;
|
||||
assert limitsSc.MemoryMax == "2G";
|
||||
assert limitsSc.TasksMax == 512;
|
||||
linuxPkgs.runCommand "ai-memory-nixos-module-eval" { } "touch $out";
|
||||
|
||||
};
|
||||
in
|
||||
(flake-utils.lib.eachDefaultSystem (
|
||||
system:
|
||||
let
|
||||
pkgs = import nixpkgs {
|
||||
inherit system;
|
||||
overlays = [ (import rust-overlay) ];
|
||||
};
|
||||
inherit (pkgs) lib;
|
||||
|
||||
# Read the same toolchain file the project pins for every other CI
|
||||
# path — rust-toolchain.toml says `channel = "1.95"`.
|
||||
@@ -67,57 +336,65 @@
|
||||
};
|
||||
in
|
||||
{
|
||||
packages.default = rustPlatform.buildRustPackage {
|
||||
pname = "ai-memory";
|
||||
version = (builtins.fromTOML (builtins.readFile ./Cargo.toml)).workspace.package.version;
|
||||
packages =
|
||||
{
|
||||
default = rustPlatform.buildRustPackage {
|
||||
pname = "ai-memory";
|
||||
version = (builtins.fromTOML (builtins.readFile ./Cargo.toml)).workspace.package.version;
|
||||
|
||||
src = ./.;
|
||||
cargoLock.lockFile = ./Cargo.lock;
|
||||
src = ./.;
|
||||
cargoLock.lockFile = ./Cargo.lock;
|
||||
|
||||
# No nativeBuildInputs needed — the build is fully self-contained
|
||||
# (SQLite bundled, libgit2 vendored, rustls with webpki-roots).
|
||||
# No nativeBuildInputs needed — the build is fully self-contained
|
||||
# (SQLite bundled, libgit2 vendored, rustls with webpki-roots).
|
||||
|
||||
# Skip the Tailwind CLI download in the sandbox. The build script
|
||||
# falls back to the vendored static/tailwind.css committed to the
|
||||
# repo (see crates/ai-memory-web/build.rs).
|
||||
TAILWIND_SKIP = "1";
|
||||
# Skip the Tailwind CLI download in the sandbox. The build script
|
||||
# falls back to the vendored static/tailwind.css committed to the
|
||||
# repo (see crates/ai-memory-web/build.rs).
|
||||
TAILWIND_SKIP = "1";
|
||||
|
||||
buildType = "release";
|
||||
buildType = "release";
|
||||
|
||||
# The packaging test suite (tests/packaging.rs) exercises the
|
||||
# Docker-wrapper shell script `bin/ai-memory` and needs
|
||||
# docker/podman on PATH — not available in a Nix sandbox. The
|
||||
# rest of the workspace test suite (unit tests + integration)
|
||||
# does not need them and can be run via `nix develop -c cargo
|
||||
# test --workspace` on a machine with Docker.
|
||||
doCheck = false;
|
||||
# The packaging test suite (tests/packaging.rs) exercises the
|
||||
# Docker-wrapper shell script `bin/ai-memory` and needs
|
||||
# docker/podman on PATH — not available in a Nix sandbox. The
|
||||
# rest of the workspace test suite (unit tests + integration)
|
||||
# does not need them and can be run via `nix develop -c cargo
|
||||
# test --workspace` on a machine with Docker.
|
||||
doCheck = false;
|
||||
|
||||
# Install the bundled hook scripts alongside the binary,
|
||||
# mirroring what the AUR PKGBUILD does. Native binary users
|
||||
# (`ai-memory serve`, `install-hooks`) look up hooks under
|
||||
# the binary's share directory at runtime.
|
||||
#
|
||||
# `bin/ai-memory` (the Docker-wrapper shell script) is NOT
|
||||
# installed — Nix users build the native binary directly and
|
||||
# have no need for a Docker wrapper.
|
||||
postInstall = ''
|
||||
mkdir -p $out/share/ai-memory
|
||||
cp -a hooks $out/share/ai-memory/
|
||||
# Install the bundled hook scripts alongside the binary,
|
||||
# mirroring what the AUR PKGBUILD does. Native binary users
|
||||
# (`ai-memory serve`, `install-hooks`) look up hooks under
|
||||
# the binary's share directory at runtime.
|
||||
#
|
||||
# `bin/ai-memory` (the Docker-wrapper shell script) is NOT
|
||||
# installed — Nix users build the native binary directly and
|
||||
# have no need for a Docker wrapper.
|
||||
postInstall = ''
|
||||
mkdir -p $out/share/ai-memory
|
||||
cp -a hooks $out/share/ai-memory/
|
||||
|
||||
# Install the default config template so `ai-memory init`
|
||||
# has a known-good starting point without a network fetch.
|
||||
mkdir -p $out/etc/ai-memory
|
||||
cp crates/ai-memory-cli/templates/config.default.toml \
|
||||
$out/etc/ai-memory/config.default.toml
|
||||
'';
|
||||
# Install the default config template so `ai-memory init`
|
||||
# has a known-good starting point without a network fetch.
|
||||
mkdir -p $out/etc/ai-memory
|
||||
cp crates/ai-memory-cli/templates/config.default.toml \
|
||||
$out/etc/ai-memory/config.default.toml
|
||||
'';
|
||||
|
||||
meta = {
|
||||
description = "Long-term memory for AI coding agents";
|
||||
homepage = "https://github.com/akitaonrails/ai-memory";
|
||||
license = pkgs.lib.licenses.mit;
|
||||
mainProgram = "ai-memory";
|
||||
meta = {
|
||||
description = "Long-term memory for AI coding agents";
|
||||
homepage = "https://github.com/akitaonrails/ai-memory";
|
||||
license = pkgs.lib.licenses.mit;
|
||||
mainProgram = "ai-memory";
|
||||
};
|
||||
};
|
||||
}
|
||||
// lib.optionalAttrs (system == "x86_64-linux") {
|
||||
# Rootfs tarball derived from the same NixOS system as
|
||||
# passthru.toplevel — single closure for the container smoke.
|
||||
nixos-ai-memory-docker = nixosAiMemoryDocker;
|
||||
};
|
||||
};
|
||||
|
||||
devShells.default = pkgs.mkShell {
|
||||
name = "ai-memory-dev";
|
||||
@@ -142,5 +419,19 @@
|
||||
'';
|
||||
};
|
||||
}
|
||||
);
|
||||
))
|
||||
// {
|
||||
# Additive: a NixOS host can run `services.ai-memory.enable = true` to
|
||||
# get this binary as a hardened systemd service (see
|
||||
# nix/nixos-module.nix). Merged at the top level, not inside
|
||||
# eachDefaultSystem, because NixOS modules are not system-scoped.
|
||||
nixosModules.default =
|
||||
{ pkgs, lib, ... }:
|
||||
{
|
||||
imports = [ ./nix/nixos-module.nix ];
|
||||
config.services.ai-memory.package = lib.mkDefault self.packages.${pkgs.system}.default;
|
||||
};
|
||||
|
||||
checks.x86_64-linux = nixosChecks;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
# NixOS module for the ai-memory MCP server.
|
||||
#
|
||||
# Standalone: no reference to `self` or anything flake-specific, so it stays
|
||||
# importable outside this flake. `flake.nix`'s `nixosModules.default` wraps
|
||||
# this file and supplies a default for `package` by closing over `self`.
|
||||
{ config, lib, pkgs, utils, ... }:
|
||||
|
||||
let
|
||||
cfg = config.services.ai-memory;
|
||||
sandbox = import ./systemd-sandbox.nix { inherit lib; };
|
||||
|
||||
defaultDataDir = "/var/lib/ai-memory";
|
||||
usesDefaultDataDir = cfg.dataDir == defaultDataDir;
|
||||
|
||||
isLoopback = lib.elem cfg.bind [ "127.0.0.1" "::1" ];
|
||||
bindHost = if lib.hasInfix ":" cfg.bind then "[${cfg.bind}]" else cfg.bind;
|
||||
|
||||
tomlFormat = pkgs.formats.toml { };
|
||||
|
||||
# Strip null leaves so optional typed settings omit keys from generated TOML.
|
||||
pruneNulls =
|
||||
value:
|
||||
if value == null then
|
||||
null
|
||||
else if builtins.isAttrs value then
|
||||
lib.filterAttrs (_: v: v != null) (lib.mapAttrs (_: v: pruneNulls v) value)
|
||||
else
|
||||
value;
|
||||
|
||||
settingsToml =
|
||||
let
|
||||
# Service-level bind/port/enableWeb win over duplicate settings keys.
|
||||
stripped = lib.filterAttrs (name: _: name != "bind") (pruneNulls cfg.settings);
|
||||
in
|
||||
if stripped == { } then
|
||||
null
|
||||
else
|
||||
tomlFormat.generate "ai-memory-config.toml" stripped;
|
||||
|
||||
ageSecretPath =
|
||||
if cfg.ageSecret == null then
|
||||
null
|
||||
else
|
||||
config.age.secrets.${cfg.ageSecret}.path;
|
||||
|
||||
sopsSecretPath =
|
||||
if cfg.sopsSecret == null then
|
||||
null
|
||||
else
|
||||
config.sops.secrets.${cfg.sopsSecret}.path;
|
||||
|
||||
secretsFile =
|
||||
if cfg.ageSecret != null then
|
||||
ageSecretPath
|
||||
else if cfg.sopsSecret != null then
|
||||
sopsSecretPath
|
||||
else
|
||||
cfg.environmentFile;
|
||||
|
||||
secretSourceCount = lib.count (source: source != null) [
|
||||
cfg.ageSecret
|
||||
cfg.sopsSecret
|
||||
cfg.environmentFile
|
||||
];
|
||||
|
||||
secretKeysInSettings =
|
||||
let
|
||||
auth = cfg.settings.auth or null;
|
||||
in
|
||||
lib.filter (k: auth != null && (auth.${k} or null) != null) [
|
||||
"bearer_token"
|
||||
"token_pepper"
|
||||
"initial_root_password"
|
||||
"recovery_token"
|
||||
"actor_proxy_bearer_token"
|
||||
];
|
||||
|
||||
execStartArgs =
|
||||
[
|
||||
(lib.getExe cfg.package)
|
||||
"--data-dir"
|
||||
cfg.dataDir
|
||||
]
|
||||
++ lib.optionals (settingsToml != null) [
|
||||
"--config"
|
||||
"${settingsToml}"
|
||||
]
|
||||
++ [
|
||||
"serve"
|
||||
"--transport"
|
||||
"http"
|
||||
"--bind"
|
||||
"${bindHost}:${toString cfg.port}"
|
||||
]
|
||||
++ lib.optionals cfg.enableWeb [ "--enable-web" ];
|
||||
in
|
||||
{
|
||||
options.services.ai-memory = {
|
||||
enable = lib.mkEnableOption "the ai-memory MCP server as a systemd service";
|
||||
|
||||
package = lib.mkOption {
|
||||
type = lib.types.package;
|
||||
description = ''
|
||||
The ai-memory package to run. No default here, so this module stays
|
||||
usable standalone. This repo's `flake.nix` supplies a default via
|
||||
`nixosModules.default`, which sets
|
||||
`services.ai-memory.package = lib.mkDefault self.packages.${pkgs.system}.default`.
|
||||
A consumer importing this file directly (bypassing that wrapper)
|
||||
must set this option themselves.
|
||||
'';
|
||||
};
|
||||
|
||||
dataDir = lib.mkOption {
|
||||
type = lib.types.path;
|
||||
default = defaultDataDir;
|
||||
description = "Data directory passed as --data-dir (wiki, SQLite, config.toml).";
|
||||
};
|
||||
|
||||
bind = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "127.0.0.1";
|
||||
description = "Host ai-memory's HTTP transport binds to.";
|
||||
};
|
||||
|
||||
port = lib.mkOption {
|
||||
type = lib.types.port;
|
||||
default = 49374;
|
||||
description = "Port ai-memory's HTTP transport binds to.";
|
||||
};
|
||||
|
||||
enableWeb = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = ''
|
||||
Pass --enable-web (mounts the built-in web UI at /web). Off by
|
||||
default, matching the underlying --enable-web CLI flag's own
|
||||
default — NOT the packaged FHS systemd unit, which hardcodes it on.
|
||||
'';
|
||||
};
|
||||
|
||||
settings = lib.mkOption {
|
||||
type = lib.types.submodule {
|
||||
freeformType = tomlFormat.type;
|
||||
options = import ./settings-options.nix { inherit lib; };
|
||||
};
|
||||
default = { };
|
||||
description = ''
|
||||
Declarative config.toml fragment (non-secret keys only). Rendered to a
|
||||
generated file and passed as `--config`. Top-level `bind`, `port`, and
|
||||
`enableWeb` service options win over duplicate keys here. The generated
|
||||
file is in the world-readable Nix store: never put credentials here,
|
||||
including `llm_headers`; use `AI_MEMORY_LLM_HEADERS` in one of the
|
||||
secret environment-file options instead.
|
||||
'';
|
||||
};
|
||||
|
||||
ageSecret = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
example = "ai-memory-env";
|
||||
description = ''
|
||||
Name of a `config.age.secrets.<name>` entry (agenix). Mutually
|
||||
exclusive with `sopsSecret` and `environmentFile`. The host must import
|
||||
agenix; this module only consumes the decrypted path.
|
||||
'';
|
||||
};
|
||||
|
||||
sopsSecret = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
example = "ai-memory/env";
|
||||
description = ''
|
||||
Name of a `config.sops.secrets.<name>` entry (sops-nix). Mutually
|
||||
exclusive with `ageSecret` and `environmentFile`. The host must import
|
||||
sops-nix; this module only consumes the decrypted path.
|
||||
'';
|
||||
};
|
||||
|
||||
environmentFile = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.path;
|
||||
default = null;
|
||||
description = ''
|
||||
Escape hatch: explicit systemd EnvironmentFile path for secrets such as
|
||||
`AI_MEMORY_AUTH_TOKEN` (required once `bind` is non-loopback). On
|
||||
loopback, a missing file does not fail service start (leading `-`).
|
||||
On non-loopback the file must exist. Mutually exclusive with
|
||||
`ageSecret` and `sopsSecret`; prefer those when using agenix or
|
||||
sops-nix.
|
||||
'';
|
||||
};
|
||||
|
||||
user = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "ai-memory";
|
||||
description = "System user the service runs as.";
|
||||
};
|
||||
|
||||
group = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "ai-memory";
|
||||
description = "System group the service runs as.";
|
||||
};
|
||||
|
||||
createUser = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
description = ''
|
||||
Create the dedicated system user and group. Defaults to true when
|
||||
`user` is `ai-memory`; set false when `user` names an existing
|
||||
account you manage elsewhere.
|
||||
'';
|
||||
};
|
||||
|
||||
openFirewall = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = "Open the service `port` in the firewall when true.";
|
||||
};
|
||||
|
||||
memoryMax = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
example = "2G";
|
||||
description = "Optional systemd MemoryMax for the service.";
|
||||
};
|
||||
|
||||
tasksMax = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.int;
|
||||
default = null;
|
||||
description = "Optional systemd TasksMax for the service.";
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable (
|
||||
lib.mkMerge [
|
||||
{
|
||||
assertions = [
|
||||
{
|
||||
assertion = secretSourceCount <= 1;
|
||||
message = "services.ai-memory: ageSecret, sopsSecret, and environmentFile are mutually exclusive";
|
||||
}
|
||||
{
|
||||
assertion = isLoopback || secretsFile != null;
|
||||
message =
|
||||
"services.ai-memory: non-loopback bind requires ageSecret, sopsSecret, or environmentFile (for AI_MEMORY_AUTH_TOKEN and related secrets)";
|
||||
}
|
||||
{
|
||||
assertion = secretKeysInSettings == [ ];
|
||||
message =
|
||||
"services.ai-memory.settings.auth must not contain secrets (${lib.concatStringsSep ", " secretKeysInSettings}); use ageSecret/sopsSecret/environmentFile";
|
||||
}
|
||||
{
|
||||
assertion = (cfg.settings.llm_headers or null) == null;
|
||||
message =
|
||||
"services.ai-memory.settings.llm_headers may contain credentials and must not enter the Nix store; use AI_MEMORY_LLM_HEADERS in ageSecret/sopsSecret/environmentFile";
|
||||
}
|
||||
{
|
||||
assertion = (cfg.settings.bind or null) == null;
|
||||
message = "services.ai-memory.settings.bind is not allowed; use the top-level bind/port options";
|
||||
}
|
||||
];
|
||||
|
||||
services.ai-memory.createUser = lib.mkDefault (cfg.user == "ai-memory");
|
||||
|
||||
environment.systemPackages = [ cfg.package ];
|
||||
|
||||
networking.firewall.allowedTCPPorts = lib.optionals cfg.openFirewall [ cfg.port ];
|
||||
}
|
||||
|
||||
(lib.mkIf cfg.createUser {
|
||||
users.users.${cfg.user} = {
|
||||
isSystemUser = true;
|
||||
group = cfg.group;
|
||||
description = "ai-memory MCP server";
|
||||
home = cfg.dataDir;
|
||||
createHome = false;
|
||||
shell = "${pkgs.util-linuxMinimal}/bin/nologin";
|
||||
};
|
||||
users.groups.${cfg.group} = { };
|
||||
})
|
||||
|
||||
(lib.mkIf (!usesDefaultDataDir && cfg.createUser) {
|
||||
systemd.tmpfiles.settings."10-ai-memory"."${cfg.dataDir}".d = {
|
||||
mode = "0750";
|
||||
user = cfg.user;
|
||||
group = cfg.group;
|
||||
};
|
||||
})
|
||||
|
||||
{
|
||||
systemd.services.ai-memory = {
|
||||
description = "ai-memory MCP server";
|
||||
documentation = [ "https://github.com/akitaonrails/ai-memory" ];
|
||||
after = if isLoopback then [ "network.target" ] else [ "network-online.target" ];
|
||||
wants = lib.optionals (!isLoopback) [ "network-online.target" ];
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
|
||||
serviceConfig = lib.mkMerge [
|
||||
{
|
||||
Type = "simple";
|
||||
User = cfg.user;
|
||||
Group = cfg.group;
|
||||
ExecStart = utils.escapeSystemdExecArgs execStartArgs;
|
||||
Restart = "on-failure";
|
||||
RestartSec = "5s";
|
||||
TimeoutStopSec = "30s";
|
||||
StartLimitBurst = 5;
|
||||
StartLimitIntervalSec = "60s";
|
||||
ReadWritePaths = [ cfg.dataDir ];
|
||||
}
|
||||
(lib.optionalAttrs usesDefaultDataDir {
|
||||
StateDirectory = "ai-memory";
|
||||
StateDirectoryMode = "0750";
|
||||
})
|
||||
sandbox.aiMemorySystemSandbox
|
||||
(if secretsFile != null then
|
||||
if isLoopback then
|
||||
{ EnvironmentFile = "-${secretsFile}"; }
|
||||
else
|
||||
{ EnvironmentFile = "${secretsFile}"; }
|
||||
else
|
||||
{ })
|
||||
(lib.optionalAttrs (cfg.memoryMax != null) {
|
||||
MemoryMax = cfg.memoryMax;
|
||||
})
|
||||
(lib.optionalAttrs (cfg.tasksMax != null) {
|
||||
TasksMax = cfg.tasksMax;
|
||||
})
|
||||
];
|
||||
};
|
||||
}
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
# A small discoverability layer for common non-secret config.toml keys.
|
||||
#
|
||||
# The parent submodule has `freeformType = pkgs.formats.toml.type`, so every
|
||||
# current and future config key remains usable without copying Config's full
|
||||
# Rust schema into Nix. Secrets belong in an environment file, never here.
|
||||
{ lib, ... }:
|
||||
|
||||
let
|
||||
inherit (lib) types;
|
||||
in
|
||||
{
|
||||
allowed_hosts = lib.mkOption {
|
||||
type = types.nullOr (types.listOf types.str);
|
||||
default = null;
|
||||
description = "Host-header allowlist (DNS-rebinding defence).";
|
||||
};
|
||||
|
||||
log_level = lib.mkOption {
|
||||
type = types.nullOr types.str;
|
||||
default = null;
|
||||
};
|
||||
|
||||
capture_assistant = lib.mkOption {
|
||||
type = types.nullOr types.bool;
|
||||
default = null;
|
||||
};
|
||||
|
||||
llm_provider = lib.mkOption {
|
||||
type = types.nullOr types.str;
|
||||
default = null;
|
||||
};
|
||||
|
||||
llm_model = lib.mkOption {
|
||||
type = types.nullOr types.str;
|
||||
default = null;
|
||||
};
|
||||
|
||||
auth = lib.mkOption {
|
||||
type = types.nullOr (types.submodule {
|
||||
# Keep unknown keys visible to the module's explicit secret-key
|
||||
# assertion, which gives a useful refusal instead of a type error.
|
||||
freeformType = types.attrsOf types.anything;
|
||||
options = {
|
||||
secure_cookie = lib.mkOption {
|
||||
type = types.nullOr types.bool;
|
||||
default = null;
|
||||
};
|
||||
root_username = lib.mkOption {
|
||||
type = types.nullOr types.str;
|
||||
default = null;
|
||||
};
|
||||
};
|
||||
});
|
||||
default = null;
|
||||
description = ''
|
||||
Non-secret auth settings only. Use ageSecret, sopsSecret, or
|
||||
environmentFile for bearer tokens, password material, and other
|
||||
credentials.
|
||||
'';
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
# Systemd hardening for the NixOS ai-memory service.
|
||||
{ lib, ... }:
|
||||
|
||||
{
|
||||
# Full sandbox for the system unit (StateDirectory + /var/lib/ai-memory).
|
||||
aiMemorySystemSandbox = {
|
||||
CapabilityBoundingSet = [ ];
|
||||
AmbientCapabilities = [ ];
|
||||
MemoryDenyWriteExecute = true;
|
||||
RestrictAddressFamilies = [
|
||||
"AF_UNIX"
|
||||
"AF_INET"
|
||||
"AF_INET6"
|
||||
];
|
||||
RestrictNamespaces = true;
|
||||
RestrictRealtime = true;
|
||||
RestrictSUIDSGID = true;
|
||||
LockPersonality = true;
|
||||
PrivateDevices = true;
|
||||
RemoveIPC = true;
|
||||
ProtectKernelTunables = true;
|
||||
ProtectKernelModules = true;
|
||||
ProtectKernelLogs = true;
|
||||
ProtectControlGroups = true;
|
||||
ProtectClock = true;
|
||||
ProtectHostname = true;
|
||||
SystemCallArchitectures = "native";
|
||||
UMask = "0077";
|
||||
NoNewPrivileges = true;
|
||||
PrivateTmp = true;
|
||||
ProtectHome = true;
|
||||
ProtectSystem = "strict";
|
||||
};
|
||||
}
|
||||
Executable
+59
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env bash
|
||||
# CI-safe checks for the Nix flake package output.
|
||||
#
|
||||
# Validates the built prefix (hooks, config template, binary execution) and
|
||||
# `nix run` wiring. Does not mutate the host or require systemd.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
PREFIX="${1:-}"
|
||||
|
||||
log() {
|
||||
printf '==> %s\n' "$*"
|
||||
}
|
||||
|
||||
fail() {
|
||||
printf 'error: %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
repo_root() {
|
||||
local script_dir
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "${script_dir}/.." && pwd
|
||||
}
|
||||
|
||||
main() {
|
||||
if [ -z "${PREFIX}" ]; then
|
||||
fail "usage: $0 <nix-build-result-prefix>"
|
||||
fi
|
||||
|
||||
local binary="${PREFIX}/bin/ai-memory"
|
||||
if [ ! -x "${binary}" ]; then
|
||||
fail "missing executable: ${binary}"
|
||||
fi
|
||||
|
||||
log "Checking ai-memory --version"
|
||||
"${binary}" --version
|
||||
|
||||
local hooks_dir="${PREFIX}/share/ai-memory/hooks"
|
||||
if [ ! -d "${hooks_dir}" ]; then
|
||||
fail "missing hooks directory: ${hooks_dir}"
|
||||
fi
|
||||
if [ -z "$(ls -A "${hooks_dir}" 2>/dev/null)" ]; then
|
||||
fail "hooks directory is empty: ${hooks_dir}"
|
||||
fi
|
||||
|
||||
local config_template="${PREFIX}/etc/ai-memory/config.default.toml"
|
||||
if [ ! -f "${config_template}" ]; then
|
||||
fail "missing config template: ${config_template}"
|
||||
fi
|
||||
|
||||
log "Checking nix run smoke"
|
||||
cd "$(repo_root)"
|
||||
nix run . -- --version
|
||||
|
||||
log "Nix packaging checks passed"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Executable
+253
@@ -0,0 +1,253 @@
|
||||
#!/usr/bin/env bash
|
||||
# Privileged NixOS systemd-in-container smoke for services.ai-memory.
|
||||
#
|
||||
# Builds (or reuses) packages.x86_64-linux.nixos-ai-memory-docker — a rootfs
|
||||
# tarball derived from one NixOS system.build.toplevel — imports it into
|
||||
# Docker/Podman, starts systemd as PID 1, and checks:
|
||||
# - systemctl is-active ai-memory
|
||||
# - curl /healthz inside the container (module default bind is loopback;
|
||||
# Docker published ports hit eth0 and cannot reach 127.0.0.1)
|
||||
# - a marker under /var/lib/ai-memory survives systemctl restart
|
||||
# - optional: the same marker survives a recreate with a named volume
|
||||
#
|
||||
# Boot race: right after /init, /run/current-system/sw/bin is not linked yet.
|
||||
# wait_for_exec_ready polls until systemctl is executable (stderr quiet during
|
||||
# that window). Early OCI "systemctl: not found in $PATH" lines are boot lag,
|
||||
# not a unit failure — they must not appear once readiness is explicit.
|
||||
#
|
||||
# Non-goals (do not extend this script for them):
|
||||
# - A→B flake/package upgrade or SQLite-wiki migration matrices
|
||||
# - Exhaustive settings-options.nix key coverage
|
||||
# - Soft/fake systemd without a real unit start
|
||||
# - Claiming the app Docker image covers the NixOS module path
|
||||
# - Re-asserting every sandbox key (covered by module evaluation)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
IMAGE_NAME="${AI_MEMORY_NIXOS_TEST_IMAGE:-ai-memory-nixos-test}"
|
||||
CONTAINER_NAME="${AI_MEMORY_NIXOS_TEST_CONTAINER:-ai-memory-nixos-systemd-test}"
|
||||
VOLUME_NAME="${AI_MEMORY_NIXOS_TEST_VOLUME_NAME:-ai-memory-nixos-test-data}"
|
||||
KEEP="${AI_MEMORY_NIXOS_TEST_KEEP:-0}"
|
||||
# Default on: one volume remount persistence pass. Set 0 to skip.
|
||||
TEST_VOLUME="${AI_MEMORY_NIXOS_TEST_VOLUME:-1}"
|
||||
# Probed via docker exec against the unit's loopback bind (not host-mapped).
|
||||
HEALTH_URL="http://127.0.0.1:49374/healthz"
|
||||
DATA_DIR="/var/lib/ai-memory"
|
||||
MARKER_PATH="${DATA_DIR}/.ai-memory-nixos-ci-marker"
|
||||
MARKER_VALUE="nixos-container-smoke"
|
||||
|
||||
ENGINE=""
|
||||
ROOTFS=""
|
||||
BUILT_IMAGE=0
|
||||
# docker import leaves no image ENV PATH. Inject the NixOS system bin dirs
|
||||
# on every exec (nixpkgs docker-image.nix documents /run/current-system/…).
|
||||
# Profile path covers the brief window before activation links current-system.
|
||||
NIXOS_PATH="/run/current-system/sw/bin:/nix/var/nix/profiles/system/sw/bin:/bin"
|
||||
|
||||
log() {
|
||||
printf '\n==> %s\n' "$*"
|
||||
}
|
||||
|
||||
fail() {
|
||||
printf 'error: %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
ctr_exec() {
|
||||
"${ENGINE}" exec -e "PATH=${NIXOS_PATH}" "${CONTAINER_NAME}" "$@"
|
||||
}
|
||||
|
||||
ctr_exec_root() {
|
||||
"${ENGINE}" exec -u root -e "PATH=${NIXOS_PATH}" "${CONTAINER_NAME}" "$@"
|
||||
}
|
||||
|
||||
repo_root() {
|
||||
local script_dir
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "${script_dir}/.." && pwd
|
||||
}
|
||||
|
||||
detect_engine() {
|
||||
if [ -n "${AI_MEMORY_DOCKER:-}" ]; then
|
||||
ENGINE="${AI_MEMORY_DOCKER}"
|
||||
return 0
|
||||
fi
|
||||
if command -v docker >/dev/null 2>&1; then
|
||||
ENGINE=docker
|
||||
return 0
|
||||
fi
|
||||
if command -v podman >/dev/null 2>&1; then
|
||||
ENGINE=podman
|
||||
return 0
|
||||
fi
|
||||
fail "need docker or podman on PATH (or set AI_MEMORY_DOCKER)"
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
if [ "${KEEP}" = "1" ]; then
|
||||
log "Keeping container ${CONTAINER_NAME} (AI_MEMORY_NIXOS_TEST_KEEP=1)"
|
||||
return 0
|
||||
fi
|
||||
if [ -n "${ENGINE}" ]; then
|
||||
"${ENGINE}" rm -f "${CONTAINER_NAME}" >/dev/null 2>&1 || true
|
||||
if [ "${TEST_VOLUME}" = "1" ]; then
|
||||
"${ENGINE}" volume rm -f "${VOLUME_NAME}" >/dev/null 2>&1 || true
|
||||
fi
|
||||
if [ "${BUILT_IMAGE}" = "1" ] && [ "${KEEP}" != "1" ]; then
|
||||
"${ENGINE}" rmi -f "${IMAGE_NAME}" >/dev/null 2>&1 || true
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# Activation links /run/current-system a few seconds after /init. Until then,
|
||||
# docker exec with NIXOS_PATH fails with OCI "executable file not found".
|
||||
# Poll quietly; fail hard if PATH never appears.
|
||||
wait_for_exec_ready() {
|
||||
local i
|
||||
for i in $(seq 1 120); do
|
||||
if ctr_exec test -x /run/current-system/sw/bin/systemctl >/dev/null 2>&1; then
|
||||
log "PATH ready (systemctl executable)"
|
||||
return 0
|
||||
fi
|
||||
sleep 0.5
|
||||
done
|
||||
fail "timed out waiting for /run/current-system/sw/bin/systemctl (activation PATH never appeared)"
|
||||
}
|
||||
|
||||
wait_for_http() {
|
||||
local url="$1"
|
||||
local i
|
||||
for i in $(seq 1 120); do
|
||||
if ctr_exec curl -fsS "${url}" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
sleep 0.5
|
||||
done
|
||||
ctr_exec journalctl -u ai-memory --no-pager -n 120 >&2 || true
|
||||
fail "timed out waiting for in-container ${url}"
|
||||
}
|
||||
|
||||
wait_for_active() {
|
||||
local i
|
||||
for i in $(seq 1 120); do
|
||||
if ctr_exec systemctl is-active --quiet ai-memory; then
|
||||
return 0
|
||||
fi
|
||||
sleep 0.5
|
||||
done
|
||||
ctr_exec systemctl status ai-memory --no-pager >&2 || true
|
||||
ctr_exec journalctl -u ai-memory --no-pager -n 120 >&2 || true
|
||||
fail "timed out waiting for systemctl is-active ai-memory"
|
||||
}
|
||||
|
||||
run_container() {
|
||||
local extra_args=("$@")
|
||||
# Privileged + host cgroup namespace: systemd as PID 1 on cgroup v2 hosts
|
||||
# (including GitHub Actions ubuntu-latest) needs this to activate units.
|
||||
# No -p: default --bind is 127.0.0.1; published ports never reach it.
|
||||
"${ENGINE}" run -d --name "${CONTAINER_NAME}" \
|
||||
--privileged \
|
||||
--cgroupns=host \
|
||||
-v /sys/fs/cgroup:/sys/fs/cgroup:rw \
|
||||
"${extra_args[@]}" \
|
||||
"${IMAGE_NAME}" /init
|
||||
}
|
||||
|
||||
write_marker() {
|
||||
# PATH is injected so chown/coreutils resolve after activation.
|
||||
ctr_exec_root /bin/sh -c \
|
||||
"printf '%s\n' '${MARKER_VALUE}' > '${MARKER_PATH}' && chown ai-memory:ai-memory '${MARKER_PATH}'"
|
||||
}
|
||||
|
||||
assert_marker() {
|
||||
local got
|
||||
got="$(ctr_exec cat "${MARKER_PATH}")"
|
||||
if [ "${got}" != "${MARKER_VALUE}" ]; then
|
||||
fail "marker mismatch at ${MARKER_PATH}: expected '${MARKER_VALUE}', got '${got}'"
|
||||
fi
|
||||
}
|
||||
|
||||
smoke_once() {
|
||||
wait_for_exec_ready
|
||||
|
||||
log "Waiting for ai-memory unit"
|
||||
wait_for_active
|
||||
ctr_exec systemctl is-active ai-memory
|
||||
|
||||
log "Waiting for in-container ${HEALTH_URL}"
|
||||
wait_for_http "${HEALTH_URL}"
|
||||
ctr_exec curl -fsS "${HEALTH_URL}" >/dev/null
|
||||
|
||||
log "Writing marker and restarting ai-memory"
|
||||
write_marker
|
||||
ctr_exec systemctl restart ai-memory
|
||||
wait_for_active
|
||||
wait_for_http "${HEALTH_URL}"
|
||||
assert_marker
|
||||
log "Marker survived systemctl restart"
|
||||
}
|
||||
|
||||
main() {
|
||||
detect_engine
|
||||
trap cleanup EXIT
|
||||
|
||||
local root
|
||||
root="$(repo_root)"
|
||||
cd "${root}"
|
||||
|
||||
case "$(uname -s)-$(uname -m)" in
|
||||
Linux-x86_64 | Linux-amd64) ;;
|
||||
*)
|
||||
fail "NixOS container smoke is Linux x86_64 only (got $(uname -s)-$(uname -m))"
|
||||
;;
|
||||
esac
|
||||
|
||||
log "Building packages.x86_64-linux.nixos-ai-memory-docker"
|
||||
nix build --print-build-logs '.#packages.x86_64-linux.nixos-ai-memory-docker'
|
||||
ROOTFS="$(pwd)/result/rootfs.tar.xz"
|
||||
if [ ! -e "${ROOTFS}" ]; then
|
||||
fail "missing rootfs tarball at ${ROOTFS}"
|
||||
fi
|
||||
if [ -f "$(pwd)/result/image-name" ]; then
|
||||
IMAGE_NAME="$(tr -d '[:space:]' <"$(pwd)/result/image-name")"
|
||||
fi
|
||||
|
||||
cleanup
|
||||
# Re-arm trap after cleanup cleared a previous container.
|
||||
trap cleanup EXIT
|
||||
|
||||
log "Importing rootfs into ${ENGINE} as ${IMAGE_NAME}"
|
||||
"${ENGINE}" import "${ROOTFS}" "${IMAGE_NAME}"
|
||||
BUILT_IMAGE=1
|
||||
|
||||
log "Starting privileged NixOS container ${CONTAINER_NAME}"
|
||||
run_container
|
||||
|
||||
smoke_once
|
||||
|
||||
if [ "${TEST_VOLUME}" = "1" ]; then
|
||||
log "Recreating with named volume ${VOLUME_NAME} at ${DATA_DIR}"
|
||||
"${ENGINE}" rm -f "${CONTAINER_NAME}" >/dev/null
|
||||
"${ENGINE}" volume rm -f "${VOLUME_NAME}" >/dev/null 2>&1 || true
|
||||
"${ENGINE}" volume create "${VOLUME_NAME}" >/dev/null
|
||||
|
||||
run_container -v "${VOLUME_NAME}:${DATA_DIR}"
|
||||
wait_for_exec_ready
|
||||
wait_for_active
|
||||
wait_for_http "${HEALTH_URL}"
|
||||
write_marker
|
||||
|
||||
log "Remounting volume and checking marker survival"
|
||||
"${ENGINE}" rm -f "${CONTAINER_NAME}" >/dev/null
|
||||
run_container -v "${VOLUME_NAME}:${DATA_DIR}"
|
||||
wait_for_exec_ready
|
||||
wait_for_active
|
||||
wait_for_http "${HEALTH_URL}"
|
||||
assert_marker
|
||||
log "Marker survived volume remount"
|
||||
fi
|
||||
|
||||
log "NixOS systemd container smoke passed"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Reference in New Issue
Block a user