From a1d4dfc8c9cd8a9c5850ff61bb704dbe7c589e81 Mon Sep 17 00:00:00 2001 From: m3tm3re Date: Tue, 14 Apr 2026 05:06:40 +0200 Subject: [PATCH] fix: pi permissions --- modules/home-manager/coding/agents/pi.nix | 203 +++++++++++----------- 1 file changed, 99 insertions(+), 104 deletions(-) diff --git a/modules/home-manager/coding/agents/pi.nix b/modules/home-manager/coding/agents/pi.nix index a3e1860..39f6d54 100644 --- a/modules/home-manager/coding/agents/pi.nix +++ b/modules/home-manager/coding/agents/pi.nix @@ -7,9 +7,7 @@ with lib; let cfg = config.coding.agents.pi; mcpCfg = config.programs.mcp or null; - hasPiPackage = pkgs ? pi; - defaultPiImageArchive = if hasPiPackage then @@ -43,30 +41,29 @@ with lib; let in { options.coding.agents.pi = { enable = mkEnableOption "Pi agent management via canonical agent.toml definitions"; - container = mkOption { description = "Run Pi through a rootless Podman container while keeping a native host UX."; default = {}; type = types.submodule { options = { enable = mkEnableOption "Containerized Pi wrapper"; - name = mkOption { type = types.str; default = "pi-agent"; description = "Container name used by the Pi wrapper."; }; - image = mkOption { type = types.str; - default = if hasPiPackage then "pi-agent:latest" else "docker.io/nixos/nix:latest"; + default = + if hasPiPackage + then "pi-agent:latest" + else "docker.io/nixos/nix:latest"; description = '' Podman image to run for Pi. Defaults to a local declarative Pi-ready image when `pkgs.pi` exists, otherwise falls back to docker.io/nixos/nix:latest. ''; }; - imageArchive = mkOption { type = types.nullOr types.path; default = defaultPiImageArchive; @@ -76,7 +73,6 @@ in { generated when `pkgs.pi` is available. ''; }; - projectRoots = mkOption { type = types.listOf types.str; default = []; @@ -86,13 +82,11 @@ in { ''; example = ["/home/m3tam3re/p"]; }; - autoStart = mkOption { type = types.bool; default = true; description = "Automatically start container when wrapper is invoked and it is not running."; }; - autoNixDevelop = mkOption { type = types.bool; default = false; @@ -102,13 +96,11 @@ in { inside the container. ''; }; - extraRunArgs = mkOption { type = types.listOf types.str; default = []; description = "Additional Podman create args appended after safe defaults."; }; - extraEnv = mkOption { type = types.attrsOf types.str; default = {}; @@ -117,10 +109,12 @@ in { }; }; }; - 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). @@ -128,7 +122,6 @@ in { Automatically inherits from config.programs.mcp.servers. ''; }; - agentsInput = mkOption { type = types.nullOr types.anything; default = null; @@ -138,7 +131,6 @@ in { all agents are listed in AGENTS.md, and subagent .md files are deployed. ''; }; - modelOverrides = mkOption { type = types.attrsOf types.str; default = {}; @@ -148,7 +140,6 @@ in { { chiron = "anthropic/claude-sonnet-4"; chiron-forge = "anthropic/claude-sonnet-4"; } ''; }; - primaryAgent = mkOption { type = types.nullOr types.str; default = null; @@ -157,7 +148,6 @@ in { When null, the first agent with mode="primary" is used. ''; }; - settings = mkOption { type = types.submodule { freeformType = types.attrsOf types.anything; @@ -170,43 +160,36 @@ in { These are written to ~/.pi/agent/settings.json. ''; }; - defaultProvider = mkOption { type = types.nullOr types.str; default = null; description = "Default LLM provider (e.g. 'anthropic', 'openai', 'zai')."; }; - defaultModel = mkOption { type = types.nullOr types.str; default = null; description = "Default model ID."; }; - defaultThinkingLevel = mkOption { type = types.nullOr (types.enum ["off" "minimal" "low" "medium" "high" "xhigh"]); default = null; description = "Default extended thinking level."; }; - theme = mkOption { type = types.nullOr types.str; default = null; description = "Pi theme name."; }; - hideThinkingBlock = mkOption { type = types.nullOr types.bool; default = null; description = "Hide thinking blocks in output."; }; - quietStartup = mkOption { type = types.nullOr types.bool; default = null; description = "Hide startup header."; }; - compaction = mkOption { type = types.nullOr (types.submodule { options = { @@ -227,25 +210,21 @@ in { default = null; description = "Auto-compaction settings."; }; - enabledModels = mkOption { type = types.nullOr (types.listOf types.str); default = null; description = "Model patterns for Ctrl+P cycling."; }; - sessionDir = mkOption { type = types.nullOr types.str; default = null; description = "Directory where session files are stored."; }; - extensions = mkOption { type = types.listOf types.str; default = []; description = "Local extension file paths or directories."; }; - skills = mkOption { type = types.listOf types.str; default = []; @@ -261,7 +240,6 @@ in { ''; }; }; - config = mkIf cfg.enable (let # Build settings.json by filtering out null values recursively filterNulls = attrs: @@ -271,12 +249,13 @@ 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; - projectRoots = map toString cfg.container.projectRoots; projectRootsShell = concatStringsSep " " (map escapeShellArg projectRoots); extraRunArgsShell = concatStringsSep " " (map escapeShellArg cfg.container.extraRunArgs); @@ -288,39 +267,44 @@ in { if cfg.container.imageArchive != null then escapeShellArg (toString cfg.container.imageArchive) else ""; - piWrapper = pkgs.writeShellScriptBin "pi" '' set -euo pipefail - PODMAN="${pkgs.podman}/bin/podman" REALPATH="${pkgs.coreutils}/bin/realpath" - CONTAINER_NAME=${escapeShellArg cfg.container.name} IMAGE=${escapeShellArg cfg.container.image} IMAGE_ARCHIVE=${imageArchiveShell} - AUTO_START=${if cfg.container.autoStart then "1" else "0"} - AUTO_NIX_DEVELOP=${if cfg.container.autoNixDevelop then "1" else "0"} + AUTO_START=${ + if cfg.container.autoStart + then "1" + else "0" + } + AUTO_NIX_DEVELOP=${ + if cfg.container.autoNixDevelop + then "1" + else "0" + } HOST_PI_DIR=${hostPiDirShell} - + PI_CONFIG_MOUNT="/pi-config" + WRAPPER_SCHEMA_VERSION="4" + RUNTIME_HOME="/tmp" + RUNTIME_NPM_PREFIX="/tmp/.npm-global" + RUNTIME_NPM_CACHE="/tmp/.npm-cache" + RUNTIME_TMPDIR="/tmp/.runtime-tmp" PROJECT_ROOTS=(${projectRootsShell}) EXTRA_RUN_ARGS=(${extraRunArgsShell}) EXTRA_ENV_VARS=(${extraEnvShell}) - err() { printf "pi-wrapper: %s\n" "$1" >&2 exit 1 } - if [ "''${#PROJECT_ROOTS[@]}" -eq 0 ]; then err "No allowed projectRoots configured. Set coding.agents.pi.container.projectRoots." fi - if ! command -v "$PODMAN" >/dev/null 2>&1; then err "podman binary not found at $PODMAN" fi - CWD="$($REALPATH -m "$PWD")" - cwd_allowed=0 NORMALIZED_ROOTS=() for root in "''${PROJECT_ROOTS[@]}"; do @@ -332,7 +316,6 @@ in { ;; esac done - if [ "$cwd_allowed" -ne 1 ]; then { printf "pi-wrapper: cwd '%s' is outside allowed projectRoots.\n" "$CWD" @@ -343,88 +326,96 @@ in { } >&2 exit 1 fi - tty_args=() if [ -t 0 ] && [ -t 1 ]; then tty_args=(-it) fi - ensure_image_available() { if [ -n "$IMAGE_ARCHIVE" ] && [ -f "$IMAGE_ARCHIVE" ]; then "$PODMAN" load -i "$IMAGE_ARCHIVE" >/dev/null fi - if ! "$PODMAN" image exists "$IMAGE"; then err "Container image '$IMAGE' is not available and no valid imageArchive was provided." fi } - create_container() { mount_args=() - for root in "''${NORMALIZED_ROOTS[@]}"; do mount_args+=("-v" "$root:$root:rw") done - if [ ! -S /nix/var/nix/daemon-socket/socket ]; then err "Host Nix daemon socket not found at /nix/var/nix/daemon-socket/socket" fi - mount_args+=("-v" "/nix/var/nix/daemon-socket/socket:/nix/var/nix/daemon-socket/socket:rw") - mkdir -p "$HOST_PI_DIR" - mount_args+=("-v" "$HOST_PI_DIR:/tmp/.pi:rw") - + mount_args+=("-v" "$HOST_PI_DIR:$PI_CONFIG_MOUNT:rw") if [ -d /nix/store ]; then mount_args+=("-v" "/nix/store:/nix/store:ro") fi - if [ -e /etc/nix/nix.conf ]; then mount_args+=("-v" "/etc/nix/nix.conf:/etc/nix/nix.conf:ro") fi - if [ -d /etc/ssl/certs ]; then mount_args+=("-v" "/etc/ssl/certs:/etc/ssl/certs:ro") fi - if [ -d /etc/pki ]; then mount_args+=("-v" "/etc/pki:/etc/pki:ro") fi - env_args=() for kv in "''${EXTRA_ENV_VARS[@]}"; do env_args+=("--env" "$kv") done - "$PODMAN" create \ --name "$CONTAINER_NAME" \ --hostname "$CONTAINER_NAME" \ + --label io.m3ta.pi-wrapper-version="$WRAPPER_SCHEMA_VERSION" \ --userns keep-id \ --user "$(${pkgs.coreutils}/bin/id -u):$(${pkgs.coreutils}/bin/id -g)" \ --security-opt no-new-privileges \ --workdir /tmp \ - --tmpfs /tmp:rw,nodev,nosuid \ - --env HOME=/tmp \ + --tmpfs /tmp:rw,nodev,nosuid,mode=1777 \ + --env HOME="$RUNTIME_HOME" \ + --env PI_CODING_AGENT_DIR="$PI_CONFIG_MOUNT/agent" \ --env NIX_REMOTE=daemon \ - --env NPM_CONFIG_PREFIX=/tmp/.npm-global \ - --env npm_config_prefix=/tmp/.npm-global \ - --env NPM_CONFIG_CACHE=/tmp/.npm \ - --env npm_config_cache=/tmp/.npm \ - --env PATH=/tmp/.npm-global/bin:/bin:/usr/bin \ + --env TMPDIR="$RUNTIME_TMPDIR" \ + --env NPM_CONFIG_PREFIX="$RUNTIME_NPM_PREFIX" \ + --env npm_config_prefix="$RUNTIME_NPM_PREFIX" \ + --env NPM_CONFIG_CACHE="$RUNTIME_NPM_CACHE" \ + --env npm_config_cache="$RUNTIME_NPM_CACHE" \ + --env PATH="$RUNTIME_NPM_PREFIX/bin:/bin:/usr/bin" \ "''${mount_args[@]}" \ "''${env_args[@]}" \ "''${EXTRA_RUN_ARGS[@]}" \ "$IMAGE" \ sleep infinity >/dev/null } - + runtime_env_args=( + --env "HOME=$RUNTIME_HOME" + --env "PI_CODING_AGENT_DIR=$PI_CONFIG_MOUNT/agent" + --env NIX_REMOTE=daemon + --env "TMPDIR=$RUNTIME_TMPDIR" + --env "NPM_CONFIG_PREFIX=$RUNTIME_NPM_PREFIX" + --env "npm_config_prefix=$RUNTIME_NPM_PREFIX" + --env "NPM_CONFIG_CACHE=$RUNTIME_NPM_CACHE" + --env "npm_config_cache=$RUNTIME_NPM_CACHE" + --env "PATH=$RUNTIME_NPM_PREFIX/bin:/bin:/usr/bin" + ) + recreate_container() { + "$PODMAN" rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true + ensure_image_available + create_container + } ensure_container_running() { if ! "$PODMAN" container exists "$CONTAINER_NAME"; then ensure_image_available create_container + else + schema_version="$("$PODMAN" inspect -f '{{ index .Config.Labels "io.m3ta.pi-wrapper-version" }}' "$CONTAINER_NAME" 2>/dev/null || true)" + if [ "$schema_version" != "$WRAPPER_SCHEMA_VERSION" ]; then + recreate_container + fi fi - - running="$($PODMAN inspect -f '{{.State.Running}}' "$CONTAINER_NAME" 2>/dev/null || true)" + running="$("$PODMAN" inspect -f '{{.State.Running}}' "$CONTAINER_NAME" 2>/dev/null || true)" if [ "$running" != "true" ]; then if [ "$AUTO_START" = "1" ]; then "$PODMAN" start "$CONTAINER_NAME" >/dev/null @@ -433,20 +424,24 @@ in { fi fi } - + ensure_runtime_dirs() { + if ! "$PODMAN" exec "''${runtime_env_args[@]}" --workdir /tmp "$CONTAINER_NAME" sh -lc 'mkdir -p "$NPM_CONFIG_PREFIX" "$NPM_CONFIG_CACHE" "$TMPDIR"'; then + err "Failed to prepare writable npm runtime directories in container '$CONTAINER_NAME'." + fi + if ! "$PODMAN" exec "''${runtime_env_args[@]}" --workdir /tmp "$CONTAINER_NAME" sh -lc 'test -w "$NPM_CONFIG_PREFIX" && test -w "$NPM_CONFIG_CACHE" && test -w "$TMPDIR"'; then + err "Container runtime dirs are not writable for npm. Remove stale container with: podman rm -f $CONTAINER_NAME" + fi + } ensure_container_running - + ensure_runtime_dirs if [ "$AUTO_NIX_DEVELOP" = "1" ] && [ -f "$CWD/flake.nix" ]; then - exec "$PODMAN" exec "''${tty_args[@]}" --workdir "$CWD" "$CONTAINER_NAME" nix develop -c pi "$@" + exec "$PODMAN" exec "''${tty_args[@]}" "''${runtime_env_args[@]}" --workdir "$CWD" "$CONTAINER_NAME" nix develop -c pi "$@" fi - - if "$PODMAN" exec --workdir "$CWD" "$CONTAINER_NAME" sh -lc 'command -v pi >/dev/null 2>&1'; then - exec "$PODMAN" exec "''${tty_args[@]}" --workdir "$CWD" "$CONTAINER_NAME" pi "$@" + if "$PODMAN" exec "''${runtime_env_args[@]}" --workdir "$CWD" "$CONTAINER_NAME" sh -lc 'command -v pi >/dev/null 2>&1'; then + exec "$PODMAN" exec "''${tty_args[@]}" "''${runtime_env_args[@]}" --workdir "$CWD" "$CONTAINER_NAME" pi "$@" fi - err "Container '$CONTAINER_NAME' does not have 'pi' in PATH (image: $IMAGE). Use a Pi-ready image or run from a flake project with autoNixDevelop=true." ''; - # Rendered agents (only computed when agentsInput is set) rendered = if cfg.agentsInput != null @@ -458,23 +453,20 @@ in { primaryAgent = cfg.primaryAgent; } else null; - # 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 = {text = builtins.readFile "${rendered}/agents/${name}.md";}; - }) - agentNames - ) + then let + agentNames = builtins.attrNames cfg.agentsInput.lib.loadAgents; + in + builtins.listToAttrs ( + map (name: { + name = ".pi/agent/agents/${name}.md"; + value = {text = builtins.readFile "${rendered}/agents/${name}.md";}; + }) + agentNames + ) else {}; - skillsSource = if cfg.agentsInput != null then @@ -499,49 +491,52 @@ in { assertion = all (path: hasPrefix "/" (toString path)) cfg.container.projectRoots; message = "coding.agents.pi.container.projectRoots entries must be absolute paths."; }; - home.packages = (optional cfg.container.enable piWrapper) ++ (optional (!cfg.container.enable && hasPiPackage) pkgs.pi); - home.file = mkMerge [ # ── MCP servers from programs.mcp → ~/.pi/agent/mcp.json ─────── (mkIf (cfg.mcpServers != {}) { ".pi/agent/mcp.json".text = builtins.toJSON {mcpServers = cfg.mcpServers;}; }) - # ── ~/.pi/agent/settings.json ────────────────────────────────── { ".pi/agent/settings.json".text = builtins.toJSON piSettings; } - # ── AGENTS.md — agent descriptions and specialist listing ────── (mkIf (cfg.agentsInput != null) { ".pi/agent/AGENTS.md".text = builtins.readFile "${rendered}/AGENTS.md"; }) - # ── SYSTEM.md — primary agent's system prompt ────────────────── (mkIf (cfg.agentsInput != null) { ".pi/agent/SYSTEM.md".text = builtins.readFile "${rendered}/SYSTEM.md"; }) - # ── Agents — pi-subagents .md files ──────────────────────────── agentFiles - # ── Skills symlinked from AGENTS repo (non-container mode) ───── (mkIf (cfg.agentsInput != null && !cfg.container.enable) { ".pi/agent/skills".source = skillsSource; }) ]; - home.activation.piMaterializeSkills = mkIf (cfg.container.enable && cfg.agentsInput != null) ( lib.hm.dag.entryAfter ["writeBoundary"] '' skillsSrc=${escapeShellArg "${skillsSource}"} skillsDst=${escapeShellArg "${config.home.homeDirectory}/.pi/agent/skills"} - - ${pkgs.coreutils}/bin/rm -rf "$skillsDst" - ${pkgs.coreutils}/bin/mkdir -p "$skillsDst" - ${pkgs.coreutils}/bin/cp -aL "$skillsSrc"/. "$skillsDst"/ + skillsParent="$(${pkgs.coreutils}/bin/dirname "$skillsDst")" + skillsNew="$skillsParent/.skills-new-$$" + skillsOld="$skillsParent/.skills-old-$$" + ${pkgs.coreutils}/bin/mkdir -p "$skillsParent" + ${pkgs.coreutils}/bin/rm -rf "$skillsNew" + ${pkgs.coreutils}/bin/mkdir -p "$skillsNew" + ${pkgs.coreutils}/bin/cp -aL "$skillsSrc"/. "$skillsNew"/ + if [ -e "$skillsDst" ]; then + ${pkgs.coreutils}/bin/mv "$skillsDst" "$skillsOld" + fi + ${pkgs.coreutils}/bin/mv "$skillsNew" "$skillsDst" + if [ -e "$skillsOld" ]; then + ${pkgs.coreutils}/bin/chmod -R u+w "$skillsOld" 2>/dev/null || true + ${pkgs.coreutils}/bin/rm -rf "$skillsOld" 2>/dev/null || true + fi '' ); });