From 0759932dad3c8ddaa1315eff517fa549a87267ae Mon Sep 17 00:00:00 2001 From: Matan Bendix Shenhav Date: Tue, 12 May 2026 07:11:42 +0200 Subject: [PATCH] nix: declarative config, typed relay options, desktop packaging (#923) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * nix: expose npmDepsHash as a callPackage arg Downstream flakes that follow a different nixpkgs revision can hit a hash mismatch on the npm-deps FOD even though package-lock.json is unchanged, because fetchNpmDeps output is sensitive to nixpkgs version. The standard fix — `.overrideAttrs { npmDepsHash = ...; }` — does not work for buildNpmPackage: npmDepsHash is destructured from args, so the default `npmDeps = fetchNpmDeps { hash = npmDepsHash; }` is already bound by the time overrideAttrs runs. Promote npmDepsHash to a callPackage arg with the current value as the default. Consumers can now `.override { npmDepsHash = "sha256-..."; }` and have it propagate to the npmDeps fetcher. Upstream CI behavior is unchanged — update-nix.sh is adjusted to match the new `npmDepsHash ? "..."` pattern. Co-Authored-By: Claude Opus 4.7 (1M context) * nix: move npmDepsHash default to a sidecar file Read the default `npmDepsHash` from `nix/npm-deps.hash` via `lib.fileContents` instead of inlining it as a string literal in `nix/package.nix`. The CI auto-updater becomes a one-line file write instead of a regex against a .nix source — decoupling lockfile bumps from the formatting of the package definition. No behavior change: same hash, same default, same `.override` surface. Lockfile diffs become smaller and the update path stops being load- bearing on a sed pattern. Co-Authored-By: Claude Opus 4.7 (1M context) * nix: declarative config via services.paseo.settings Today only a handful of `config.json` fields are surfaced as module options (listen, port, hostnames, relay.enable). Anything richer — custom agent providers, MCP injection, log config, voice features — requires hand-editing `$PASEO_HOME/config.json`. Add `services.paseo.settings` as a freeform attrset rendered to JSON via `pkgs.formats.json` and installed at `$PASEO_HOME/config.json` on each service start. Standard NixOS idiom. `install` on `preStart` rather than a `tmpfiles` symlink because the daemon writes to `config.json` at runtime via `DaemonConfigStore.patch` (MCP / provider toggles). A read-only symlink would break those writes; a copy-on-start lets the daemon mutate freely within a session while the Nix-managed file remains the source of truth at boot. The full schema is `PersistedConfigSchema` in `packages/server/src/server/persisted-config.ts`. Documented in the option description that runtime mutations don't survive restarts when `settings` is non-empty. Co-Authored-By: Claude Opus 4.7 (1M context) * nix: typed services.paseo.relay options with auto-wired endpoint Addresses #224 (option surface only). Today `services.paseo.relay.enable` is a bool that just toggles `--no-relay`. Pointing the daemon at a self-hosted relay requires hand-setting `PASEO_RELAY_ENDPOINT` and `PASEO_RELAY_USE_TLS` via the freeform `environment` option. Add a typed relay subtree: - `relay.mode = "hosted" | "remote"` selects how the daemon reaches the relay when enabled. Default is `"hosted"` (current behavior). - `relay.{host,port,useTls}` configure the `"remote"` case. - The module auto-wires `PASEO_RELAY_ENDPOINT` and `PASEO_RELAY_USE_TLS` when `mode = "remote"`. - Assertion fires at eval time when `mode = "remote"` but `host` is empty. - `relay.enable` keeps its current semantics — bool answers "is it on?", the new options answer "how is it configured?". The `"local"` mode from #224 (running a relay on the same host as a systemd unit) is deliberately not added here: `packages/relay` ships only a Cloudflare Workers adapter, so there's no Node.js runtime to package as a binary. Adding a Node adapter is a TS-side feature change worth its own design discussion; tracked as a follow-up. No breaking changes — existing `relay.enable = true|false` configs evaluate unchanged with the new `mode = "hosted"` default. Co-Authored-By: Claude Opus 4.7 (1M context) * nix: package paseo desktop app for Linux NixOS users have no easy way to run the desktop app today — electron-builder's outputs (.deb, .rpm, .AppImage) don't fit Nix's model, so `nix run github:getpaseo/paseo#desktop` doesn't exist. Add `packages..desktop` following the standard nixpkgs Electron pattern (see e.g. signal-desktop, vscode): skip electron-builder entirely, build the desktop main process with `tsc`, bundle the Expo web export and built daemon workspaces, and wrap `pkgs.electron` with `makeWrapper`. Output is a runnable derivation usable via `nix run` or `environment.systemPackages`. The install layout preserves the monorepo source tree (`packages/desktop/dist/main.js`, `packages/app/dist`, `node_modules` at the workspace root) so `main.ts`'s dev-mode path resolution (`__dirname/../../app/dist`, `__dirname/../assets/icon.png`) works without any source patches. When Electron is invoked unpackaged via `electron path/to/main.js`, `app.isPackaged` is false and these relative paths are used. `--no-sandbox` is set on the launcher: Chromium's setuid sandbox can't live in `/nix/store` (immutable, no setuid). A follow-up can wire `security.wrappers` from a NixOS module for users who want the renderer sandbox. No CI changes — `desktop-release.yml` continues to produce .deb/.AppImage/.rpm/macOS/Windows installers as today. This is purely additive for NixOS users. Co-Authored-By: Claude Opus 4.7 (1M context) * nix: copy full packages/ tree in desktop derivation The previous installPhase selectively copied built artifacts (dist/ under server, cli, relay, highlight, expo-two-way-audio), which left two workspace symlinks dangling and failed noBrokenSymlinks: - node_modules/@getpaseo/expo-two-way-audio → packages/expo-two-way-audio (the Expo native module ships source + native projects, no built dist/) - node_modules/.bin/paseo → @getpaseo/cli/bin/paseo (the CLI launcher script lives under bin/, not dist/) npm workspace symlinks expect every workspace package to exist at its source path. Copy the whole packages/ tree instead. The cleanSourceWith filter already excludes the heavy platform-specific paths (android/ios under packages/app, website, tests), and the remaining ~16MB of src is acceptable for an Electron app derivation. Co-Authored-By: Claude Opus 4.7 (1M context) * nix: route desktop renderer through paseo:// protocol handler When `paseo-desktop` is launched via `electron path/to/main.js` (our unpackaged Nix layout), `app.isPackaged` is false and main.ts loads `DEV_SERVER_URL` — which defaults to http://localhost:8081 (the Expo dev server). That URL has nothing listening in a Nix-installed run, so the renderer fails with ERR_CONNECTION_REFUSED. main.ts already supports overriding this via the `EXPO_DEV_URL` env var. Set it to `paseo://app/` so the request goes through the `paseo://` protocol handler that main.ts registers unconditionally. The handler resolves files via `getAppDistDir()`, which in the unpackaged branch returns `__dirname/../../app/dist` — exactly where our install layout places the Expo web export. Co-Authored-By: Claude Opus 4.7 (1M context) * ci: track nix/npm-deps.hash in nix-build commit step The commit step still referenced nix/package.nix in its diff check and git add. After moving the hash to nix/npm-deps.hash, the auto-updated hash would never be staged and the new value would sit unstaged in the working tree forever. * ci: push nix-build hash commits via paseo-ai[bot] App token The default GITHUB_TOKEN cannot bypass main's required status checks, so the auto-commit of stale Nix hash updates has been silently failing. Mint an installation token for the paseo-ai App (which is in the ruleset bypass list) and use it for checkout and push. --------- Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: Mohamed Boudra --- .github/workflows/nix-build.yml | 16 +++- flake.nix | 7 ++ nix/desktop-package.nix | 157 ++++++++++++++++++++++++++++++++ nix/module.nix | 96 ++++++++++++++++++- nix/npm-deps.hash | 1 + nix/package.nix | 15 ++- scripts/update-nix.sh | 11 +-- 7 files changed, 286 insertions(+), 17 deletions(-) create mode 100644 nix/desktop-package.nix create mode 100644 nix/npm-deps.hash diff --git a/.github/workflows/nix-build.yml b/.github/workflows/nix-build.yml index 124debdb9..1cf8811ad 100644 --- a/.github/workflows/nix-build.yml +++ b/.github/workflows/nix-build.yml @@ -41,10 +41,16 @@ jobs: permissions: contents: write steps: + - uses: actions/create-github-app-token@v1 + id: app-token + with: + app-id: ${{ secrets.PASEO_BOT_APP_ID }} + private-key: ${{ secrets.PASEO_BOT_APP_PRIVATE_KEY }} + - uses: actions/checkout@v4 with: ref: ${{ github.event.pull_request.head.sha || github.ref }} - token: ${{ secrets.GITHUB_TOKEN }} + token: ${{ steps.app-token.outputs.token }} - uses: actions/setup-node@v4 with: @@ -64,9 +70,9 @@ jobs: - name: Commit hash/lockfile updates (main push only) if: github.event_name == 'push' && github.ref == 'refs/heads/main' 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 diff --quiet package-lock.json nix/npm-deps.hash && exit 0 + git config user.name "paseo-ai[bot]" + git config user.email "266920839+paseo-ai[bot]@users.noreply.github.com" + git add package-lock.json nix/npm-deps.hash git commit -m "fix: update lockfile signatures and Nix hash" git push diff --git a/flake.nix b/flake.nix index f9e194318..4b62acf4d 100644 --- a/flake.nix +++ b/flake.nix @@ -26,11 +26,18 @@ let pkgs = pkgsFor system; paseo = pkgs.callPackage ./nix/package.nix { }; + isLinux = nixpkgs.lib.elem system [ + "x86_64-linux" + "aarch64-linux" + ]; in { default = paseo; paseo = paseo; } + // nixpkgs.lib.optionalAttrs isLinux { + desktop = pkgs.callPackage ./nix/desktop-package.nix { }; + } ); nixosModules.default = self.nixosModules.paseo; diff --git a/nix/desktop-package.nix b/nix/desktop-package.nix new file mode 100644 index 000000000..d7e83a631 --- /dev/null +++ b/nix/desktop-package.nix @@ -0,0 +1,157 @@ +{ + lib, + stdenv, + buildNpmPackage, + nodejs_22, + python3, + makeWrapper, + copyDesktopItems, + makeDesktopItem, + electron, + libuv, + # Shares the daemon's npm-deps hash — same package-lock.json, same fetcher. + # Override via `.override { npmDepsHash = "..."; }` if your nixpkgs computes a + # different value. + npmDepsHash ? lib.fileContents ./npm-deps.hash, +}: + +buildNpmPackage rec { + pname = "paseo-desktop"; + 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 mobile-only platform code (we only need the web/electron build) + !(lib.hasPrefix "/packages/app/android" relPath) + && !(lib.hasPrefix "/packages/app/ios" relPath) + # Website is unrelated to the desktop app + && !(lib.hasPrefix "/packages/website" relPath) + # Test fixtures and build artifacts + && !(lib.hasSuffix ".test.ts" baseName) + && !(lib.hasSuffix ".e2e.test.ts" baseName) + && baseName != "node_modules" + && baseName != ".git" + && baseName != ".paseo" + && baseName != ".DS_Store" + && baseName != "release"; + }; + + nodejs = nodejs_22; + inherit npmDepsHash; + + # Prevent onnxruntime-node's install script from running during automatic + # npm rebuild. We manually rebuild only node-pty in buildPhase. + npmRebuildFlags = [ "--ignore-scripts" ]; + + nativeBuildInputs = [ + python3 # for node-gyp (node-pty) + makeWrapper + copyDesktopItems + ]; + + buildInputs = lib.optionals stdenv.hostPlatform.isLinux [ libuv ]; + + dontNpmBuild = true; + + env = { + EXPO_NO_TELEMETRY = "1"; + # Expo's web build pulls in some pre-bundled assets; ensure it doesn't try + # to phone home during the build. + CI = "1"; + }; + + buildPhase = '' + runHook preBuild + + # Native deps (terminal emulation; libuv-linked on Linux) + npm rebuild node-pty + + # Daemon workspaces (highlight + relay + server + cli) + npm run build:daemon + + # App workspace deps not covered by build:daemon + npm run build --workspace=@getpaseo/expo-two-way-audio + + # Expo web export for the Electron renderer + ( cd packages/app && PASEO_WEB_PLATFORM=electron npx expo export --platform web ) + + # Desktop main process (tsc only — NOT electron-builder) + npm run build:main --workspace=@getpaseo/desktop + + runHook postBuild + ''; + + installPhase = '' + runHook preInstall + + mkdir -p $out/share/paseo-desktop $out/bin + + # Preserve the monorepo layout so main.js's dev-mode path resolution + # (`__dirname/../../app/dist`, `__dirname/../assets/icon.png`) works + # without patching: invoked unpackaged via `electron path/to/main.js`, + # `app.isPackaged` is false, so these relative paths are used. + # + # Copy the entire packages/ tree (not just built artifacts) because npm + # creates workspace symlinks from node_modules/@getpaseo/* into packages/*. + # Missing any workspace package leaves dangling symlinks and fails the + # noBrokenSymlinks output check. The cleanSourceWith filter above already + # drops the big platform-specific things (android/ios, website, tests). + cp package.json $out/share/paseo-desktop/ + cp -a packages $out/share/paseo-desktop/ + cp -a node_modules $out/share/paseo-desktop/ + + # Skills directory referenced at runtime by some agents + if [ -d skills ]; then + cp -a skills $out/share/paseo-desktop/ + fi + + # Hicolor icon for desktop environments + install -Dm644 packages/desktop/assets/icon.png \ + $out/share/icons/hicolor/512x512/apps/paseo-desktop.png + + # Launcher wraps nixpkgs electron. + # --no-sandbox: Chromium's setuid sandbox can't live in /nix/store + # (immutable, no setuid). Acceptable for v1; a follow-up can wire + # `security.wrappers` via a NixOS module for users who want the sandbox. + # + # EXPO_DEV_URL: We run unpackaged via `electron path/to/main.js`, so + # `app.isPackaged` is false. In that mode main.ts loads `DEV_SERVER_URL` + # (defaults to http://localhost:8081 — the Expo dev server, which doesn't + # exist here). Point it at the `paseo://` protocol handler instead, which + # serves from `__dirname/../../app/dist` (our install layout matches). + makeWrapper ${electron}/bin/electron $out/bin/paseo-desktop \ + --add-flags "$out/share/paseo-desktop/packages/desktop/dist/main.js" \ + --add-flags "--no-sandbox" \ + --set EXPO_DEV_URL "paseo://app/" + + copyDesktopItems + + runHook postInstall + ''; + + desktopItems = [ + (makeDesktopItem { + name = "paseo-desktop"; + desktopName = "Paseo"; + genericName = "AI Coding Agents"; + comment = "Self-hosted daemon for AI coding agents"; + exec = "paseo-desktop"; + icon = "paseo-desktop"; + categories = [ "Development" ]; + startupWMClass = "Paseo"; + }) + ]; + + meta = { + description = "Paseo desktop app (Electron wrapper)"; + homepage = "https://github.com/getpaseo/paseo"; + license = lib.licenses.agpl3Plus; + mainProgram = "paseo-desktop"; + platforms = lib.platforms.linux; + }; +} diff --git a/nix/module.nix b/nix/module.nix index 022fc9eb8..8cf1a4998 100644 --- a/nix/module.nix +++ b/nix/module.nix @@ -81,7 +81,48 @@ in enable = lib.mkOption { type = lib.types.bool; default = true; - description = "Whether to enable the relay connection for remote access via app.paseo.sh."; + description = '' + Whether to enable relay-based remote access. When false, the daemon + runs with `--no-relay` and only accepts direct (LAN/loopback) + connections. + ''; + }; + + mode = lib.mkOption { + type = lib.types.enum [ "hosted" "remote" ]; + default = "hosted"; + description = '' + How the daemon reaches the relay when `relay.enable = true`: + + - `"hosted"` (default): use the upstream `app.paseo.sh` relay. + Preserves the current behavior; no extra options needed. + - `"remote"`: connect to a self-hosted relay at + `relay.host:relay.port`. Sets `PASEO_RELAY_ENDPOINT` and + `PASEO_RELAY_USE_TLS` for the daemon. + + A `"local"` mode (running a relay on the same host as a systemd + unit) is not yet implemented — the relay package currently only + ships a Cloudflare Workers adapter. Tracked separately. + ''; + }; + + host = lib.mkOption { + type = lib.types.str; + default = ""; + example = "relay.example.com"; + description = "Relay hostname. Required when `relay.mode = \"remote\"`."; + }; + + port = lib.mkOption { + type = lib.types.port; + default = 443; + description = "Relay port. Used when `relay.mode = \"remote\"`."; + }; + + useTls = lib.mkOption { + type = lib.types.bool; + default = true; + description = "Whether to use TLS when connecting to the relay. Used when `relay.mode = \"remote\"`."; }; }; @@ -111,9 +152,50 @@ in ''; description = "Extra environment variables for the Paseo daemon."; }; + + settings = lib.mkOption { + type = (pkgs.formats.json { }).type; + default = { }; + example = lib.literalExpression '' + { + daemon.mcp = { enabled = true; injectIntoAgents = false; }; + agents.providers.myAcp = { + extends = "acp"; + label = "My Agent"; + command = { path = "/run/current-system/sw/bin/my-acp"; }; + }; + log.file = { level = "info"; path = "/var/lib/paseo/daemon.log"; }; + } + ''; + description = '' + Declarative content for `$PASEO_HOME/config.json`. Rendered to JSON + and installed on every service start. + + Runtime mutations to `config.json` (e.g. via `paseo daemon set-password` + or the mobile app toggling MCP injection / provider overrides) are + overwritten on the next restart. Pick one: manage via this option, or + manage via the CLI — not both. + + The full schema is defined by `PersistedConfigSchema` in + `packages/server/src/server/persisted-config.ts`. + ''; + }; }; - config = lib.mkIf cfg.enable { + config = lib.mkIf cfg.enable ( + let + settingsFile = (pkgs.formats.json { }).generate "paseo-config.json" cfg.settings; + in + { + assertions = [ + { + assertion = !(cfg.relay.enable && cfg.relay.mode == "remote" && cfg.relay.host == ""); + message = '' + services.paseo.relay.host must be set when relay.mode = "remote". + ''; + } + ]; + users.users.${cfg.user} = lib.mkIf (cfg.user == "paseo") { isSystemUser = true; group = cfg.group; @@ -131,6 +213,10 @@ in after = [ "network.target" ]; wantedBy = [ "multi-user.target" ]; + preStart = lib.mkIf (cfg.settings != { }) '' + install -m 0600 ${settingsFile} ${cfg.dataDir}/config.json + ''; + environment = { NODE_ENV = "production"; PASEO_HOME = cfg.dataDir; @@ -149,6 +235,9 @@ in PASEO_HOSTNAMES = "true"; } // lib.optionalAttrs (lib.isList cfg.hostnames && cfg.hostnames != [ ]) { PASEO_HOSTNAMES = lib.concatStringsSep "," cfg.hostnames; + } // lib.optionalAttrs (cfg.relay.enable && cfg.relay.mode == "remote") { + PASEO_RELAY_ENDPOINT = "${cfg.relay.host}:${toString cfg.relay.port}"; + PASEO_RELAY_USE_TLS = if cfg.relay.useTls then "true" else "false"; } // cfg.environment; serviceConfig = { @@ -172,5 +261,6 @@ in environment.systemPackages = [ cfg.package ]; networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall [ cfg.port ]; - }; + } + ); } diff --git a/nix/npm-deps.hash b/nix/npm-deps.hash new file mode 100644 index 000000000..42b510c59 --- /dev/null +++ b/nix/npm-deps.hash @@ -0,0 +1 @@ +sha256-qXCfTM7Q1PyXL53C+AFgFA5b99uznKaKomwmX2UcZHo= diff --git a/nix/package.nix b/nix/package.nix index 47a087bf6..8df5fcd37 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -7,6 +7,15 @@ makeWrapper, # node-pty needs libuv headers on Linux libuv, + # Exposed so downstream flakes that follow a different nixpkgs revision + # (where `fetchNpmDeps` may produce a different hash for the same lockfile) + # can override via `.override { npmDepsHash = "sha256-..."; }` without + # `overrideAttrs` gymnastics — `npmDepsHash` is destructured from + # `buildNpmPackage`'s args, so `overrideAttrs` cannot reach it. + # + # The default is read from a sidecar file so the CI auto-updater can replace + # the hash with a single file write instead of a sed against this source. + npmDepsHash ? lib.fileContents ./npm-deps.hash, }: buildNpmPackage rec { @@ -40,9 +49,9 @@ buildNpmPackage rec { 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-qXCfTM7Q1PyXL53C+AFgFA5b99uznKaKomwmX2UcZHo="; + # Default hash lives in nix/npm-deps.hash (see arg default above). + # CI auto-updates that file when package-lock.json changes (see .github/workflows/). + inherit npmDepsHash; # 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). diff --git a/scripts/update-nix.sh b/scripts/update-nix.sh index 387007dfb..108cd0d60 100755 --- a/scripts/update-nix.sh +++ b/scripts/update-nix.sh @@ -10,7 +10,7 @@ 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" +HASH_FILE="$ROOT_DIR/nix/npm-deps.hash" CHECK_MODE=false if [[ "${1:-}" == "--check" ]]; then @@ -42,8 +42,8 @@ if ! NEW_HASH="$(nix shell "${NIXPKGS_URL}#prefetch-npm-deps" -c prefetch-npm-de fi echo "Computed hash: $NEW_HASH" -# 3. Read current hash -CURRENT_HASH="$(grep 'npmDepsHash' "$PACKAGE_NIX" | sed 's/.*"\(.*\)".*/\1/')" +# 3. Read current hash from the sidecar file +CURRENT_HASH="$(tr -d '[:space:]' < "$HASH_FILE")" if [[ "$NEW_HASH" == "$CURRENT_HASH" ]]; then echo "Hash is already up to date." @@ -56,8 +56,7 @@ else 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 "Updating nix/npm-deps.hash..." + printf '%s\n' "$NEW_HASH" > "$HASH_FILE" echo "Updated: $CURRENT_HASH -> $NEW_HASH" fi