diff --git a/modules/nixos/pi-agent-runner.nix b/modules/nixos/pi-agent-runner.nix new file mode 100644 index 0000000..438ba61 --- /dev/null +++ b/modules/nixos/pi-agent-runner.nix @@ -0,0 +1,376 @@ +{cfg, pkgs, lib, ...}: +with lib; let + 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 + ); +in +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} [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[@]}" +'' diff --git a/modules/nixos/pi-agent-wrapper.nix b/modules/nixos/pi-agent-wrapper.nix new file mode 100644 index 0000000..61dcfc6 --- /dev/null +++ b/modules/nixos/pi-agent-wrapper.nix @@ -0,0 +1,92 @@ +{cfg, pkgs, lib, runner, ...}: +with lib; +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" "$@" +'' diff --git a/modules/nixos/pi-agent.nix b/modules/nixos/pi-agent.nix index f6bc04d..4c01233 100644 --- a/modules/nixos/pi-agent.nix +++ b/modules/nixos/pi-agent.nix @@ -17,471 +17,8 @@ with lib; let 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} [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" "$@" - ''; + runner = import ./pi-agent-runner.nix {inherit cfg pkgs lib;}; + wrapper = import ./pi-agent-wrapper.nix {inherit cfg pkgs lib runner;}; in { options.m3ta.pi-agent = { enable = mkEnableOption "isolated Pi execution with dedicated system user and policy-enforced wrapper";