mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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.<linux>.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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
267 lines
8.4 KiB
Nix
267 lines
8.4 KiB
Nix
{
|
|
config,
|
|
lib,
|
|
pkgs,
|
|
...
|
|
}:
|
|
|
|
let
|
|
cfg = config.services.paseo;
|
|
in
|
|
{
|
|
imports = [
|
|
(lib.mkRenamedOptionModule [ "services" "paseo" "allowedHosts" ] [ "services" "paseo" "hostnames" ])
|
|
];
|
|
|
|
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.";
|
|
};
|
|
|
|
hostnames = lib.mkOption {
|
|
type = lib.types.either (lib.types.enum [ true ]) (lib.types.listOf lib.types.str);
|
|
default = [ ];
|
|
example = [ ".example.com" "myhost.local" ];
|
|
description = ''
|
|
Hostnames the Paseo daemon accepts in the Host header (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 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\"`.";
|
|
};
|
|
};
|
|
|
|
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.";
|
|
};
|
|
|
|
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 (
|
|
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;
|
|
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" ];
|
|
|
|
preStart = lib.mkIf (cfg.settings != { }) ''
|
|
install -m 0600 ${settingsFile} ${cfg.dataDir}/config.json
|
|
'';
|
|
|
|
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.hostnames == true) {
|
|
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 = {
|
|
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 ];
|
|
}
|
|
);
|
|
}
|