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