From 6b0ff4274857c7107b9a20741fe1f4798ed586f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Albornoz?= Date: Wed, 11 Mar 2026 23:48:57 -0400 Subject: [PATCH] feat: add Nix flake with package and NixOS module Add a Nix flake that builds the Paseo daemon (server + CLI) and provides a NixOS module for declarative deployment. Package (nix/package.nix): - Builds relay, server, and CLI workspaces - Skips onnxruntime-node install script (sandbox-incompatible) - Rebuilds only node-pty for native terminal support - Source filter excludes app/website/desktop workspaces NixOS module (nix/module.nix): - Systemd service with configurable user, port, listen address - allowedHosts for DNS rebinding protection - relay.enable to toggle remote access via app.paseo.sh - inheritUserEnvironment to expose user tools (git, ssh) to agents - openFirewall and extra environment variables ci: add Nix hash maintenance scripts and workflows scripts/fix-lockfile.mjs: Adds missing resolved/integrity fields to package-lock.json for workspace-local overrides. Idempotent, uses `npm view`. scripts/update-nix.sh: Runs fix-lockfile.mjs, prefetches deps, computes NAR hash, and updates npmDepsHash in nix/package.nix. Supports --check for CI. .github/workflows/nix-build.yml: Builds the Nix package on push/PR and verifies the lockfile and hash are up to date. .github/workflows/fix-nix-hash.yml: Auto-fixes lockfile signatures and Nix hash on dependabot PRs. fix: update npmDepsHash after upstream sync nix: allowlist workspace symlinks instead of blocklist Prevents build failures when upstream adds new workspace packages. --- .github/workflows/fix-nix-hash.yml | 41 +++++++ .github/workflows/nix-build.yml | 53 +++++++++ flake.lock | 27 +++++ flake.nix | 59 ++++++++++ nix/module.nix | 172 +++++++++++++++++++++++++++++ nix/package.nix | 152 +++++++++++++++++++++++++ scripts/fix-lockfile.mjs | 66 +++++++++++ scripts/update-nix.sh | 51 +++++++++ 8 files changed, 621 insertions(+) create mode 100644 .github/workflows/fix-nix-hash.yml create mode 100644 .github/workflows/nix-build.yml create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 nix/module.nix create mode 100644 nix/package.nix create mode 100644 scripts/fix-lockfile.mjs create mode 100755 scripts/update-nix.sh diff --git a/.github/workflows/fix-nix-hash.yml b/.github/workflows/fix-nix-hash.yml new file mode 100644 index 0000000000..939216a964 --- /dev/null +++ b/.github/workflows/fix-nix-hash.yml @@ -0,0 +1,41 @@ +name: Fix Nix hash + +on: + pull_request: + paths: + - 'package.json' + - 'package-lock.json' + +permissions: + contents: write + +jobs: + fix-nix-hash: + runs-on: ubuntu-latest + if: github.actor == 'dependabot[bot]' + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.head_ref }} + token: ${{ secrets.GITHUB_TOKEN }} + + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + + - uses: cachix/install-nix-action@v31 + with: + nix_path: nixpkgs=channel:nixos-unstable + + - name: Fix lockfile and update hash + run: ./scripts/update-nix.sh + + - name: Commit changes + run: | + git diff --quiet package-lock.json nix/package.nix && exit 0 + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add package-lock.json nix/package.nix + git commit -m "fix: update lockfile signatures and Nix hash" + git push diff --git a/.github/workflows/nix-build.yml b/.github/workflows/nix-build.yml new file mode 100644 index 0000000000..06bb590472 --- /dev/null +++ b/.github/workflows/nix-build.yml @@ -0,0 +1,53 @@ +name: Nix Build + +on: + push: + branches: [main] + paths: + - 'nix/**' + - 'flake.nix' + - 'flake.lock' + - 'package.json' + - 'package-lock.json' + - 'packages/server/**' + - 'packages/relay/**' + - 'packages/cli/**' + pull_request: + branches: [main] + paths: + - 'nix/**' + - 'flake.nix' + - 'flake.lock' + - 'package.json' + - 'package-lock.json' + - 'packages/server/**' + - 'packages/relay/**' + - 'packages/cli/**' + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: cachix/install-nix-action@v31 + with: + nix_path: nixpkgs=channel:nixos-unstable + + - name: Build Nix package + run: nix build .#default -o result + + - name: Verify lockfile is complete + run: | + node scripts/fix-lockfile.mjs package-lock.json + git diff --exit-code package-lock.json || { + echo "ERROR: package-lock.json has missing resolved/integrity fields." + echo "Run 'node scripts/fix-lockfile.mjs' and commit the result." + exit 1 + } + + - name: Check npmDepsHash is up to date + run: ./scripts/update-nix.sh --check diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000000..3e389405eb --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1772963539, + "narHash": "sha256-9jVDGZnvCckTGdYT53d/EfznygLskyLQXYwJLKMPsZs=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "9dcb002ca1690658be4a04645215baea8b95f31d", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000000..f9e1943187 --- /dev/null +++ b/flake.nix @@ -0,0 +1,59 @@ +{ + description = "Paseo - self-hosted daemon for AI coding agents"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + }; + + outputs = + { + self, + nixpkgs, + }: + let + supportedSystems = [ + "x86_64-linux" + "aarch64-linux" + "x86_64-darwin" + "aarch64-darwin" + ]; + forAllSystems = nixpkgs.lib.genAttrs supportedSystems; + pkgsFor = system: import nixpkgs { inherit system; }; + in + { + packages = forAllSystems ( + system: + let + pkgs = pkgsFor system; + paseo = pkgs.callPackage ./nix/package.nix { }; + in + { + default = paseo; + paseo = paseo; + } + ); + + nixosModules.default = self.nixosModules.paseo; + nixosModules.paseo = + { pkgs, lib, ... }: + { + imports = [ ./nix/module.nix ]; + services.paseo.package = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.default; + }; + + devShells = forAllSystems ( + system: + let + pkgs = pkgsFor system; + in + { + default = pkgs.mkShell { + packages = [ + pkgs.nodejs_22 + pkgs.python3 + ]; + }; + } + ); + }; +} diff --git a/nix/module.nix b/nix/module.nix new file mode 100644 index 0000000000..72a3876148 --- /dev/null +++ b/nix/module.nix @@ -0,0 +1,172 @@ +{ + config, + lib, + pkgs, + ... +}: + +let + cfg = config.services.paseo; +in +{ + options.services.paseo = { + enable = lib.mkEnableOption "Paseo, a self-hosted daemon for AI coding agents"; + + package = lib.mkPackageOption pkgs "paseo" { }; + + user = lib.mkOption { + type = lib.types.str; + default = "paseo"; + description = "User account under which Paseo runs."; + }; + + group = lib.mkOption { + type = lib.types.str; + default = "paseo"; + description = "Group under which Paseo runs."; + }; + + dataDir = lib.mkOption { + type = lib.types.str; + default = + if cfg.user == "paseo" + then "/var/lib/paseo" + else "/home/${cfg.user}/.paseo"; + defaultText = lib.literalExpression '' + if cfg.user == "paseo" + then "/var/lib/paseo" + else "/home/''${cfg.user}/.paseo" + ''; + description = "Directory for Paseo state (PASEO_HOME). Stores agent data, config, and logs."; + }; + + port = lib.mkOption { + type = lib.types.port; + default = 6767; + description = "Port for the Paseo daemon to listen on."; + }; + + listenAddress = lib.mkOption { + type = lib.types.str; + default = "127.0.0.1"; + description = "Address for the Paseo daemon to bind to."; + }; + + openFirewall = lib.mkOption { + type = lib.types.bool; + default = false; + description = "Whether to open the firewall for the Paseo daemon port."; + }; + + allowedHosts = lib.mkOption { + type = lib.types.either (lib.types.enum [ true ]) (lib.types.listOf lib.types.str); + default = [ ]; + example = [ ".example.com" "myhost.local" ]; + description = '' + Hosts allowed to connect to the Paseo daemon (DNS rebinding protection). + Localhost and IP addresses are always allowed by default. + + Use a leading dot to match a domain and all its subdomains + (e.g. `".example.com"` matches `example.com` and `foo.example.com`). + + Set to `true` to allow any host (not recommended). + ''; + }; + + relay = { + enable = lib.mkOption { + type = lib.types.bool; + default = true; + description = "Whether to enable the relay connection for remote access via app.paseo.sh."; + }; + }; + + inheritUserEnvironment = lib.mkOption { + type = lib.types.bool; + default = cfg.user != "paseo"; + defaultText = lib.literalExpression ''cfg.user != "paseo"''; + description = '' + Whether to include the user's profile PATH in the service environment. + + When Paseo runs as a real user (not the default system user), AI agents + need access to the user's tools (git, ssh, etc.). This adds the user's + NixOS profile and system paths so agents can use them without manually + setting PATH. + + Enabled by default when `user` is set to a non-default value. + ''; + }; + + environment = lib.mkOption { + type = lib.types.attrsOf lib.types.str; + default = { }; + example = lib.literalExpression '' + { + PASEO_RELAY_ENDPOINT = "relay.paseo.sh:443"; + } + ''; + description = "Extra environment variables for the Paseo daemon."; + }; + }; + + config = lib.mkIf cfg.enable { + users.users.${cfg.user} = lib.mkIf (cfg.user == "paseo") { + isSystemUser = true; + group = cfg.group; + home = cfg.dataDir; + }; + + users.groups.${cfg.group} = lib.mkIf (cfg.group == "paseo") { }; + + systemd.tmpfiles.rules = [ + "d ${cfg.dataDir} 0700 ${cfg.user} ${cfg.group} - -" + ]; + + systemd.services.paseo = { + description = "Paseo - self-hosted daemon for AI coding agents"; + after = [ "network.target" ]; + wantedBy = [ "multi-user.target" ]; + + environment = { + NODE_ENV = "production"; + PASEO_HOME = cfg.dataDir; + PASEO_LISTEN = "${cfg.listenAddress}:${toString cfg.port}"; + } // lib.optionalAttrs cfg.inheritUserEnvironment { + # mkForce overrides the default PATH from NixOS's systemd module (which + # only includes store paths for coreutils/grep/sed/systemd). Our PATH + # includes /run/current-system/sw/bin which is a superset of those. + PATH = lib.mkForce (lib.concatStringsSep ":" [ + "/etc/profiles/per-user/${cfg.user}/bin" + "/run/current-system/sw/bin" + "/run/wrappers/bin" + "/nix/var/nix/profiles/default/bin" + ]); + } // lib.optionalAttrs (cfg.allowedHosts == true) { + PASEO_ALLOWED_HOSTS = "true"; + } // lib.optionalAttrs (lib.isList cfg.allowedHosts && cfg.allowedHosts != [ ]) { + PASEO_ALLOWED_HOSTS = lib.concatStringsSep "," cfg.allowedHosts; + } // cfg.environment; + + serviceConfig = { + Type = "simple"; + User = cfg.user; + Group = cfg.group; + + ExecStart = + "${cfg.package}/bin/paseo-server" + + lib.optionalString (!cfg.relay.enable) " --no-relay"; + + Restart = "on-failure"; + RestartSec = 5; + + # Graceful shutdown (server handles SIGTERM with a 10s timeout) + KillSignal = "SIGTERM"; + TimeoutStopSec = 15; + }; + }; + + environment.systemPackages = [ cfg.package ]; + + networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall [ cfg.port ]; + }; +} diff --git a/nix/package.nix b/nix/package.nix new file mode 100644 index 0000000000..7243d16496 --- /dev/null +++ b/nix/package.nix @@ -0,0 +1,152 @@ +{ + lib, + stdenv, + buildNpmPackage, + nodejs_22, + python3, + makeWrapper, + # node-pty needs libuv headers on Linux + libuv, +}: + +buildNpmPackage rec { + pname = "paseo"; + version = (builtins.fromJSON (builtins.readFile ../package.json)).version; + + src = lib.cleanSourceWith { + src = ./..; + filter = path: type: + let + baseName = builtins.baseNameOf path; + relPath = lib.removePrefix (toString ./..) path; + in + # Exclude non-daemon workspace contents (keep package.json for workspace resolution) + !(lib.hasPrefix "/packages/app/src" relPath) + && !(lib.hasPrefix "/packages/app/assets" relPath) + && !(lib.hasPrefix "/packages/app/android" relPath) + && !(lib.hasPrefix "/packages/app/ios" relPath) + && !(lib.hasPrefix "/packages/website/src" relPath) + && !(lib.hasPrefix "/packages/website/public" relPath) + && !(lib.hasPrefix "/packages/desktop/src" relPath) + && !(lib.hasPrefix "/packages/desktop/src-tauri" relPath) + # Exclude test fixtures and debug files + && !(lib.hasSuffix ".test.ts" baseName) + && !(lib.hasSuffix ".e2e.test.ts" baseName) + && baseName != "node_modules" + && baseName != ".git" + && baseName != ".paseo" + && baseName != ".DS_Store"; + }; + + nodejs = nodejs_22; + + # To update: run `nix build` with lib.fakeHash, copy the `got:` hash. + # CI auto-updates this when package-lock.json changes (see .github/workflows/). + npmDepsHash = "sha256-/jkIg4ZVLp+EuMij4YpmtbwLihUtKi8U/jLh981M4CI="; + + # Prevent onnxruntime-node's install script from running during automatic + # npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox). + # We manually rebuild only node-pty in buildPhase. + npmRebuildFlags = [ "--ignore-scripts" ]; + + nativeBuildInputs = [ + python3 # for node-gyp (node-pty compilation) + makeWrapper + ]; + + buildInputs = lib.optionals stdenv.hostPlatform.isLinux [ + libuv + ]; + + # Don't use the default npm build hook — we need a custom build sequence + dontNpmBuild = true; + + buildPhase = '' + runHook preBuild + + # Rebuild only node-pty (native addon for terminal emulation). + # Speech-related native modules (sherpa-onnx, onnxruntime-node) are + # intentionally left unbuilt — they're lazily loaded and gracefully + # degrade when unavailable. + npm rebuild node-pty + + # Build the daemon packages (relay → server → cli) + npm run build --workspace=@getpaseo/relay + npm run build --workspace=@getpaseo/server + npm run build --workspace=@getpaseo/cli + + runHook postBuild + ''; + + installPhase = '' + runHook preInstall + + mkdir -p $out/lib/paseo + + # Copy root package metadata + cp package.json $out/lib/paseo/ + + # Copy node_modules (preserving workspace symlinks) + cp -a node_modules $out/lib/paseo/ + + # Remove all @getpaseo workspace symlinks except the daemon packages we need + for link in $out/lib/paseo/node_modules/@getpaseo/*; do + name=$(basename "$link") + case "$name" in + relay|server|cli) ;; # keep these + *) rm -f "$link" ;; + esac + done + + # Copy built daemon workspace packages + for pkg in relay server cli; do + mkdir -p $out/lib/paseo/packages/$pkg + cp packages/$pkg/package.json $out/lib/paseo/packages/$pkg/ + if [ -d packages/$pkg/dist ]; then + cp -a packages/$pkg/dist $out/lib/paseo/packages/$pkg/ + fi + # Copy workspace-local node_modules if present + if [ -d packages/$pkg/node_modules ]; then + cp -a packages/$pkg/node_modules $out/lib/paseo/packages/$pkg/ + fi + done + + # Copy CLI bin entry + mkdir -p $out/lib/paseo/packages/cli/bin + cp packages/cli/bin/paseo $out/lib/paseo/packages/cli/bin/ + + # Copy extra server files referenced at runtime + for f in agent-prompt.md .env.example; do + if [ -f packages/server/$f ]; then + cp packages/server/$f $out/lib/paseo/packages/server/ + fi + done + + # Copy server scripts (daemon-runner, supervisor) needed by CLI + if [ -d packages/server/dist/scripts ]; then + mkdir -p $out/lib/paseo/packages/server/dist/scripts + cp -a packages/server/dist/scripts/* $out/lib/paseo/packages/server/dist/scripts/ + fi + + # Create wrapper for the server entry point (for systemd / direct use) + mkdir -p $out/bin + makeWrapper ${nodejs}/bin/node $out/bin/paseo-server \ + --add-flags "$out/lib/paseo/packages/server/dist/server/server/index.js" \ + --set NODE_ENV production + + # Create wrapper for the CLI + makeWrapper ${nodejs}/bin/node $out/bin/paseo \ + --add-flags "$out/lib/paseo/packages/cli/dist/index.js" \ + --set NODE_PATH "$out/lib/paseo/node_modules" + + runHook postInstall + ''; + + meta = { + description = "Self-hosted daemon for Claude Code, Codex, and OpenCode"; + homepage = "https://github.com/getpaseo/paseo"; + license = lib.licenses.agpl3Plus; + mainProgram = "paseo"; + platforms = lib.platforms.linux ++ lib.platforms.darwin; + }; +} diff --git a/scripts/fix-lockfile.mjs b/scripts/fix-lockfile.mjs new file mode 100644 index 0000000000..a76446fde9 --- /dev/null +++ b/scripts/fix-lockfile.mjs @@ -0,0 +1,66 @@ +#!/usr/bin/env node +// Ensure all workspace-local node_modules entries in package-lock.json have +// `resolved` and `integrity` fields. npm omits these for workspace-hoisted +// overrides, but tools like Nix's offline `npm ci` require them. +// +// This script is idempotent — running it on an already-complete lockfile +// is a no-op. Run it after `npm install` when the lockfile changes. +// +// Usage: +// node scripts/fix-lockfile.mjs +// node scripts/fix-lockfile.mjs path/to/package-lock.json + +import fs from "fs"; +import { execSync } from "child_process"; + +const lockPath = process.argv[2] || "package-lock.json"; +const lock = JSON.parse(fs.readFileSync(lockPath, "utf8")); + +// Collect workspace package roots (local packages, not from npm) +const workspaceRoots = new Set(); +for (const [key, val] of Object.entries(lock.packages || {})) { + if (val.link) { + workspaceRoots.add(val.resolved || key); + } +} + +let fixed = 0; + +for (const [key, val] of Object.entries(lock.packages || {})) { + if ( + !key || // root package + key.startsWith("node_modules/") || // top-level (already has resolved) + val.link || // workspace link entry + (val.resolved && val.integrity) || // already complete + !val.version || // no version to look up + workspaceRoots.has(key) // workspace package root (local, not on npm) + ) + continue; + + const pkgName = key.replace(/.*node_modules\//, ""); + const version = val.version; + + try { + const info = JSON.parse( + execSync(`npm view ${pkgName}@${version} --json dist`, { + encoding: "utf8", + stdio: ["pipe", "pipe", "pipe"], + }) + ); + if (info.tarball && info.integrity) { + val.resolved = info.tarball; + val.integrity = info.integrity; + fixed++; + } + } catch { + console.error(`Warning: could not fetch info for ${pkgName}@${version}`); + } +} + +fs.writeFileSync(lockPath, JSON.stringify(lock, null, 2) + "\n"); + +if (fixed > 0) { + console.log(`Fixed ${fixed} lockfile entries with missing resolved/integrity`); +} else { + console.log("Lockfile is already complete"); +} diff --git a/scripts/update-nix.sh b/scripts/update-nix.sh new file mode 100755 index 0000000000..58e77cbff9 --- /dev/null +++ b/scripts/update-nix.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# Fix workspace-local lockfile entries and update the Nix dependency hash. +# Requires: node, npm, nix (with prefetch-npm-deps from nixpkgs) +# +# Usage: +# ./scripts/update-nix.sh # fix lockfile + update hash +# ./scripts/update-nix.sh --check # verify everything is up to date (CI mode) +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +LOCK_FILE="$ROOT_DIR/package-lock.json" +PACKAGE_NIX="$ROOT_DIR/nix/package.nix" + +CHECK_MODE=false +if [[ "${1:-}" == "--check" ]]; then + CHECK_MODE=true +fi + +# 1. Fix lockfile (add resolved/integrity for workspace-local entries) +echo "Fixing lockfile..." +node "$SCRIPT_DIR/fix-lockfile.mjs" "$LOCK_FILE" + +# 2. Prefetch deps and compute hash +echo "Prefetching npm dependencies..." +TMPDIR_DEPS="$(mktemp -d)" +trap "rm -rf $TMPDIR_DEPS" EXIT + +prefetch-npm-deps "$LOCK_FILE" "$TMPDIR_DEPS/deps" 2>/dev/null +NEW_HASH="$(nix hash path "$TMPDIR_DEPS/deps")" +echo "Computed hash: $NEW_HASH" + +# 3. Read current hash +CURRENT_HASH="$(grep 'npmDepsHash' "$PACKAGE_NIX" | sed 's/.*"\(.*\)".*/\1/')" + +if [[ "$NEW_HASH" == "$CURRENT_HASH" ]]; then + echo "Hash is already up to date." +else + if $CHECK_MODE; then + echo "ERROR: npmDepsHash is stale." + echo " current: $CURRENT_HASH" + echo " correct: $NEW_HASH" + echo "Run ./scripts/update-nix.sh to fix." + exit 1 + fi + + echo "Updating npmDepsHash in nix/package.nix..." + sed -i.bak "s|npmDepsHash = \".*\"|npmDepsHash = \"$NEW_HASH\"|" "$PACKAGE_NIX" + rm -f "$PACKAGE_NIX.bak" + echo "Updated: $CURRENT_HASH -> $NEW_HASH" +fi