Compare commits
8 Commits
master
...
9adfa185bb
| Author | SHA1 | Date | |
|---|---|---|---|
| 9adfa185bb | |||
|
|
a1b6950e93 | ||
| 25a44e79fa | |||
|
|
c615eb5c1e | ||
| aa084be01a | |||
|
|
3794500230 | ||
| 0867492170 | |||
|
|
cab1f73c89 |
Binary file not shown.
BIN
.cache/nix/fetcher-cache-v4.sqlite
Normal file
BIN
.cache/nix/fetcher-cache-v4.sqlite
Normal file
Binary file not shown.
2
.pi-lens/cache/jscpd.meta.json
vendored
2
.pi-lens/cache/jscpd.meta.json
vendored
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"timestamp": "2026-04-11T04:17:20.531Z"
|
||||
"timestamp": "2026-04-15T09:30:34.459Z"
|
||||
}
|
||||
2
.pi-lens/cache/knip.meta.json
vendored
2
.pi-lens/cache/knip.meta.json
vendored
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"timestamp": "2026-04-11T04:17:21.374Z"
|
||||
"timestamp": "2026-04-15T09:30:35.667Z"
|
||||
}
|
||||
4
.pi-lens/cache/session-start-guidance.json
vendored
4
.pi-lens/cache/session-start-guidance.json
vendored
@@ -1,3 +1 @@
|
||||
{
|
||||
"content": "📌 pi-lens active — as you work on this project, fix any errors you encounter (including pre-existing). Prefer: lsp_navigation for definitions/references, ast_grep_search for code patterns, grep for text/TODO search."
|
||||
}
|
||||
null
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"timestamp": "2026-04-11T04:21:36.939Z"
|
||||
"timestamp": "2026-04-15T09:28:51.987Z"
|
||||
}
|
||||
2
.pi-lens/cache/todo-baseline.meta.json
vendored
2
.pi-lens/cache/todo-baseline.meta.json
vendored
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"timestamp": "2026-04-11T04:21:36.940Z"
|
||||
"timestamp": "2026-04-15T09:28:16.965Z"
|
||||
}
|
||||
@@ -2,5 +2,5 @@
|
||||
"files": {},
|
||||
"turnCycles": 0,
|
||||
"maxCycles": 3,
|
||||
"lastUpdated": "2026-04-11T04:17:22.397Z"
|
||||
"lastUpdated": "2026-04-15T09:30:35.668Z"
|
||||
}
|
||||
99
docs/guides/pi-agent-isolation.md
Normal file
99
docs/guides/pi-agent-isolation.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# Pi Agent Isolation (two-repo setup)
|
||||
|
||||
This guide documents the split setup where:
|
||||
|
||||
- `m3ta-nixpkgs` provides reusable module logic.
|
||||
- `nixos-config` consumes it on specific hosts.
|
||||
|
||||
## 1) In `m3ta-nixpkgs`
|
||||
|
||||
Use:
|
||||
|
||||
- Home Manager module: `coding.agents.pi`
|
||||
- renders Pi config in user space (default path: `.pi/agent` => `~/.pi/agent`)
|
||||
- NixOS module: `m3ta.pi-agent`
|
||||
- dedicated user/group (default `pi-agent`)
|
||||
- state directory (default `/var/lib/pi-agent`)
|
||||
- hardened execution via transient `systemd-run`
|
||||
- host-side wrapper command (default `pi`)
|
||||
- per-user allowlists via `hostUsers.<name>.projectRoots`
|
||||
- host config sync into isolated runtime (default source `.pi/agent`)
|
||||
- managed settings/env merge into isolated runtime
|
||||
|
||||
## 2) In consumer repo (`nixos-config`)
|
||||
|
||||
### Home Manager side
|
||||
|
||||
Keep Pi config rendering enabled for your normal user:
|
||||
|
||||
```nix
|
||||
coding.agents.pi = {
|
||||
enable = true;
|
||||
agentsInput = inputs.agents;
|
||||
path = ".pi/agent";
|
||||
};
|
||||
```
|
||||
|
||||
### NixOS host side (example: `m3-kratos`)
|
||||
|
||||
Enable isolated wrapper execution:
|
||||
|
||||
```nix
|
||||
m3ta.pi-agent = {
|
||||
enable = true;
|
||||
stateDir = "/var/lib/pi-agent";
|
||||
|
||||
hostUsers = {
|
||||
m3tam3re = {
|
||||
projectRoots = ["~/p" "~/work/private"];
|
||||
# optional; defaults to wrapper.hostConfigPath
|
||||
configPath = ".pi/agent";
|
||||
};
|
||||
};
|
||||
|
||||
settings = {
|
||||
defaultProvider = "anthropic";
|
||||
defaultModel = "anthropic/claude-sonnet-4";
|
||||
quietStartup = true;
|
||||
};
|
||||
|
||||
environment = {
|
||||
PI_TELEMETRY = "0";
|
||||
};
|
||||
|
||||
environmentFiles = [
|
||||
"/run/secrets/pi-agent.env"
|
||||
];
|
||||
|
||||
wrapper = {
|
||||
enable = true;
|
||||
commandName = "pi";
|
||||
hideDirectBinary = true;
|
||||
hostConfigPath = ".pi/agent";
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
## 3) Authorization model
|
||||
|
||||
The wrapper uses a tightly scoped sudo rule:
|
||||
|
||||
- authorized users may run only the privileged runner command
|
||||
- with `NOPASSWD`
|
||||
- no broad `NOPASSWD: ALL`
|
||||
|
||||
## 4) Merge behavior
|
||||
|
||||
At invocation time, isolated runtime files are built from:
|
||||
|
||||
1. Host user Pi config (synced from source path, e.g. `~/.pi/agent`)
|
||||
2. Nix-managed settings/env (override host values)
|
||||
3. Environment files (appended after managed env attrs)
|
||||
|
||||
This keeps user-authored Pi config available while allowing reproducible Nix overrides.
|
||||
|
||||
## 5) Migration notes
|
||||
|
||||
- If wrapper mode is canonical, remove direct `pi-coding-agent` from user package lists to reduce command-path ambiguity.
|
||||
- Rebuild host config and test from an allowlisted project path.
|
||||
- Validate `pi` process identity runs as `pi-agent`.
|
||||
@@ -157,6 +157,32 @@ m3ta.mem0 = {
|
||||
|
||||
**Documentation**: [mem0 Module](../modules/nixos/mem0.md)
|
||||
|
||||
#### `m3ta.pi-agent`
|
||||
|
||||
Isolated Pi execution with a dedicated system user (`pi-agent` by default),
|
||||
a hardened runtime, and a host-side `pi` wrapper command.
|
||||
|
||||
```nix
|
||||
m3ta.pi-agent = {
|
||||
enable = true;
|
||||
stateDir = "/var/lib/pi-agent";
|
||||
|
||||
hostUsers = {
|
||||
m3tam3re = {
|
||||
projectRoots = ["~/p" "~/work/private"];
|
||||
configPath = ".pi/agent"; # optional
|
||||
};
|
||||
};
|
||||
|
||||
settings.defaultModel = "anthropic/claude-sonnet-4";
|
||||
environment.PI_TELEMETRY = "0";
|
||||
wrapper.commandName = "pi";
|
||||
wrapper.hideDirectBinary = true;
|
||||
};
|
||||
```
|
||||
|
||||
**Documentation**: [Pi Agent Isolation Guide](./pi-agent-isolation.md)
|
||||
|
||||
### Home Manager Modules
|
||||
|
||||
#### `m3ta.ports`
|
||||
@@ -255,6 +281,7 @@ Pi agent deployment from canonical TOML definitions.
|
||||
coding.agents.pi = {
|
||||
enable = true;
|
||||
agentsInput = inputs.agents;
|
||||
path = ".pi/agent"; # default; can be changed
|
||||
};
|
||||
```
|
||||
|
||||
|
||||
@@ -72,6 +72,7 @@
|
||||
# Individual modules for selective imports
|
||||
ports = ./modules/nixos/ports.nix;
|
||||
mem0 = ./modules/nixos/mem0.nix;
|
||||
pi-agent = ./modules/nixos/pi-agent.nix;
|
||||
};
|
||||
|
||||
# Home Manager modules - for user-level configuration
|
||||
|
||||
@@ -119,23 +119,26 @@ coding.agents.claude-code = {
|
||||
enable = true;
|
||||
agentsInput = inputs.agents;
|
||||
modelOverrides = {};
|
||||
externalSkills = [{ src = inputs.skills-anthropic; }];
|
||||
};
|
||||
```
|
||||
|
||||
**Options:** `enable`, `agentsInput`, `modelOverrides`
|
||||
**Options:** `enable`, `agentsInput`, `modelOverrides`, `externalSkills`
|
||||
|
||||
### Pi (`coding.agents.pi`)
|
||||
|
||||
Renders `AGENTS.md` + `SYSTEM.md` to `~/.pi/agent/`:
|
||||
Renders `AGENTS.md` + `SYSTEM.md` to `~/.pi/agent/` by default:
|
||||
|
||||
```nix
|
||||
coding.agents.pi = {
|
||||
enable = true;
|
||||
agentsInput = inputs.agents;
|
||||
path = ".pi/agent"; # default, relative to $HOME
|
||||
externalSkills = [{ src = inputs.skills-anthropic; }];
|
||||
};
|
||||
```
|
||||
|
||||
**Options:** `enable`, `agentsInput`
|
||||
**Options:** `enable`, `path`, `agentsInput`, `modelOverrides`, `externalSkills`, `primaryAgent`, `mcpServers`, `settings`
|
||||
|
||||
### Project-level usage
|
||||
|
||||
|
||||
@@ -36,6 +36,44 @@ in {
|
||||
'';
|
||||
};
|
||||
|
||||
externalSkills = mkOption {
|
||||
type = types.listOf (types.submodule {
|
||||
options = {
|
||||
src = mkOption {
|
||||
type = types.anything;
|
||||
description = "Flake input pointing to a skills repository root.";
|
||||
};
|
||||
skillsDir = mkOption {
|
||||
type = types.str;
|
||||
default = "skills";
|
||||
description = ''
|
||||
Subdirectory inside src that contains skill folders.
|
||||
'';
|
||||
};
|
||||
selectSkills = mkOption {
|
||||
type = types.nullOr (types.listOf types.str);
|
||||
default = null;
|
||||
description = ''
|
||||
List of skill names to cherry-pick from this source.
|
||||
null means include every skill found in skillsDir.
|
||||
'';
|
||||
};
|
||||
};
|
||||
});
|
||||
default = [];
|
||||
description = ''
|
||||
External skill sources passed to mkOpencodeSkills.
|
||||
Each entry maps directly to an element of the externalSkills
|
||||
list accepted by the AGENTS flake's lib.mkOpencodeSkills.
|
||||
'';
|
||||
example = literalExpression ''
|
||||
[
|
||||
{ src = inputs.skills-anthropic; selectSkills = [ "claude-api" ]; }
|
||||
{ src = inputs.skills-vercel; }
|
||||
]
|
||||
'';
|
||||
};
|
||||
|
||||
mcpServers = mkOption {
|
||||
type = types.attrsOf types.anything;
|
||||
default = if mcpCfg != null then mcpCfg.servers else {};
|
||||
@@ -82,6 +120,21 @@ in {
|
||||
source = "${rendered}/.claude/agents";
|
||||
};
|
||||
|
||||
# Skills (merged from personal AGENTS repo + optional external skills)
|
||||
home.file.".claude/skills" = mkIf (cfg.agentsInput != null) {
|
||||
source = cfg.agentsInput.lib.mkOpencodeSkills {
|
||||
inherit pkgs;
|
||||
customSkills = "${cfg.agentsInput}/skills";
|
||||
externalSkills =
|
||||
map (
|
||||
entry:
|
||||
{inherit (entry) src skillsDir;}
|
||||
// optionalAttrs (entry.selectSkills != null) {inherit (entry) selectSkills;}
|
||||
)
|
||||
cfg.externalSkills;
|
||||
};
|
||||
};
|
||||
|
||||
# Rendered settings.json with permissions + MCP servers
|
||||
home.file.".claude/settings.json" = mkIf (settingsJson != null) {
|
||||
source = "${settingsJson}";
|
||||
|
||||
@@ -11,13 +11,28 @@ in {
|
||||
options.coding.agents.pi = {
|
||||
enable = mkEnableOption "Pi agent management via canonical agent.toml definitions";
|
||||
|
||||
path = mkOption {
|
||||
type = types.str;
|
||||
default = ".pi/agent";
|
||||
description = ''
|
||||
Relative path (inside the Home Manager user's home) where Pi agent
|
||||
config should be materialized.
|
||||
|
||||
Defaults to `.pi/agent`, i.e. `~/.pi/agent`.
|
||||
'';
|
||||
example = ".config/pi/agent";
|
||||
};
|
||||
|
||||
mcpServers = mkOption {
|
||||
type = types.attrsOf types.anything;
|
||||
default = if mcpCfg != null then mcpCfg.servers else {};
|
||||
default =
|
||||
if mcpCfg != null
|
||||
then mcpCfg.servers
|
||||
else {};
|
||||
defaultText = literalExpression "config.programs.mcp.servers";
|
||||
description = ''
|
||||
MCP server configurations for Pi (pi-mcp-adapter).
|
||||
Written to ~/.pi/agent/mcp.json.
|
||||
Written to `${cfg.path}/mcp.json`.
|
||||
Automatically inherits from config.programs.mcp.servers.
|
||||
'';
|
||||
};
|
||||
@@ -42,6 +57,44 @@ in {
|
||||
'';
|
||||
};
|
||||
|
||||
externalSkills = mkOption {
|
||||
type = types.listOf (types.submodule {
|
||||
options = {
|
||||
src = mkOption {
|
||||
type = types.anything;
|
||||
description = "Flake input pointing to a skills repository root.";
|
||||
};
|
||||
skillsDir = mkOption {
|
||||
type = types.str;
|
||||
default = "skills";
|
||||
description = ''
|
||||
Subdirectory inside src that contains skill folders.
|
||||
'';
|
||||
};
|
||||
selectSkills = mkOption {
|
||||
type = types.nullOr (types.listOf types.str);
|
||||
default = null;
|
||||
description = ''
|
||||
List of skill names to cherry-pick from this source.
|
||||
null means include every skill found in skillsDir.
|
||||
'';
|
||||
};
|
||||
};
|
||||
});
|
||||
default = [];
|
||||
description = ''
|
||||
External skill sources passed to mkOpencodeSkills.
|
||||
Each entry maps directly to an element of the externalSkills
|
||||
list accepted by the AGENTS flake's lib.mkOpencodeSkills.
|
||||
'';
|
||||
example = literalExpression ''
|
||||
[
|
||||
{ src = inputs.skills-anthropic; selectSkills = [ "claude-api" ]; }
|
||||
{ src = inputs.skills-vercel; }
|
||||
]
|
||||
'';
|
||||
};
|
||||
|
||||
primaryAgent = mkOption {
|
||||
type = types.nullOr types.str;
|
||||
default = null;
|
||||
@@ -60,7 +113,7 @@ in {
|
||||
default = [];
|
||||
description = ''
|
||||
Pi packages to install (npm:, git:, or local paths).
|
||||
These are written to ~/.pi/agent/settings.json.
|
||||
These are written to `${cfg.path}/settings.json`.
|
||||
'';
|
||||
};
|
||||
|
||||
@@ -148,7 +201,7 @@ in {
|
||||
};
|
||||
default = {};
|
||||
description = ''
|
||||
Pi settings written to ~/.pi/agent/settings.json.
|
||||
Pi settings written to `${cfg.path}/settings.json`.
|
||||
Only non-null values are included in the generated JSON.
|
||||
See pi docs/settings.md for all options.
|
||||
'';
|
||||
@@ -156,6 +209,8 @@ in {
|
||||
};
|
||||
|
||||
config = mkIf cfg.enable (let
|
||||
basePath = lib.removeSuffix "/" cfg.path;
|
||||
|
||||
# Build settings.json by filtering out null values recursively
|
||||
filterNulls = attrs:
|
||||
lib.filterAttrs (_: v: v != null) (
|
||||
@@ -164,8 +219,11 @@ in {
|
||||
then let
|
||||
filtered = filterNulls v;
|
||||
in
|
||||
if filtered == {} then null else filtered
|
||||
else v) attrs
|
||||
if filtered == {}
|
||||
then null
|
||||
else filtered
|
||||
else v)
|
||||
attrs
|
||||
);
|
||||
|
||||
piSettings = filterNulls cfg.settings;
|
||||
@@ -185,38 +243,37 @@ in {
|
||||
# Dynamic home.file entries for agent .md files
|
||||
agentFiles =
|
||||
if cfg.agentsInput != null
|
||||
then
|
||||
let
|
||||
agentNames = builtins.attrNames cfg.agentsInput.lib.loadAgents;
|
||||
in
|
||||
builtins.listToAttrs (
|
||||
map (name: {
|
||||
name = ".pi/agent/agents/${name}.md";
|
||||
value = {source = "${rendered}/agents/${name}.md";};
|
||||
})
|
||||
agentNames
|
||||
)
|
||||
then let
|
||||
agentNames = builtins.attrNames cfg.agentsInput.lib.loadAgents;
|
||||
in
|
||||
builtins.listToAttrs (
|
||||
map (name: {
|
||||
name = "${basePath}/agents/${name}.md";
|
||||
value = {source = "${rendered}/agents/${name}.md";};
|
||||
})
|
||||
agentNames
|
||||
)
|
||||
else {};
|
||||
in {
|
||||
home.file = mkMerge [
|
||||
# ── MCP servers from programs.mcp → ~/.pi/agent/mcp.json ───────
|
||||
# ── MCP servers from programs.mcp → ${cfg.path}/mcp.json ───────
|
||||
(mkIf (cfg.mcpServers != {}) {
|
||||
".pi/agent/mcp.json".text = builtins.toJSON {mcpServers = cfg.mcpServers;};
|
||||
"${basePath}/mcp.json".text = builtins.toJSON {mcpServers = cfg.mcpServers;};
|
||||
})
|
||||
|
||||
# ── ~/.pi/agent/settings.json ──────────────────────────────────
|
||||
# ── ${cfg.path}/settings.json ──────────────────────────────────
|
||||
{
|
||||
".pi/agent/settings.json".text = builtins.toJSON piSettings;
|
||||
"${basePath}/settings.json".text = builtins.toJSON piSettings;
|
||||
}
|
||||
|
||||
# ── AGENTS.md — agent descriptions and specialist listing ──────
|
||||
(mkIf (cfg.agentsInput != null) {
|
||||
".pi/agent/AGENTS.md".source = "${rendered}/AGENTS.md";
|
||||
"${basePath}/AGENTS.md".source = "${rendered}/AGENTS.md";
|
||||
})
|
||||
|
||||
# ── SYSTEM.md — primary agent's system prompt ──────────────────
|
||||
(mkIf (cfg.agentsInput != null) {
|
||||
".pi/agent/SYSTEM.md".source = "${rendered}/SYSTEM.md";
|
||||
"${basePath}/SYSTEM.md".source = "${rendered}/SYSTEM.md";
|
||||
})
|
||||
|
||||
# ── Agents — pi-subagents .md files ────────────────────────────
|
||||
@@ -224,9 +281,16 @@ in {
|
||||
|
||||
# ── Skills symlinked from AGENTS repo ──────────────────────────
|
||||
(mkIf (cfg.agentsInput != null) {
|
||||
".pi/agent/skills".source = cfg.agentsInput.lib.mkOpencodeSkills {
|
||||
"${basePath}/skills".source = cfg.agentsInput.lib.mkOpencodeSkills {
|
||||
inherit pkgs;
|
||||
customSkills = "${cfg.agentsInput}/skills";
|
||||
externalSkills =
|
||||
map (
|
||||
entry:
|
||||
{inherit (entry) src skillsDir;}
|
||||
// optionalAttrs (entry.selectSkills != null) {inherit (entry) selectSkills;}
|
||||
)
|
||||
cfg.externalSkills;
|
||||
};
|
||||
})
|
||||
];
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
imports = [
|
||||
./mem0.nix
|
||||
./ports.nix
|
||||
./pi-agent.nix
|
||||
# Example: ./my-service.nix
|
||||
# Add more module files here as you create them
|
||||
];
|
||||
|
||||
748
modules/nixos/pi-agent.nix
Normal file
748
modules/nixos/pi-agent.nix
Normal file
@@ -0,0 +1,748 @@
|
||||
# NixOS Module for isolated Pi execution (fresh design)
|
||||
#
|
||||
# Goals:
|
||||
# - Dedicated isolated runtime identity (pi-agent user/group)
|
||||
# - Host UX via `pi` wrapper command
|
||||
# - Per-host-user project allowlists (different roots per user)
|
||||
# - No container mode
|
||||
# - Merge user Pi config + Nix-managed settings/env into isolated runtime
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
with lib; let
|
||||
cfg = config.m3ta.pi-agent;
|
||||
|
||||
hostUserNames = attrNames cfg.hostUsers;
|
||||
|
||||
managedSettingsFile = pkgs.writeText "pi-agent-managed-settings.json" (builtins.toJSON cfg.settings);
|
||||
|
||||
managedEnvFile =
|
||||
pkgs.writeText "pi-agent-managed.env"
|
||||
(concatStringsSep "\n" (mapAttrsToList (k: v: "${k}=${v}") cfg.environment));
|
||||
|
||||
runtimePath = concatStringsSep ":" (
|
||||
[
|
||||
"${cfg.package}/bin"
|
||||
"${pkgs.nodejs}/bin"
|
||||
"${pkgs.git}/bin"
|
||||
"${pkgs.coreutils}/bin"
|
||||
"${pkgs.findutils}/bin"
|
||||
"${pkgs.gnugrep}/bin"
|
||||
"${pkgs.gnused}/bin"
|
||||
"${pkgs.util-linux}/bin"
|
||||
"/run/current-system/sw/bin"
|
||||
]
|
||||
++ map (p: "${p}/bin") cfg.extraPackages
|
||||
);
|
||||
|
||||
userPolicyCase = concatStringsSep "\n" (
|
||||
mapAttrsToList (
|
||||
user: userCfg: ''
|
||||
${escapeShellArg user})
|
||||
USER_CONFIG_PATH=${escapeShellArg (
|
||||
if userCfg.configPath != null
|
||||
then userCfg.configPath
|
||||
else cfg.wrapper.hostConfigPath
|
||||
)}
|
||||
USER_ROOTS=(${concatStringsSep " " (map escapeShellArg userCfg.projectRoots)})
|
||||
;;
|
||||
''
|
||||
)
|
||||
cfg.hostUsers
|
||||
);
|
||||
|
||||
runner = pkgs.writeShellScriptBin cfg.wrapper.runnerName ''
|
||||
set -euo pipefail
|
||||
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
echo "${cfg.wrapper.runnerName} must run as root" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$#" -lt 2 ]; then
|
||||
echo "Usage: ${cfg.wrapper.runnerName} <invoking-user> <cwd> [pi-args...]" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
invoking_user="$1"
|
||||
shift
|
||||
cwd="$1"
|
||||
shift
|
||||
|
||||
resolve_user_policy() {
|
||||
local user="$1"
|
||||
USER_CONFIG_PATH=""
|
||||
USER_ROOTS=()
|
||||
case "$user" in
|
||||
${userPolicyCase}
|
||||
*)
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
return 0
|
||||
}
|
||||
|
||||
if ! resolve_user_policy "$invoking_user"; then
|
||||
echo "User '$invoking_user' is not allowed to use ${cfg.wrapper.commandName}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
user_home="$(eval echo "~$invoking_user")"
|
||||
if [ -z "$user_home" ] || [ "$user_home" = "~$invoking_user" ]; then
|
||||
echo "Unable to determine home directory for user '$invoking_user'" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
expand_home_path() {
|
||||
local input="$1"
|
||||
if [ "$input" = "~" ]; then
|
||||
printf '%s\n' "$user_home"
|
||||
elif ${pkgs.gnugrep}/bin/grep -q '^~/' <<<"$input"; then
|
||||
printf '%s\n' "$user_home/''${input:2}"
|
||||
elif ${pkgs.gnugrep}/bin/grep -q '^/' <<<"$input"; then
|
||||
printf '%s\n' "$input"
|
||||
else
|
||||
# Bare relative path → resolve from user's home
|
||||
printf '%s\n' "$user_home/$input"
|
||||
fi
|
||||
}
|
||||
|
||||
cwd_real="$(${pkgs.coreutils}/bin/realpath -m "$cwd")"
|
||||
|
||||
resolved_roots=()
|
||||
skipped_roots=()
|
||||
is_allowed_cwd=0
|
||||
for configured_root in "''${USER_ROOTS[@]}"; do
|
||||
expanded_root="$(expand_home_path "$configured_root")"
|
||||
resolved_root="$(${pkgs.coreutils}/bin/realpath -m "$expanded_root")"
|
||||
if [ ! -d "$resolved_root" ]; then
|
||||
skipped_roots+=("$resolved_root")
|
||||
continue
|
||||
fi
|
||||
resolved_roots+=("$resolved_root")
|
||||
case "$cwd_real/" in
|
||||
"$resolved_root"/*)
|
||||
is_allowed_cwd=1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ "''${#resolved_roots[@]}" -eq 0 ]; then
|
||||
echo "Denied: no valid existing project roots are configured for user '$invoking_user'." >&2
|
||||
if [ "''${#skipped_roots[@]}" -gt 0 ]; then
|
||||
echo "Configured but missing roots:" >&2
|
||||
for root in "''${skipped_roots[@]}"; do
|
||||
echo " - $root" >&2
|
||||
done
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$is_allowed_cwd" -ne 1 ]; then
|
||||
echo "Denied: '$cwd_real' is outside allowed project roots for user '$invoking_user'." >&2
|
||||
echo "Allowed roots:" >&2
|
||||
for root in "''${resolved_roots[@]}"; do
|
||||
echo " - $root" >&2
|
||||
done
|
||||
exit 1
|
||||
fi
|
||||
|
||||
${pkgs.coreutils}/bin/install -d -m 0750 -o ${escapeShellArg cfg.user} -g ${escapeShellArg cfg.group} \
|
||||
${escapeShellArg cfg.stateDir} \
|
||||
${escapeShellArg "${cfg.stateDir}/.pi"} \
|
||||
${escapeShellArg "${cfg.stateDir}/.pi/agent"} \
|
||||
${escapeShellArg "${cfg.stateDir}/.pi/agent/sessions"} \
|
||||
${escapeShellArg "${cfg.stateDir}/.project-mounts"} \
|
||||
${escapeShellArg "${cfg.stateDir}/projects"} \
|
||||
${escapeShellArg "${cfg.stateDir}/.npm"} \
|
||||
${escapeShellArg "${cfg.stateDir}/.npm-global"} \
|
||||
${escapeShellArg "${cfg.stateDir}/.npm-global/bin"} \
|
||||
${escapeShellArg "${cfg.stateDir}/.npm-global/lib"}
|
||||
|
||||
config_source="$USER_CONFIG_PATH"
|
||||
if ${pkgs.gnugrep}/bin/grep -q '^/' <<<"$config_source"; then
|
||||
source_dir="$config_source"
|
||||
else
|
||||
source_dir="$(expand_home_path "$config_source")"
|
||||
fi
|
||||
|
||||
|
||||
if [ "${
|
||||
if cfg.wrapper.syncConfigFromHost
|
||||
then "1"
|
||||
else "0"
|
||||
}" = "1" ] && [ -d "$source_dir" ]; then
|
||||
${pkgs.rsync}/bin/rsync -a --delete \
|
||||
--exclude='auth.json' \
|
||||
--exclude='mcp-oauth' \
|
||||
--exclude='sessions' \
|
||||
--exclude='bin' \
|
||||
--exclude='mcp-cache.json' \
|
||||
"$source_dir/" ${escapeShellArg "${cfg.stateDir}/.pi/agent/"}
|
||||
${pkgs.coreutils}/bin/chown -R ${escapeShellArg "${cfg.user}:${cfg.group}"} ${escapeShellArg "${cfg.stateDir}/.pi/agent"}
|
||||
fi
|
||||
|
||||
# Merge host settings.json (if any) with Nix-managed settings.
|
||||
# Precedence: host settings first, Nix-managed keys override recursively.
|
||||
settings_target=${escapeShellArg "${cfg.stateDir}/.pi/agent/settings.json"}
|
||||
${pkgs.python3}/bin/python3 - "$settings_target" ${escapeShellArg managedSettingsFile} <<'PY_PI_SETTINGS_MERGE'
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def load_obj(path):
|
||||
if not os.path.exists(path):
|
||||
return {}
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
return data if isinstance(data, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def deep_merge(base, override):
|
||||
if isinstance(base, dict) and isinstance(override, dict):
|
||||
out = dict(base)
|
||||
for key, value in override.items():
|
||||
out[key] = deep_merge(out.get(key), value)
|
||||
return out
|
||||
return override
|
||||
|
||||
|
||||
def main():
|
||||
target = sys.argv[1]
|
||||
managed = sys.argv[2]
|
||||
base_obj = load_obj(target)
|
||||
managed_obj = load_obj(managed)
|
||||
merged = deep_merge(base_obj, managed_obj)
|
||||
|
||||
os.makedirs(os.path.dirname(target), exist_ok=True)
|
||||
tmp = f"{target}.tmp"
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
json.dump(merged, f, indent=2, sort_keys=True)
|
||||
f.write("\n")
|
||||
os.replace(tmp, target)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
PY_PI_SETTINGS_MERGE
|
||||
${pkgs.coreutils}/bin/chown ${escapeShellArg "${cfg.user}:${cfg.group}"} "$settings_target"
|
||||
${pkgs.coreutils}/bin/chmod 0640 "$settings_target"
|
||||
|
||||
# Merge environment into isolated .env with precedence:
|
||||
# 1) synced host env (source_dir/.env)
|
||||
# 2) Nix-managed environment attrset
|
||||
# 3) Nix-managed environmentFiles (appended in declaration order)
|
||||
env_target=${escapeShellArg "${cfg.stateDir}/.pi/.env"}
|
||||
${pkgs.coreutils}/bin/install -o ${escapeShellArg cfg.user} -g ${escapeShellArg cfg.group} -m 0640 /dev/null "$env_target"
|
||||
|
||||
if [ -f "$source_dir/.env" ]; then
|
||||
${pkgs.coreutils}/bin/cat "$source_dir/.env" >> "$env_target"
|
||||
printf '\n' >> "$env_target"
|
||||
fi
|
||||
|
||||
if [ -f ${escapeShellArg managedEnvFile} ]; then
|
||||
${pkgs.coreutils}/bin/cat ${escapeShellArg managedEnvFile} >> "$env_target"
|
||||
printf '\n' >> "$env_target"
|
||||
fi
|
||||
|
||||
${concatStringsSep "\n" (map (f: ''
|
||||
if [ -f ${escapeShellArg f} ]; then
|
||||
${pkgs.coreutils}/bin/cat ${escapeShellArg f} >> "$env_target"
|
||||
printf '\n' >> "$env_target"
|
||||
fi
|
||||
'')
|
||||
cfg.environmentFiles)}
|
||||
|
||||
${pkgs.coreutils}/bin/chown ${escapeShellArg "${cfg.user}:${cfg.group}"} "$env_target"
|
||||
${pkgs.coreutils}/bin/chmod 0640 "$env_target"
|
||||
|
||||
npm_prefix=${escapeShellArg "${cfg.stateDir}/.npm-global"}
|
||||
runtime_path=${escapeShellArg runtimePath}
|
||||
|
||||
project_mount_dir=${escapeShellArg "${cfg.stateDir}/.project-mounts"}
|
||||
project_links_dir=${escapeShellArg "${cfg.stateDir}/projects"}
|
||||
project_bind_pairs=()
|
||||
|
||||
matched_root=""
|
||||
matched_mount=""
|
||||
project_index=0
|
||||
|
||||
for root in "''${resolved_roots[@]}"; do
|
||||
if [ ! -d "$root" ]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
root_slug="$(printf '%s' "$root" | ${pkgs.gnused}/bin/sed 's#^/##; s#/#-#g; s#-\{2,\}#-#g; s#-$##; s#^$#root#')"
|
||||
root_slug="''${project_index}-''${root_slug}"
|
||||
project_index=$((project_index + 1))
|
||||
|
||||
mount_point="''${project_mount_dir}/''${root_slug}"
|
||||
link_path="''${project_links_dir}/''${root_slug}"
|
||||
|
||||
${pkgs.coreutils}/bin/install -d -m 0750 -o ${escapeShellArg cfg.user} -g ${escapeShellArg cfg.group} "$mount_point"
|
||||
${pkgs.coreutils}/bin/ln -sfn "$mount_point" "$link_path"
|
||||
|
||||
project_bind_pairs+=("$root:$mount_point")
|
||||
|
||||
case "$cwd_real/" in
|
||||
"$root"/*)
|
||||
if [ -z "$matched_root" ] || [ "''${#root}" -gt "''${#matched_root}" ]; then
|
||||
matched_root="$root"
|
||||
matched_mount="$mount_point"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ -z "$matched_root" ]; then
|
||||
echo "Failed to map cwd '$cwd_real' to an allowed root." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$cwd_real" = "$matched_root" ]; then
|
||||
mapped_cwd="$matched_mount"
|
||||
else
|
||||
rel_path="''${cwd_real#"$matched_root/"}"
|
||||
mapped_cwd="$matched_mount/$rel_path"
|
||||
fi
|
||||
|
||||
pi_bin=${escapeShellArg "${cfg.package}/bin/${cfg.binaryName}"}
|
||||
|
||||
if [ ! -x "$pi_bin" ]; then
|
||||
for candidate in pi pi-agent; do
|
||||
alt=${escapeShellArg "${cfg.package}/bin"}/$candidate
|
||||
if [ -x "$alt" ]; then
|
||||
pi_bin="$alt"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [ ! -x "$pi_bin" ]; then
|
||||
echo "Pi binary not found or not executable: $pi_bin" >&2
|
||||
echo "Available executables in ${cfg.package}/bin:" >&2
|
||||
${pkgs.coreutils}/bin/ls -1 ${escapeShellArg "${cfg.package}/bin"} >&2 || true
|
||||
exit 127
|
||||
fi
|
||||
|
||||
cmd=(
|
||||
${pkgs.systemd}/bin/systemd-run
|
||||
--collect
|
||||
--wait
|
||||
--pty
|
||||
--service-type=exec
|
||||
-p User=${cfg.user}
|
||||
-p Group=${cfg.group}
|
||||
-p WorkingDirectory="$mapped_cwd"
|
||||
-p NoNewPrivileges=yes
|
||||
-p PrivateTmp=yes
|
||||
-p ProtectSystem=strict
|
||||
-p ProtectHome=false
|
||||
-p ProtectControlGroups=yes
|
||||
-p ProtectKernelTunables=yes
|
||||
-p ProtectKernelModules=yes
|
||||
-p RestrictSUIDSGID=yes
|
||||
-p LockPersonality=yes
|
||||
-p RestrictRealtime=yes
|
||||
-p RestrictNamespaces=yes
|
||||
-p MemoryDenyWriteExecute=no
|
||||
-p UMask=0007
|
||||
-p ReadWritePaths=${cfg.stateDir}
|
||||
-p EnvironmentFile=${cfg.stateDir}/.pi/.env
|
||||
-E HOME=${cfg.stateDir}
|
||||
-E PI_HOME=${cfg.stateDir}/.pi
|
||||
-E MESSAGING_CWD="$mapped_cwd"
|
||||
-E PATH="$runtime_path"
|
||||
-E NPM_CONFIG_CACHE=${cfg.stateDir}/.npm
|
||||
-E NPM_CONFIG_PREFIX="$npm_prefix"
|
||||
-E PI_AGENT_INVOKING_USER="$invoking_user"
|
||||
)
|
||||
|
||||
${optionalString (cfg.projectGroup != null) ''
|
||||
cmd+=( -p SupplementaryGroups=${cfg.projectGroup} )
|
||||
''}
|
||||
|
||||
# Only mark existing top-level paths inaccessible; systemd fails namespace
|
||||
# setup if InaccessiblePaths points to a non-existent path on this host.
|
||||
for p in /home /root /mnt /media /srv; do
|
||||
if [ -e "$p" ]; then
|
||||
cmd+=( -p "InaccessiblePaths=$p" )
|
||||
fi
|
||||
done
|
||||
|
||||
for pair in "''${project_bind_pairs[@]}"; do
|
||||
src="''${pair%%:*}"
|
||||
dst="''${pair#*:}"
|
||||
cmd+=( -p "BindPaths=$src:$dst" )
|
||||
done
|
||||
|
||||
${concatStringsSep "\n" (mapAttrsToList (name: value: ''cmd+=( -E ${escapeShellArg "${name}=${value}"} )'') cfg.wrapper.extraEnvironment)}
|
||||
|
||||
cmd+=( "$pi_bin" )
|
||||
${concatStringsSep "\n" (map (arg: ''cmd+=( ${escapeShellArg arg} )'') cfg.wrapper.extraRunArgs)}
|
||||
cmd+=( "$@" )
|
||||
|
||||
exec "''${cmd[@]}"
|
||||
'';
|
||||
|
||||
wrapper = pkgs.writeShellScriptBin cfg.wrapper.commandName ''
|
||||
set -euo pipefail
|
||||
|
||||
user_name="$(id -un)"
|
||||
user_home="$(eval echo "~$user_name")"
|
||||
if [ -z "$user_home" ] || [ "$user_home" = "~$user_name" ]; then
|
||||
user_home="$HOME"
|
||||
fi
|
||||
|
||||
resolve_user_policy() {
|
||||
local user="$1"
|
||||
USER_ROOTS=()
|
||||
case "$user" in
|
||||
${concatStringsSep "\n" (
|
||||
mapAttrsToList (
|
||||
user: userCfg: ''
|
||||
${escapeShellArg user})
|
||||
USER_ROOTS=(${concatStringsSep " " (map escapeShellArg userCfg.projectRoots)})
|
||||
;;
|
||||
''
|
||||
)
|
||||
cfg.hostUsers
|
||||
)}
|
||||
*)
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
return 0
|
||||
}
|
||||
|
||||
if ! resolve_user_policy "$user_name"; then
|
||||
echo "User '$user_name' is not allowed to use ${cfg.wrapper.commandName}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
expand_home_path() {
|
||||
local input="$1"
|
||||
if [ "$input" = "~" ]; then
|
||||
printf '%s\n' "$user_home"
|
||||
elif ${pkgs.gnugrep}/bin/grep -q '^~/' <<<"$input"; then
|
||||
printf '%s\n' "$user_home/''${input:2}"
|
||||
elif ${pkgs.gnugrep}/bin/grep -q '^/' <<<"$input"; then
|
||||
printf '%s\n' "$input"
|
||||
else
|
||||
printf '%s\n' "$user_home/$input"
|
||||
fi
|
||||
}
|
||||
|
||||
cwd_real="$(${pkgs.coreutils}/bin/realpath -m "$PWD")"
|
||||
|
||||
is_allowed_cwd=0
|
||||
resolved_roots=()
|
||||
skipped_roots=()
|
||||
for configured_root in "''${USER_ROOTS[@]}"; do
|
||||
expanded_root="$(expand_home_path "$configured_root")"
|
||||
resolved_root="$(${pkgs.coreutils}/bin/realpath -m "$expanded_root")"
|
||||
if [ ! -d "$resolved_root" ]; then
|
||||
skipped_roots+=("$resolved_root")
|
||||
continue
|
||||
fi
|
||||
resolved_roots+=("$resolved_root")
|
||||
case "$cwd_real/" in
|
||||
"$resolved_root"/*)
|
||||
is_allowed_cwd=1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ "''${#resolved_roots[@]}" -eq 0 ]; then
|
||||
echo "Denied: no valid existing project roots are configured for user '$user_name'." >&2
|
||||
if [ "''${#skipped_roots[@]}" -gt 0 ]; then
|
||||
echo "Configured but missing roots:" >&2
|
||||
for root in "''${skipped_roots[@]}"; do
|
||||
echo " - $root" >&2
|
||||
done
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$is_allowed_cwd" -ne 1 ]; then
|
||||
echo "Denied: '$cwd_real' is outside allowed project roots for user '$user_name'." >&2
|
||||
echo "Allowed roots:" >&2
|
||||
for root in "''${resolved_roots[@]}"; do
|
||||
echo " - $root" >&2
|
||||
done
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exec /run/wrappers/bin/sudo --non-interactive ${runner}/bin/${cfg.wrapper.runnerName} "$user_name" "$cwd_real" "$@"
|
||||
'';
|
||||
in {
|
||||
options.m3ta.pi-agent = {
|
||||
enable = mkEnableOption "isolated Pi execution with dedicated system user and policy-enforced wrapper";
|
||||
|
||||
package = mkOption {
|
||||
type = types.package;
|
||||
default = pkgs.pi-coding-agent;
|
||||
defaultText = literalExpression "pkgs.pi-coding-agent";
|
||||
description = "Pi package providing the executable used in isolated runtime.";
|
||||
};
|
||||
|
||||
binaryName = mkOption {
|
||||
type = types.str;
|
||||
default = "pi-agent";
|
||||
description = "Preferred executable name inside `${cfg.package}/bin` (falls back to pi/pi-agent auto-detection).";
|
||||
example = "pi";
|
||||
};
|
||||
|
||||
user = mkOption {
|
||||
type = types.str;
|
||||
default = "pi-agent";
|
||||
description = "System user that executes Pi in isolated mode.";
|
||||
};
|
||||
|
||||
group = mkOption {
|
||||
type = types.str;
|
||||
default = "pi-agent";
|
||||
description = "System group for the isolated Pi user.";
|
||||
};
|
||||
|
||||
stateDir = mkOption {
|
||||
type = types.str;
|
||||
default = "/var/lib/pi-agent";
|
||||
description = "Writable state/home directory for isolated Pi runtime.";
|
||||
};
|
||||
|
||||
createUser = mkOption {
|
||||
type = types.bool;
|
||||
default = true;
|
||||
description = "Whether to create the dedicated Pi user/group automatically.";
|
||||
};
|
||||
|
||||
hostUsers = mkOption {
|
||||
type = types.attrsOf (types.submodule {
|
||||
options = {
|
||||
projectRoots = mkOption {
|
||||
type = types.listOf types.str;
|
||||
default = [];
|
||||
description = ''
|
||||
Allowed project roots for this host user.
|
||||
`~` and `~/...` are expanded relative to that host user's home.
|
||||
'';
|
||||
example = ["~/p" "~/work/client-a"];
|
||||
};
|
||||
|
||||
configPath = mkOption {
|
||||
type = types.nullOr types.str;
|
||||
default = null;
|
||||
description = ''
|
||||
Optional host path for this user's Pi config source. If null,
|
||||
wrapper.hostConfigPath is used. Relative paths resolve from the
|
||||
host user's home.
|
||||
'';
|
||||
example = ".pi/agent";
|
||||
};
|
||||
};
|
||||
});
|
||||
default = {};
|
||||
description = ''
|
||||
Per-host-user policy map. Keys are host usernames.
|
||||
Each user defines their own allowed project roots and optional config source.
|
||||
'';
|
||||
example = literalExpression ''
|
||||
{
|
||||
m3tam3re = {
|
||||
projectRoots = [ "~/p" "~/src/private" ];
|
||||
configPath = ".pi/agent";
|
||||
};
|
||||
teammate = {
|
||||
projectRoots = [ "~/projects" ];
|
||||
};
|
||||
}
|
||||
'';
|
||||
};
|
||||
|
||||
settings = mkOption {
|
||||
type = types.attrsOf types.anything;
|
||||
default = {};
|
||||
description = ''
|
||||
Nix-managed Pi settings merged into isolated `${cfg.stateDir}/.pi/agent/settings.json`.
|
||||
Merge precedence: synced host settings first, Nix-managed values override recursively.
|
||||
'';
|
||||
example = literalExpression ''
|
||||
{
|
||||
defaultModel = "anthropic/claude-sonnet-4";
|
||||
defaultProvider = "anthropic";
|
||||
quietStartup = true;
|
||||
}
|
||||
'';
|
||||
};
|
||||
|
||||
environment = mkOption {
|
||||
type = types.attrsOf types.str;
|
||||
default = {};
|
||||
description = ''
|
||||
Non-secret Nix-managed environment variables appended into isolated
|
||||
`${cfg.stateDir}/.pi/.env` after synced host values.
|
||||
'';
|
||||
};
|
||||
|
||||
environmentFiles = mkOption {
|
||||
type = types.listOf types.str;
|
||||
default = [];
|
||||
description = ''
|
||||
Paths to env files (secrets/tokens) appended to isolated `${cfg.stateDir}/.pi/.env`
|
||||
after `environment` entries.
|
||||
'';
|
||||
};
|
||||
|
||||
extraPackages = mkOption {
|
||||
type = types.listOf types.package;
|
||||
default = [];
|
||||
description = "Extra packages added to isolated runtime PATH.";
|
||||
};
|
||||
|
||||
projectGroup = mkOption {
|
||||
type = types.nullOr types.str;
|
||||
default = null;
|
||||
description = ''
|
||||
When set, the pi-agent user is added to this group and the group is
|
||||
passed as SupplementaryGroups to the systemd-run sandbox. This allows
|
||||
pi-agent to write to project directories that grant group write access.
|
||||
The user must ensure project directories have appropriate group ownership
|
||||
and permissions (e.g. setgid + group write).
|
||||
'';
|
||||
example = "users";
|
||||
};
|
||||
|
||||
wrapper = {
|
||||
enable = mkOption {
|
||||
type = types.bool;
|
||||
default = true;
|
||||
description = "Enable host-side wrapper command that enforces policy and runs isolated Pi.";
|
||||
};
|
||||
|
||||
commandName = mkOption {
|
||||
type = types.str;
|
||||
default = "pi";
|
||||
description = "Host wrapper command name.";
|
||||
};
|
||||
|
||||
runnerName = mkOption {
|
||||
type = types.str;
|
||||
default = "m3ta-pi-agent-runner";
|
||||
description = "Privileged runner command invoked via scoped sudo rule.";
|
||||
};
|
||||
|
||||
hideDirectBinary = mkOption {
|
||||
type = types.bool;
|
||||
default = true;
|
||||
description = ''
|
||||
When true and wrapper is enabled, do not add the raw Pi package to host PATH,
|
||||
reducing bypass risk by making wrapper the canonical entrypoint.
|
||||
'';
|
||||
};
|
||||
|
||||
syncConfigFromHost = mkOption {
|
||||
type = types.bool;
|
||||
default = true;
|
||||
description = ''
|
||||
Sync host Pi config directory into isolated `${cfg.stateDir}/.pi/agent`
|
||||
on each invocation.
|
||||
'';
|
||||
};
|
||||
|
||||
hostConfigPath = mkOption {
|
||||
type = types.str;
|
||||
default = ".pi/agent";
|
||||
description = ''
|
||||
Default source path for host Pi config sync. Relative paths resolve from
|
||||
the invoking user's home. Per-user hostUsers.<name>.configPath overrides this.
|
||||
'';
|
||||
};
|
||||
|
||||
extraRunArgs = mkOption {
|
||||
type = types.listOf types.str;
|
||||
default = [];
|
||||
description = "Extra arguments inserted before user-provided Pi args.";
|
||||
};
|
||||
|
||||
extraEnvironment = mkOption {
|
||||
type = types.attrsOf types.str;
|
||||
default = {};
|
||||
description = "Additional environment variables passed to isolated Pi runtime.";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
config = mkIf cfg.enable {
|
||||
assertions =
|
||||
[
|
||||
{
|
||||
assertion = cfg.hostUsers != {};
|
||||
message = "m3ta.pi-agent.hostUsers must define at least one authorized host user.";
|
||||
}
|
||||
{
|
||||
assertion = (!cfg.wrapper.enable) || (cfg.hostUsers != {});
|
||||
message = "m3ta.pi-agent.hostUsers must not be empty when wrapper is enabled.";
|
||||
}
|
||||
]
|
||||
++ mapAttrsToList (user: userCfg: {
|
||||
assertion = userCfg.projectRoots != [];
|
||||
message = "m3ta.pi-agent.hostUsers.${user}.projectRoots must not be empty.";
|
||||
})
|
||||
cfg.hostUsers;
|
||||
|
||||
users.groups = mkIf cfg.createUser {
|
||||
"${cfg.group}" = {};
|
||||
};
|
||||
|
||||
users.users = mkIf cfg.createUser {
|
||||
"${cfg.user}" = {
|
||||
isSystemUser = true;
|
||||
group = cfg.group;
|
||||
extraGroups = mkIf (cfg.projectGroup != null) [cfg.projectGroup];
|
||||
description = "Isolated Pi agent user";
|
||||
home = cfg.stateDir;
|
||||
createHome = true;
|
||||
shell = pkgs.bashInteractive;
|
||||
};
|
||||
};
|
||||
|
||||
systemd.tmpfiles.rules = [
|
||||
"d ${cfg.stateDir} 0750 ${cfg.user} ${cfg.group} - -"
|
||||
"d ${cfg.stateDir}/.pi 0750 ${cfg.user} ${cfg.group} - -"
|
||||
"d ${cfg.stateDir}/.pi/agent 0750 ${cfg.user} ${cfg.group} - -"
|
||||
"d ${cfg.stateDir}/.pi/agent/sessions 0750 ${cfg.user} ${cfg.group} - -"
|
||||
"d ${cfg.stateDir}/.project-mounts 0750 ${cfg.user} ${cfg.group} - -"
|
||||
"d ${cfg.stateDir}/projects 0750 ${cfg.user} ${cfg.group} - -"
|
||||
"d ${cfg.stateDir}/.npm 0750 ${cfg.user} ${cfg.group} - -"
|
||||
"d ${cfg.stateDir}/.npm-global 0750 ${cfg.user} ${cfg.group} - -"
|
||||
"d ${cfg.stateDir}/.npm-global/bin 0750 ${cfg.user} ${cfg.group} - -"
|
||||
"d ${cfg.stateDir}/.npm-global/lib 0750 ${cfg.user} ${cfg.group} - -"
|
||||
];
|
||||
|
||||
# Wrapper is canonical when enabled; raw package on PATH is optional and
|
||||
# disabled by default to reduce bypass opportunities.
|
||||
environment.systemPackages =
|
||||
optional cfg.wrapper.enable wrapper
|
||||
++ optional ((!cfg.wrapper.enable) || (!cfg.wrapper.hideDirectBinary)) cfg.package;
|
||||
|
||||
security.sudo.extraRules = mkIf (cfg.wrapper.enable && hostUserNames != []) [
|
||||
{
|
||||
users = hostUserNames;
|
||||
commands = [
|
||||
{
|
||||
command = "${runner}/bin/${cfg.wrapper.runnerName}";
|
||||
options = ["NOPASSWD"];
|
||||
}
|
||||
];
|
||||
}
|
||||
];
|
||||
};
|
||||
}
|
||||
@@ -8,10 +8,10 @@
|
||||
nix-update-script,
|
||||
}: let
|
||||
pname = "eigent";
|
||||
version = "0.0.89";
|
||||
version = "0.0.90";
|
||||
src = fetchurl {
|
||||
url = "https://github.com/eigent-ai/eigent/releases/download/v${version}/Eigent-${version}.AppImage";
|
||||
hash = "sha256-9KuiFjegfXhCu1W/FCinWX4ae/DsNPudeBcXFfW18Hc=";
|
||||
hash = "sha256-mwCBx+D6mgGqQa8bDuUpo3h49EwFVkwasJwaYc6aXFE=";
|
||||
};
|
||||
appimageContents = appimageTools.extractType2 {inherit pname version src;};
|
||||
in
|
||||
|
||||
@@ -25,20 +25,20 @@
|
||||
in
|
||||
stdenv.mkDerivation (finalAttrs: {
|
||||
pname = "n8n";
|
||||
version = "stable";
|
||||
version = "2.14.2";
|
||||
|
||||
src = fetchFromGitHub {
|
||||
owner = "n8n-io";
|
||||
repo = "n8n";
|
||||
tag = "${finalAttrs.version}";
|
||||
hash = "sha256-/atba0ymCqhh5Rt61UxwC2xf8SGrRsEKtlsDCIkg37Y=";
|
||||
tag = "n8n@${finalAttrs.version}";
|
||||
hash = "sha256-nWV3DFDkBlfDdoOxwYB0HSrTyKpTt70YxAQYUPartkE=";
|
||||
};
|
||||
|
||||
pnpmDeps = fetchPnpmDeps {
|
||||
inherit (finalAttrs) pname version src;
|
||||
pnpm = pnpm_10;
|
||||
fetcherVersion = 3;
|
||||
hash = "sha256-YGplNNvIOIY1BthWmejAzucXujq8AkgPJus774GmWCA=";
|
||||
hash = "sha256-0SnPF3CgIja3M1ubLrwyFcx7vY0eHz9DEgn/gDLXN80=";
|
||||
};
|
||||
|
||||
nativeBuildInputs =
|
||||
|
||||
Reference in New Issue
Block a user