Merge pull request #989 from rubenlr/nixos-module

This commit is contained in:
AkitaOnRails
2026-10-01 13:05:34 -03:00
14 changed files with 1348 additions and 55 deletions
+7
View File
@@ -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
+53 -8
View File
@@ -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
+5 -1
View File
@@ -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
View File
@@ -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
+57
View File
@@ -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
+57
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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
}
+335 -44
View File
@@ -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;
};
}
+334
View File
@@ -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;
})
];
};
}
]
);
}
+61
View File
@@ -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.
'';
};
}
+34
View File
@@ -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";
};
}
+59
View File
@@ -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 "$@"
+253
View File
@@ -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 "$@"