{ 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\"`."; }; publicUseTls = lib.mkOption { type = lib.types.nullOr lib.types.bool; default = null; description = '' Whether the public (client-facing) relay endpoint uses TLS. When `null` (default), the daemon falls back to `relay.useTls`. Override when the internal path is plain `ws://` behind a TLS-terminating reverse proxy. ''; }; }; 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, home-manager profile (`~/.nix-profile/bin` and `~/.local/state/nix/profile/bin`), 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 ( let # Match dataDir's convention. We can't read users.users..home # because the user may be managed outside NixOS. userHome = "/home/${cfg.user}"; in { # mkForce overrides the default PATH from NixOS's systemd module (which # only includes store paths for coreutils/grep/sed/systemd). When the # daemon runs as a real user, also include home-manager profile paths # so user-installed CLIs (claude, opencode, codex, ...) are reachable # by agent processes the daemon spawns. PATH = lib.mkForce (lib.concatStringsSep ":" ( lib.optionals (cfg.user != "paseo") [ "${userHome}/.nix-profile/bin" "${userHome}/.local/state/nix/profile/bin" ] ++ [ "/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"; } // lib.optionalAttrs (cfg.relay.enable && cfg.relay.mode == "remote" && cfg.relay.publicUseTls != null) { PASEO_RELAY_PUBLIC_USE_TLS = if cfg.relay.publicUseTls 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 ]; } ); }