From c61ba320fdd892eea3d615346388475938bb5249 Mon Sep 17 00:00:00 2001 From: Chris Tam Date: Sat, 9 May 2026 11:50:16 -0400 Subject: [PATCH] feat(nix): Add official flake with home-manager and NixOS support (#402) * nix: add official flake with home-manager and nixos modules * Pin pnpm version * Format README.md * Populate PATH files to discover installed CLIs * Revert "Populate PATH files to discover installed CLIs" This reverts commit 18d88781a88b8781913cf5a8b680dfb38eabf7e4. * Fix missing sqlite issue * Fix system issue * Reapply "Populate PATH files to discover installed CLIs" This reverts commit d02ea994e6edda8196d5977869a2e46b5e6e9887. * Handle different ports for web frontend * Provide documentation for getting pnpm hash * Enable nix flake checks for code changes * Set `OD_WEB_PORT` on daemon when declared * fix: Fix environmentFile for macOS targets * chore: Ignore nix and direnv related files * fix: Read version directly from `package.json` * feat: Make nix shell entry prettier * chore: Update pnpm hashes * chore: Bump `pnpm` hashes * docs: Add blurb about dev shell in `README.md` * Address review comments * Add support for `OD_WEB_ORIGINS` * Fix `isLocalSameOrigin` * Update pnpm checksums * docs: Update documentation on host origins * Move allowedOrigins mapping out of the webFrontend.enable guard * fix: Bump pnpm hashes * Remove changes to `daemon` with `main` changes `main` merged a feature that addressed our need for allowed origins. Since this feature branch no longer needs it, remove any remaining changes in `daemon` code so that this is a pure Nix change. * Update documentation around `OD_DAEMON_URL` * Rewrite option docs to match same-origin proxy contract The port, webFrontend, and webFrontend.port option descriptions still described OD_DAEMON_URL as the runtime contract for the SPA, but the SPA issues relative /api/*, /artifacts/*, /frames/* requests and there is no runtime daemon-URL injection. Rewrite the three blocks to describe what the caddy / custom proxy must actually do. * Document daemon-side requirements for custom-server proxy paths The bring-your-own-server path in section (3) and the same-origin contract in section (4) understated what the daemon needs: any proxy whose origin differs from the daemon's bind (including loopback split-port like 127.0.0.1:8080 while the daemon stays on :7457) is 403'd by the daemon's same-origin gate until told about that origin. Add a callout under section (3)'s table, expand section (4) with a decision table covering same-port, loopback split-port (OD_WEB_PORT or webFrontend.allowedOrigins), and non-loopback (webFrontend.allowedOrigins) cases, and rewrite the webFrontend.allowedOrigins option description to enumerate the cases where it's required and surface OD_WEB_PORT as an alternative for the loopback split-port case. --------- Co-authored-by: lefarcen <935902669@qq.com> --- .github/workflows/nix-check.yml | 90 ++++++++++ .gitignore | 3 + README.md | 23 ++- flake.lock | 187 +++++++++++++++++++++ flake.nix | 120 +++++++++++++ nix/README.md | 236 ++++++++++++++++++++++++++ nix/home-manager.nix | 289 ++++++++++++++++++++++++++++++++ nix/module-common.nix | 244 +++++++++++++++++++++++++++ nix/nixos.nix | 267 +++++++++++++++++++++++++++++ nix/package-daemon.nix | 178 ++++++++++++++++++++ nix/package-web.nix | 91 ++++++++++ 11 files changed, 1727 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/nix-check.yml create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 nix/README.md create mode 100644 nix/home-manager.nix create mode 100644 nix/module-common.nix create mode 100644 nix/nixos.nix create mode 100644 nix/package-daemon.nix create mode 100644 nix/package-web.nix diff --git a/.github/workflows/nix-check.yml b/.github/workflows/nix-check.yml new file mode 100644 index 0000000000..dcae8e20ea --- /dev/null +++ b/.github/workflows/nix-check.yml @@ -0,0 +1,90 @@ +name: nix-check + +on: + push: + paths-ignore: + - '**/*.md' + - '**/*.mdx' + - '**/*.txt' + - LICENSE + - .gitignore + - .editorconfig + - .vscode/** + - .idea/** + - docs/** + - assets/** + - '**/*.png' + - '**/*.jpg' + - '**/*.jpeg' + - '**/*.gif' + - '**/*.svg' + - '**/*.webp' + - .github/ISSUE_TEMPLATE/** + - .github/PULL_REQUEST_TEMPLATE.md + - .github/CODEOWNERS + pull_request: + paths-ignore: + - '**/*.md' + - '**/*.mdx' + - '**/*.txt' + - LICENSE + - .gitignore + - .editorconfig + - .vscode/** + - .idea/** + - docs/** + - assets/** + - '**/*.png' + - '**/*.jpg' + - '**/*.jpeg' + - '**/*.gif' + - '**/*.svg' + - '**/*.webp' + - .github/ISSUE_TEMPLATE/** + - .github/PULL_REQUEST_TEMPLATE.md + - .github/CODEOWNERS + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Nix + uses: cachix/install-nix-action@v27 + with: + extra_nix_config: | + experimental-features = nix-command flakes + accept-flake-config = true + + # - name: Configure Cachix (always pull; push only with token) + # uses: cachix/cachix-action@v15 + # with: + # name: nexu-open-design + # # Empty token = read-only mode. Forks/PRs from external + # # contributors still get cache hits but cannot push back. + # authToken: ${{ secrets.CACHIX_AUTH_TOKEN }} + # skipPush: ${{ secrets.CACHIX_AUTH_TOKEN == '' }} + + - name: nix flake check + run: nix flake check --print-build-logs --keep-going + + - name: nix build .#daemon + id: build-daemon + run: nix build .#daemon --print-build-logs --log-format raw |& tee daemon-build.log + + - name: nix build .#web + id: build-web + run: nix build .#web --print-build-logs --log-format raw |& tee web-build.log + + - name: Upload build logs (on failure) + if: failure() + uses: actions/upload-artifact@v4 + with: + name: nix-build-logs + path: | + daemon-build.log + web-build.log + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index 3f90c96921..38ee51be23 100644 --- a/.gitignore +++ b/.gitignore @@ -52,5 +52,8 @@ task.md specs/change/active .ralph/ +# Nix and direnv +.direnv/ +.envrc # Local design assistant context .impeccable.md diff --git a/README.md b/README.md index 3b0144a06f..a28a9dc750 100644 --- a/README.md +++ b/README.md @@ -601,6 +601,27 @@ The desktop app discovers the web URL automatically via sidecar IPC — no port For fixed-port restarts, background startup, and full troubleshooting see [`QUICKSTART.md`](QUICKSTART.md). +## Nix + +A flake is published at the repo root. Home Manager is the recommended path for individual developers; a NixOS module is also exposed for shared/server installs. See [`nix/README.md`](nix/README.md) for the full surface (data dir, secrets, `webFrontend` vs. bringing your own server, `OD_DAEMON_URL`). + +```nix +# Home Manager +inputs.open-design.url = "github:nexu-io/open-design"; +# then: imports = [ inputs.open-design.homeManagerModules.default ]; +``` + +```bash +nix run github:nexu-io/open-design # boot the daemon (`od`) without installing +``` + +For developers, a Nix dev shell is available and can be used with `direnv` too: + +```bash +nix develop # dev shell with required dependencies to work on Open Design +``` + + ## Use Open Design from your coding agent Open Design ships a stdio MCP server. Wire it into Claude Code, Codex, Cursor, VS Code, Antigravity, Zed, Windsurf, or any MCP-compatible client and the agent in another repo can read files from your local Open Design projects directly. Replaces the export-then-attach loop. When the agent calls `search_files`, `get_file`, or `get_artifact` without a project argument, the MCP defaults to whatever project (and file) you have open in Open Design right now, so prompts like *"build this in my app"* or *"match these styles"* just work. @@ -611,7 +632,7 @@ Open **Settings → MCP server** in the Open Design app for a per-client install The daemon must be running locally for MCP tool calls to succeed. If the agent was started before Open Design, restart the agent after Open Design is up so it can reach the live daemon. Tool calls made while the daemon is offline return a clear `"daemon not reachable"` error rather than a crash. -**Security model.** The MCP server is read-only; it exposes file reads, file metadata, and search -- nothing that writes to disk or calls an external service. It runs as a child process of the coding agent over stdio, so any MCP client you register inherits read access to your local Open Design projects. Treat it like installing a VS Code extension: only register clients you trust. The daemon binds to `127.0.0.1` by default; LAN-wide exposure requires an explicit `OD_BIND_HOST` opt-in. +**Security model.** The MCP server is read-only; it exposes file reads, file metadata, and search -- nothing that writes to disk or calls an external service. It runs as a child process of the coding agent over stdio, so any MCP client you register inherits read access to your local Open Design projects. Treat it like installing a VS Code extension: only register clients you trust. The daemon binds to `127.0.0.1` by default; LAN-wide exposure requires an explicit `OD_BIND_HOST` opt-in. If you also front the SPA with a non-loopback static server, set `OD_ALLOWED_ORIGINS=,,...` (comma-separated `scheme://host[:port]` entries) so the daemon's same-origin gate accepts API writes from those origins on both the `Origin` and `Host` checks; without it the browser will see 403s on every PUT/POST (Caddy v2 reverse_proxy preserves the original Host header upstream by default, so loopback alone is not enough). Connector-credential and live-artifact preview routes stay loopback-only regardless. ## Repository structure diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000000..f989938749 --- /dev/null +++ b/flake.lock @@ -0,0 +1,187 @@ +{ + "nodes": { + "dream2nix": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ], + "purescript-overlay": "purescript-overlay", + "pyproject-nix": "pyproject-nix" + }, + "locked": { + "lastModified": 1765953015, + "narHash": "sha256-5FBZbbWR1Csp3Y2icfRkxMJw/a/5FGg8hCXej2//bbI=", + "owner": "nix-community", + "repo": "dream2nix", + "rev": "69eb01fa0995e1e90add49d8ca5bcba213b0416f", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "dream2nix", + "type": "github" + } + }, + "flake-compat": { + "flake": false, + "locked": { + "lastModified": 1696426674, + "narHash": "sha256-kvjfFW7WAETZlt09AgDn1MrtKzP7t90Vf7vypd3OL1U=", + "owner": "edolstra", + "repo": "flake-compat", + "rev": "0f9255e01c2351cc7d116c072cb317785dd33b33", + "type": "github" + }, + "original": { + "owner": "edolstra", + "repo": "flake-compat", + "type": "github" + } + }, + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "home-manager": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1777679572, + "narHash": "sha256-egYNbRrkn+6SwTHinhdb6WUfzzdC3nXfCRqS321VylY=", + "owner": "nix-community", + "repo": "home-manager", + "rev": "9cb587ade2aa1b4a7257f0238d41072690b0ca4f", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "home-manager", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1777268161, + "narHash": "sha256-bxrdOn8SCOv8tN4JbTF/TXq7kjo9ag4M+C8yzzIRYbE=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "1c3fe55ad329cbcb28471bb30f05c9827f724c76", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "purescript-overlay": { + "inputs": { + "flake-compat": "flake-compat", + "nixpkgs": [ + "dream2nix", + "nixpkgs" + ], + "slimlock": "slimlock" + }, + "locked": { + "lastModified": 1728546539, + "narHash": "sha256-Sws7w0tlnjD+Bjck1nv29NjC5DbL6nH5auL9Ex9Iz2A=", + "owner": "thomashoneyman", + "repo": "purescript-overlay", + "rev": "4ad4c15d07bd899d7346b331f377606631eb0ee4", + "type": "github" + }, + "original": { + "owner": "thomashoneyman", + "repo": "purescript-overlay", + "type": "github" + } + }, + "pyproject-nix": { + "inputs": { + "nixpkgs": [ + "dream2nix", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1763017646, + "narHash": "sha256-Z+R2lveIp6Skn1VPH3taQIuMhABg1IizJd8oVdmdHsQ=", + "owner": "pyproject-nix", + "repo": "pyproject.nix", + "rev": "47bd6f296502842643078d66128f7b5e5370790c", + "type": "github" + }, + "original": { + "owner": "pyproject-nix", + "repo": "pyproject.nix", + "type": "github" + } + }, + "root": { + "inputs": { + "dream2nix": "dream2nix", + "flake-utils": "flake-utils", + "home-manager": "home-manager", + "nixpkgs": "nixpkgs" + } + }, + "slimlock": { + "inputs": { + "nixpkgs": [ + "dream2nix", + "purescript-overlay", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1688756706, + "narHash": "sha256-xzkkMv3neJJJ89zo3o2ojp7nFeaZc2G0fYwNXNJRFlo=", + "owner": "thomashoneyman", + "repo": "slimlock", + "rev": "cf72723f59e2340d24881fd7bf61cb113b4c407c", + "type": "github" + }, + "original": { + "owner": "thomashoneyman", + "repo": "slimlock", + "type": "github" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000000..3381ea8178 --- /dev/null +++ b/flake.nix @@ -0,0 +1,120 @@ +{ + description = "Open Design — local-first design product. Daemon (`od` CLI) + Next.js static web frontend."; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + dream2nix = { + url = "github:nix-community/dream2nix"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + home-manager = { + url = "github:nix-community/home-manager"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + }; + + outputs = { + self, + nixpkgs, + flake-utils, + dream2nix, + home-manager, + }: let + perSystem = flake-utils.lib.eachDefaultSystem (system: let + pkgs = import nixpkgs {inherit system;}; + nodejs = pkgs.nodejs_24; + + # nixpkgs ships pnpm 10.33.0; the repo's package.json declares + # `engines.pnpm: ">=10.33.2 <11"` and pnpm refuses to install + # against an older binary. Override the upstream tarball to + # the exact version pinned by `packageManager`. Bump the url + # + hash in lockstep with package.json#packageManager. + # + # When bumping versions, run the following to get a new hash: + # + # ```bash + # nix store prefetch-file --hash-type sha256 \ + # https://registry.npmjs.org/pnpm/-/pnpm-${NEW_VERSION}.tgz + # ``` + pnpm_10 = pkgs.pnpm_10.overrideAttrs (_old: rec { + version = "10.33.2"; + src = pkgs.fetchurl { + url = "https://registry.npmjs.org/pnpm/-/pnpm-${version}.tgz"; + hash = "sha256-envPE9f2zrOUbAOXg3PZm+n94cr8MAC9/tTE95EWdhA="; + }; + }); + + daemon = pkgs.callPackage ./nix/package-daemon.nix { + inherit dream2nix nixpkgs system nodejs pnpm_10; + src = self; + }; + web = pkgs.callPackage ./nix/package-web.nix { + inherit dream2nix nixpkgs system nodejs pnpm_10; + src = self; + }; + in { + packages = { + inherit daemon web; + default = daemon; + }; + + # Wrap `od` with `--no-open` for `nix run`: the daemon package + # builds the daemon workspace only, not `apps/web/out/`, so the + # browser would otherwise auto-open onto an empty static dir. + apps.default = { + type = "app"; + program = "${pkgs.writeShellScript "od-nix-run" '' + exec ${daemon}/bin/od --no-open "$@" + ''}"; + meta.description = "Open Design local daemon (`od`)"; + }; + + devShells.default = pkgs.mkShell { + packages = [ + nodejs + pnpm_10 + ]; + shellHook = '' + echo "🎨 Open Design dev shell loaded!" + echo "" + echo "Language runtimes:" + echo " - 🐢 Node.js: $(node --version 2>/dev/null || echo 'not found')" + echo " - 📦 pnpm: $(pnpm --version 2>/dev/null || echo 'not found')" + echo "" + echo "Quick start:" + echo " - 🚀 pnpm install" + echo " - 🚀 pnpm tools-dev # local lifecycle entry point" + echo "" + ''; + }; + + checks = { + daemon = daemon; + web = web; + }; + + formatter = pkgs.nixpkgs-fmt; + }); + + moduleCommon = import ./nix/module-common.nix; + in + perSystem + // { + homeManagerModules = rec { + open-design = import ./nix/home-manager.nix { + inherit moduleCommon; + flake = self; + }; + default = open-design; + }; + + nixosModules = rec { + open-design = import ./nix/nixos.nix { + inherit moduleCommon; + flake = self; + }; + default = open-design; + }; + }; +} diff --git a/nix/README.md b/nix/README.md new file mode 100644 index 0000000000..5465af72f4 --- /dev/null +++ b/nix/README.md @@ -0,0 +1,236 @@ +# Open Design — Nix flake + +This flake exposes Open Design as a reproducible package, a `nix run` entry +point, a dev shell, and Home Manager / NixOS modules. The architecture +mirrors the runtime: the **daemon** (`od` CLI, Express API on `/api/*`) +and the **web frontend** (Next.js static SPA at `apps/web/out/`) are +**separate packages** and **separate services** — you can run either or +both. + +## Outputs + +| Output | What it is | +| ------------------------------------------ | -------------------------------------------------------------------------------------- | +| `packages..daemon` | The `@open-design/daemon` package — produces `bin/od`. Default output. | +| `packages..web` | The Next.js static export (`apps/web/out/`) ready to drop into any static file server. | +| `apps..default` | `nix run github:nexu-io/open-design` — boots the daemon. | +| `devShells..default` | Node 24 + Corepack-pinned pnpm 10.33 — reproduces `pnpm install` locally. | +| `homeManagerModules.{default,open-design}` | Home Manager module — primary individual-developer interface. | +| `nixosModules.{default,open-design}` | NixOS module — secondary, for shared/server installs. | + +## Try it without installing + +```bash +nix run github:nexu-io/open-design # boots the daemon on :7457 +nix develop github:nexu-io/open-design # drop into the dev shell +``` + +## (1) Home Manager — the recommended path + +For an individual workstation, add the flake as an input and import the +default module: + +```nix +{ + inputs.open-design.url = "github:nexu-io/open-design"; + + outputs = { self, home-manager, open-design, ... }: { + homeConfigurations.you = home-manager.lib.homeManagerConfiguration { + modules = [ + open-design.homeManagerModules.default + { + services.open-design = { + enable = true; + autoStart = true; # systemd --user / launchd agent + webFrontend.enable = true; # also run the static SPA on :5174 + }; + } + ]; + }; + }; +} +``` + +What this wires up: + +- Linux: `systemd --user` units `open-design.service` and (optionally) + `open-design-web.service`. `systemctl --user status open-design`. +- macOS: `launchd` agents `io.nexu.open-design` and (optionally) + `io.nexu.open-design-web`. `launchctl print gui/$UID/io.nexu.open-design`. +- Data lives in `$HOME/.od/` by default — override `dataDir` to relocate. + +## (2) NixOS — for shared/server installs + +```nix +{ + imports = [ inputs.open-design.nixosModules.default ]; + + services.open-design = { + enable = true; + autoStart = true; + openFirewall = true; + webFrontend.enable = true; + user = "open-design"; + group = "open-design"; + }; +} +``` + +This creates a system user, drops a tmpfiles rule for `/var/lib/open-design`, +and runs the daemon under hardened systemd (`ProtectSystem=strict`, +`PrivateTmp`, `ReadWritePaths` scoped to the data directory). Use this +when you want a single shared instance — for individual user +configuration prefer the Home Manager module. + +## (3) `webFrontend` — when to use it, when to bring your own server + +Open Design's frontend is a static SPA that issues relative `/api/*`, +`/artifacts/*`, and `/frames/*` requests. Three serving options: + +| Option | When | +| -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `webFrontend.enable = true` | You want one-line setup. The module spawns a tiny Caddy file server on `webFrontend.port` (default `5174`) that serves the SPA and reverse-proxies the three path prefixes to the daemon. | +| `webFrontend.enable = false` (default) | You're running nginx / Caddy / Apache / Traefik yourself. Point your server's document root at `${pkgs.open-design.web}` (or the `packages..web` output) and replicate the proxy contract in section (4). | +| Skip the frontend entirely | You only need the daemon's API for headless agent dispatch. | + +The two services are independent. `autoStart` controls the daemon; +`webFrontend.enable` controls the static server. Mix freely. + +> **Bring-your-own-server gotcha:** if your proxy listens on any +> origin that differs from the daemon's bind (different host _or_ +> different port — even loopback split-port like +> `http://127.0.0.1:8080` while the daemon stays on `:7457`), the +> daemon's same-origin gate will 403 the SPA's writes until you tell +> it about that origin. Either set +> `services.open-design.webFrontend.allowedOrigins = [ "" ]` +> (which feeds `OD_ALLOWED_ORIGINS`) or, for the loopback-only +> split-port case, set `extraEnv.OD_WEB_PORT = ""`. See +> section (4) for the full decision tree. + +### Exposing the bundled frontend on a non-loopback host + +By default `webFrontend.host = "127.0.0.1"` so enabling the bundled +caddy does not publish anything beyond loopback. To intentionally +share with a LAN, two settings must be widened together — the +modules assert at eval time that the second is set whenever the +first is widened: + +```nix +services.open-design.webFrontend = { + enable = true; + host = "0.0.0.0"; # caddy listener + # Every external origin browsers will load the SPA from. The daemon + # matches each entry against the browser's `Origin` header AND adds + # its host:port to the `Host`-header allowlist (Caddy v2 reverse_proxy + # preserves the original Host upstream by default), so list each + # scheme + hostname combo you actually use. + allowedOrigins = [ + "http://laptop.local:5174" + "https://laptop.local:5174" + ]; +}; +# On NixOS you also need: +services.open-design.openFirewall = true; +``` + +Under the hood `allowedOrigins` is forwarded to the daemon as the +`OD_ALLOWED_ORIGINS` environment variable (comma-separated). If you +run the daemon outside the modules — for example, behind your own +nginx/caddy — set `OD_ALLOWED_ORIGINS` directly in the daemon's +environment with the same shape: + +``` +OD_ALLOWED_ORIGINS=http://host1:port,https://host1:port,http://host2:port +``` + +Each entry must be a bare origin (`scheme://host[:port]`); only +`http://` and `https://` schemes are accepted, and the daemon refuses +to start if any entry fails to parse. The variable widens only the +general `/api/*` same-origin gate — connector-credential and +live-artifact preview/refresh routes stay strictly loopback-only by +design. + +## (4) Same-origin proxying contract + +The web package is built with `OD_DAEMON_URL = ""` so the bundled JS +issues **relative** requests — `/api/*`, `/artifacts/*`, `/frames/*` — +instead of baking a daemon URL into the export. There is no runtime +config endpoint; the SPA does not read `OD_DAEMON_URL` from the +serving environment. + +The serving contract is therefore: **the static export must be served +same-origin with a reverse proxy to the daemon**. The bundled caddy +service does exactly this — `webFrontend` listens on +`webFrontend.port` and reverse-proxies the three path prefixes above +to `127.0.0.1:`, with `flush_interval -1` and no `encode` on +`/api/*` so SSE streams flush immediately (gzip would buffer chunked +responses for ~80s and surface as `ERR_INCOMPLETE_CHUNKED_ENCODING`). + +If you serve the static bundle yourself, replicate that shape: + +- Document root → `${pkgs.open-design.web}` (or + `packages..web`). +- Reverse-proxy `/api/*`, `/artifacts/*`, `/frames/*` to the daemon's + bind address; `/api/*` must stream chunks immediately and skip + response compression. +- SPA fallback for unmatched paths → `index.html`. + +The static-server's environment does not need any Open Design env +vars — but **the daemon's environment usually does**, because its +same-origin gate is built from `OD_BIND_HOST:port` (loopback hosts +included). The browser's `Origin` and `Host` are whatever your proxy +exposes, so unless that matches `127.0.0.1:` exactly, +the daemon will 403 every PUT/POST until told otherwise: + +| Your custom-server setup | What to set on the daemon | +| ------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| Proxy at `http://127.0.0.1:` (same host, same port — unusual) | Nothing. | +| Proxy at a loopback host but different port (e.g. `http://127.0.0.1:8080` while daemon is on `:7457`) | Either `extraEnv.OD_WEB_PORT = "8080"` (whitelists `8080` on every loopback host) or `services.open-design.webFrontend.allowedOrigins`. | +| Proxy on any non-loopback host (LAN IP, mDNS name, Tailscale name, public domain — `https://od.example.com`, `http://laptop.local:5174`, …) | `services.open-design.webFrontend.allowedOrigins = [ "" ]`. List every scheme + host[:port] combo a browser might load the SPA from. | + +`webFrontend.allowedOrigins` is forwarded to the daemon as +`OD_ALLOWED_ORIGINS`; if you run the daemon outside the modules, +export `OD_ALLOWED_ORIGINS` directly with the same shape (see +section (3)). The variable widens only the general `/api/*` gate — +connector-credential and live-artifact preview/refresh routes stay +strictly loopback-only by design. + +## (5) Secrets — DO NOT put them in your Nix config + +The `environmentFile` option takes a path to a `KEY=VALUE` file that the +service unit reads. Use it for BYOK API keys (Anthropic, OpenAI, Gemini), +provider tokens, and anything else you do not want world-readable in +`/nix/store`. + +Recommended secret managers: + +- [sops-nix](https://github.com/Mic92/sops-nix) — age- or PGP-encrypted + YAML, decrypted into runtime files at activation. +- [agenix](https://github.com/ryantm/agenix) — age-encrypted single + files, dropped into `/run/agenix/` at boot. + +Either renders to a file like `/run/secrets/open-design.env`; pass that +path: + +```nix +services.open-design.environmentFile = "/run/secrets/open-design.env"; +``` + +Never inline a secret with `pkgs.writeText` or `home.file`. + +## First-build hash pinning + +Both `nix/package-daemon.nix` and `nix/package-web.nix` vendor the pnpm +store via a fixed-output derivation (`pnpmDeps`). The `outputHash` +defaults to `lib.fakeSha256` so `nix build` will fail with the expected +hash printed. Copy that value into the matching `pnpmDepsHash` constant +at the top of each file and re-run. Bump the hash whenever +`pnpm-lock.yaml` changes. + +## CI + +`.github/workflows/nix-check.yml` runs `nix flake check` followed by +separate `nix build .#daemon` and `nix build .#web` steps on each push +that touches the flake or the lockfile. Build artifacts are cached on +the `nexu-open-design` Cachix instance — PRs from forks read from the +cache without needing the auth token. diff --git a/nix/home-manager.nix b/nix/home-manager.nix new file mode 100644 index 0000000000..9fd883f20e --- /dev/null +++ b/nix/home-manager.nix @@ -0,0 +1,289 @@ +# Home Manager module for Open Design — primary interface for individual +# developers. Linux uses systemd --user units; macOS uses launchd agents. +# +# Both the daemon and the optional web frontend are user-scoped and run +# as the user's UID — there is no system user, no setuid, and no +# privileged port binding by default. +# +# Usage: +# imports = [ inputs.open-design.homeManagerModules.default ]; +# services.open-design = { +# enable = true; +# autoStart = true; +# webFrontend.enable = true; +# }; +{ + moduleCommon, + flake, +}: { + config, + lib, + pkgs, + ... +}: let + cfg = config.services.open-design; + + commonOpts = moduleCommon { + inherit lib pkgs flake; + defaultDataDir = "${config.home.homeDirectory}/.od"; + }; + + daemonExe = lib.getExe cfg.package; + + # Static file server. caddy is the sweet spot: single binary, handles + # SPA-style fallback if any deep link bypasses the trailingSlash + # directories Next.js emits, and ~30MB is acceptable for an opt-in + # service. Users who want lighter can override + # `services.open-design.webFrontend.package` and bring their own + # server — though that disables the bundled service in favor of + # whatever they wire up. + caddy = pkgs.caddy; + + # Synthesize a Caddyfile pointing at the static package's out tree. + # + # The web frontend is a static SPA bundled at build time with + # `OD_DAEMON_URL=""`, so the bundled JS calls relative URLs like + # `/api/agents`, `/artifacts/...`, and `/frames/...`. The daemon + # serves those routes on `cfg.port`; caddy reverse-proxies them so + # the SPA works same-origin without CORS or runtime config. + # + # /api/* needs SSE-safe proxying: + # * `flush_interval -1` → stream chunks immediately + # * no `encode` on the API route → gzip would buffer chunked SSE + # responses for ~80s and surface as ERR_INCOMPLETE_CHUNKED_ENCODING + # in browsers (same failure mode QUICKSTART.md calls out for + # nginx). + # * generous read/write timeouts for long-running streams. + caddyfile = pkgs.writeText "open-design-web.Caddyfile" '' + { + auto_https off + admin off + persist_config off + } + + ${cfg.webFrontend.host}:${toString cfg.webFrontend.port} { + handle /api/* { + reverse_proxy 127.0.0.1:${toString cfg.port} { + flush_interval -1 + transport http { + read_timeout 86400s + write_timeout 86400s + } + } + } + handle /artifacts/* { + reverse_proxy 127.0.0.1:${toString cfg.port} + } + handle /frames/* { + reverse_proxy 127.0.0.1:${toString cfg.port} + } + handle { + root * ${cfg.webFrontend.package} + try_files {path} {path}/ /index.html + file_server + encode gzip + } + } + ''; + + # systemd --user units (and macOS launchd agents) start with a minimal + # default PATH that excludes Home Manager and NixOS user-profile bin + # directories. The daemon scans `process.env.PATH` for agent CLIs, so + # without an explicit PATH the UI reports "no agents detected" even when + # claude / codex / opencode / ... are installed. + # + # `${config.home.profileDirectory}/bin` covers both standalone HM + # (~/.nix-profile/bin) and HM-as-NixOS-module (/etc/profiles/per-user//bin). + # The remaining Linux entries pick up wrappers, the system profile, and + # the default Nix profile. Darwin gets the standard launchd PATH. + daemonPathEntries = + ["${config.home.profileDirectory}/bin"] + ++ lib.optionals pkgs.stdenv.isLinux [ + "/run/wrappers/bin" + "/etc/profiles/per-user/${config.home.username}/bin" + "/run/current-system/sw/bin" + "/nix/var/nix/profiles/default/bin" + "/usr/local/bin" + "/usr/bin" + "/bin" + ] + ++ lib.optionals pkgs.stdenv.isDarwin [ + "/usr/local/bin" + "/usr/bin" + "/bin" + "/usr/sbin" + "/sbin" + ] + ++ cfg.extraBinPaths; + + # Conservative loopback check used to gate the allowedOrigins + # assertion. Anything not matched here requires the operator to + # declare external origins explicitly so the daemon's CSRF gate + # actually accepts SPA writes. + isLoopbackHost = h: + h == "127.0.0.1" + || h == "localhost" + || h == "::1" + || h == "[::1]" + || lib.hasPrefix "127." h; + + daemonEnv = + { + OD_PORT = toString cfg.port; + OD_DATA_DIR = toString cfg.dataDir; + PATH = lib.concatStringsSep ":" daemonPathEntries; + } + // lib.optionalAttrs cfg.webFrontend.enable { + # Tell the daemon's same-origin allowlist about the caddy port, + # otherwise PUT/POST requests from the SPA served on + # `webFrontend.port` get 403'd by the /api middleware + # (apps/daemon/src/server.ts buildAllowedOrigins). + OD_WEB_PORT = toString cfg.webFrontend.port; + } + // lib.optionalAttrs (cfg.webFrontend.allowedOrigins != []) { + # Operator-declared external origins for the LAN-exposure escape + # hatch (webFrontend.host non-loopback). Honored regardless of + # `webFrontend.enable` so users who serve the SPA through their + # own reverse proxy (or expose the daemon's `/api` directly) can + # still widen the daemon's same-origin allowlist via this + # option. Comma-joined; parsed by configuredAllowedOrigins() in + # apps/daemon/src/origin-validation.ts. + OD_ALLOWED_ORIGINS = lib.concatStringsSep "," cfg.webFrontend.allowedOrigins; + } + // cfg.extraEnv; + + envToList = e: lib.mapAttrsToList (k: v: "${k}=${v}") e; +in { + options.services.open-design = commonOpts; + + config = lib.mkIf cfg.enable (lib.mkMerge [ + { + home.packages = [cfg.package]; + + # Ensure the data directory exists ahead of first daemon launch. + # mkdir -p so this is safe to re-run. + home.activation.openDesignDataDir = lib.hm.dag.entryAfter ["writeBoundary"] '' + run mkdir -p ${lib.escapeShellArg (toString cfg.dataDir)} + ''; + + # Fail-closed: if the operator widens the bundled caddy bind to + # a non-loopback interface but does not declare which external + # origins the SPA will be loaded from, the daemon's CSRF gate + # will silently 403 every PUT/POST. Catch that at eval time so + # the broken state never reaches the user's session. + assertions = [ + { + assertion = + !cfg.webFrontend.enable + || isLoopbackHost cfg.webFrontend.host + || cfg.webFrontend.allowedOrigins != []; + message = '' + services.open-design.webFrontend.host = "${cfg.webFrontend.host}" exposes the + bundled web frontend on a non-loopback interface, but + services.open-design.webFrontend.allowedOrigins is empty. + + The daemon's same-origin allowlist would reject every API + write the SPA issues from that host. Either keep the + default loopback bind, or declare every external origin + the SPA will be loaded from, e.g. + + services.open-design.webFrontend.allowedOrigins = [ + "http://laptop.local:''${toString cfg.webFrontend.port}" + ]; + ''; + } + ]; + } + + # ----- Linux: systemd --user units -------------------------------- + (lib.mkIf (pkgs.stdenv.isLinux && cfg.autoStart) { + systemd.user.services.open-design = { + Unit = { + Description = "Open Design daemon (user service)"; + After = ["network-online.target"]; + Wants = ["network-online.target"]; + }; + Install.WantedBy = ["default.target"]; + Service = + { + Type = "simple"; + ExecStart = "${daemonExe} --port ${toString cfg.port} --no-open"; + Environment = envToList daemonEnv; + Restart = "on-failure"; + RestartSec = 3; + } + // lib.optionalAttrs (cfg.environmentFile != null) { + EnvironmentFile = toString cfg.environmentFile; + }; + }; + }) + + (lib.mkIf (pkgs.stdenv.isLinux && cfg.webFrontend.enable) { + systemd.user.services.open-design-web = { + Unit = { + Description = "Open Design web frontend (static file server)"; + After = ["network-online.target"]; + Wants = ["network-online.target"]; + }; + Install.WantedBy = ["default.target"]; + Service = { + Type = "simple"; + ExecStart = "${lib.getExe caddy} run --config ${caddyfile} --adapter caddyfile"; + Restart = "on-failure"; + RestartSec = 3; + }; + }; + }) + + # ----- macOS: launchd agents ------------------------------------- + (lib.mkIf (pkgs.stdenv.isDarwin && cfg.autoStart) (let + # launchd has no EnvironmentFile equivalent. When the user supplies + # one, wrap the daemon exec in a tiny shell that sources the file + # at runtime — keeps secrets out of the world-readable Nix store + # while still honoring the documented `environmentFile` option on + # Darwin (parity with the Linux systemd path above). + daemonLaunchScript = pkgs.writeShellScript "open-design-daemon-launch" '' + set -a + . ${lib.escapeShellArg (toString cfg.environmentFile)} + set +a + exec ${lib.escapeShellArg daemonExe} --port ${toString cfg.port} --no-open + ''; + programArguments = + if cfg.environmentFile != null + then [(toString daemonLaunchScript)] + else [daemonExe "--port" (toString cfg.port) "--no-open"]; + in { + launchd.agents.open-design = { + enable = true; + config = { + Label = "io.nexu.open-design"; + ProgramArguments = programArguments; + RunAtLoad = true; + KeepAlive = true; + EnvironmentVariables = daemonEnv; + StandardOutPath = "${cfg.dataDir}/open-design.out.log"; + StandardErrorPath = "${cfg.dataDir}/open-design.err.log"; + }; + }; + })) + + (lib.mkIf (pkgs.stdenv.isDarwin && cfg.webFrontend.enable) { + launchd.agents.open-design-web = { + enable = true; + config = { + Label = "io.nexu.open-design-web"; + ProgramArguments = [ + (lib.getExe caddy) + "run" + "--config" + (toString caddyfile) + "--adapter" + "caddyfile" + ]; + RunAtLoad = true; + KeepAlive = true; + }; + }; + }) + ]); +} diff --git a/nix/module-common.nix b/nix/module-common.nix new file mode 100644 index 0000000000..18ea18cbe0 --- /dev/null +++ b/nix/module-common.nix @@ -0,0 +1,244 @@ +# Shared option definitions for the Open Design Home Manager and NixOS +# modules. Returns a plain attrset of options (NOT a NixOS module). The +# consuming module imports this and merges the result into its own +# `options..open-design`. +# +# The two callers differ only in: +# - default `dataDir` (HM: $HOME/.od; NixOS: /var/lib/open-design) +# - service supervision (HM: systemd --user / launchd agents; +# NixOS: system systemd units + dynamic user) +# Everything else — port, autoStart, environmentFile, agents, webFrontend — +# is identical across both, so it lives here. +{ + lib, + pkgs, + flake, + defaultDataDir, +}: let + # `pkgs.system` is a legacy alias removed when consumers set + # `nixpkgs.config.allowAliases = false`. Use the canonical attribute + # path so the module evaluates under both default and strict configs. + systemAttr = pkgs.stdenv.hostPlatform.system; + + flakePackages = + if flake ? packages.${pkgs.stdenv.hostPlatform.system} + then flake.packages.${pkgs.stdenv.hostPlatform.system} + else {}; +in { + enable = lib.mkEnableOption "Open Design — local-first design product daemon"; + + package = lib.mkOption { + type = lib.types.package; + default = + flakePackages.daemon or (throw + "open-design: no daemon package available for ${pkgs.stdenv.hostPlatform.system}; set services.open-design.package explicitly"); + defaultText = lib.literalExpression "open-design.packages.\${pkgs.stdenv.hostPlatform.system}.daemon"; + description = "The Open Design daemon package providing the `od` binary."; + }; + + port = lib.mkOption { + type = lib.types.port; + default = 7457; + description = '' + TCP port the daemon API binds to. Passed to `od --port`. + + The static SPA issues relative `/api/*`, `/artifacts/*`, and + `/frames/*` requests, so whatever fronts the bundle (the + built-in `webFrontend` caddy or your own nginx/Caddy) must + reverse-proxy those three path prefixes to `127.0.0.1:` and serve same-origin with the SPA. There is no runtime + `OD_DAEMON_URL` injection — see section (4) of `nix/README.md`. + ''; + }; + + dataDir = lib.mkOption { + type = lib.types.path; + default = defaultDataDir; + defaultText = + lib.literalExpression + ( + if defaultDataDir == "/var/lib/open-design" + then "\"/var/lib/open-design\"" + else "\"\${config.home.homeDirectory}/.od\"" + ); + description = '' + Directory holding the daemon's runtime state: SQLite database + (`app.sqlite`), per-project working trees under `projects//`, + and saved artifact bundles under `artifacts/`. + ''; + }; + + autoStart = lib.mkOption { + type = lib.types.bool; + default = false; + description = '' + Whether to register a service that starts the daemon automatically. + Independent of `webFrontend.enable` — you can run either or both. + ''; + }; + + environmentFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + description = '' + Path to a file containing `KEY=VALUE` lines passed to the daemon's + service environment. Use this for runtime secrets (BYOK API keys, + provider tokens, etc.). + + WARNING: never put secret values directly into Nix configuration — + the Nix store is world-readable. Generate this file out-of-band + with sops-nix (https://github.com/Mic92/sops-nix) or agenix + (https://github.com/ryantm/agenix). + ''; + example = "/run/secrets/open-design.env"; + }; + + extraEnv = lib.mkOption { + type = lib.types.attrsOf lib.types.str; + default = {}; + description = '' + Additional non-secret environment variables for the daemon + service (e.g. `OD_CODEX_DISABLE_PLUGINS = "1"`). Secrets belong + in `environmentFile`, not here. + ''; + example = lib.literalExpression '' + { + OD_CODEX_DISABLE_PLUGINS = "1"; + } + ''; + }; + + extraBinPaths = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = []; + description = '' + Extra absolute directories to prepend to the daemon service's + PATH. The daemon discovers agent CLIs (claude, codex, gemini, + opencode, ...) by scanning `process.env.PATH` at runtime, but + systemd unit and macOS launchd agent invocations launch with a + minimal default PATH that excludes Home Manager / NixOS user + profiles — so without entries here the daemon will report + "no agents detected" even when the CLIs are installed. + + Both modules pre-populate PATH with sensible defaults for their + context (Home Manager: the user's HM profile and, on NixOS, the + per-user/system profile dirs; NixOS: the system profile and + wrapper dirs). Use this option to add additional locations + (custom installs under `/opt`, project-local `bin/`, etc.). + ''; + example = lib.literalExpression ''[ "/opt/agents/bin" ]''; + }; + + webFrontend = { + # The Open Design web frontend is a static SPA built by + # `apps/web` → `apps/web/out/`. The daemon is a separate Express + # process that serves the JSON API at `/api/*`. The SPA is built + # with `OD_DAEMON_URL=""`, so the bundled JS issues relative + # `/api/*`, `/artifacts/*`, and `/frames/*` requests and expects + # the static-server in front of it to reverse-proxy those to the + # daemon — there is no runtime daemon-URL injection. + # + # Enabling `webFrontend` runs a tiny static file server (caddy) that + # hosts the SPA on a sibling port and proxies the three path + # prefixes back to the daemon. This is for users who just want + # `nix run`-style convenience without configuring nginx/caddy by + # hand. + # + # If you already serve the static export through your own reverse + # proxy, leave `webFrontend.enable = false`, point your server's + # document root at `${cfg.webFrontend.package}`, and replicate the + # reverse-proxy rules (with SSE-safe streaming on `/api/*`). + enable = lib.mkOption { + type = lib.types.bool; + default = false; + description = '' + Run a lightweight static file server for the Next.js export. + Independent of the daemon service: enable either or both. + ''; + }; + + port = lib.mkOption { + type = lib.types.port; + # Confirmed via QUICKSTART.md examples (`--web-port 5175`) and + # tools-dev defaults — pick 5174 to leave 5175 free for the dev + # tools-dev workflow on the same machine. + default = 5174; + description = '' + TCP port the static file server binds to. The bundled caddy + serves the SPA on this port and reverse-proxies the SPA's + relative API requests (`/api/*`, `/artifacts/*`, `/frames/*`) + to the daemon at `127.0.0.1:''${toString cfg.port}`, so the + browser sees a single same-origin endpoint. + ''; + }; + + host = lib.mkOption { + type = lib.types.str; + default = "127.0.0.1"; + description = '' + Interface address the static file server binds to. Defaults to + loopback so enabling `webFrontend` does not expose the bundled + proxy (which forwards `/api/*`, `/artifacts/*`, and `/frames/*` + to the local daemon) to other hosts on the network. + + For shared deployments set this to `"0.0.0.0"` (or a specific + interface address) AND populate `webFrontend.allowedOrigins` + with every external origin the SPA will be loaded from — the + daemon's same-origin gate is fail-closed and would otherwise + reject API writes proxied by caddy with a 403. On NixOS you + must additionally set `services.open-design.openFirewall = + true` for inbound traffic to reach the listener. + + Note: certain sensitive routes (connector credentials, live + artifact preview/refresh) remain strictly loopback-only even + when `allowedOrigins` is set; that is intentional. + ''; + }; + + allowedOrigins = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = []; + description = '' + Full HTTP(S) origins (`scheme://host[:port]`, no path) the + daemon should accept as same-site for `/api/*` requests in + addition to its built-in loopback set. Honored regardless of + `webFrontend.enable` — set this whenever the browser-facing + origin differs from the daemon's bind address, including the + following cases: + + * Bundled caddy on a non-loopback host: SPA loaded from + `http://:''${toString cfg.webFrontend.port}` cannot + issue PUT/POST through the bundled proxy without this set. + * Custom nginx/Caddy out front (`webFrontend.enable = false`) + on a non-loopback host or any port other than + `''${toString cfg.port}` — including loopback split-port + setups like `http://127.0.0.1:8080`. + + For the loopback split-port case specifically, an alternative is + to set `extraEnv.OD_WEB_PORT = ""`, which whitelists + that port on every loopback host without enumerating origins. + + Each entry is forwarded to the daemon via the `OD_ALLOWED_ORIGINS` + env var. The daemon both compares it verbatim against the + browser's `Origin` header AND admits its host:port to the + `Host`-header allowlist (Caddy v2 reverse_proxy preserves the + original Host upstream by default, so widening only the Origin + check would still 403 same-site GETs at the Host check). List + every hostname/scheme combo a user might access (LAN IP, mDNS + name, Tailscale name, http and https separately if both are + reachable). Loopback origins on the daemon's own port are + already accepted unconditionally — do not bother listing those. + ''; + example = ["http://laptop.local:5174" "https://laptop.local:5174"]; + }; + + package = lib.mkOption { + type = lib.types.package; + default = + flakePackages.web or (throw + "open-design: no web package available for ${pkgs.stdenv.hostPlatform.system}; set services.open-design.webFrontend.package explicitly"); + defaultText = lib.literalExpression "open-design.packages.\${pkgs.stdenv.hostPlatform.system}.web"; + description = "Built static export to serve (Next.js out/ tree)."; + }; + }; +} diff --git a/nix/nixos.nix b/nix/nixos.nix new file mode 100644 index 0000000000..d8a21e62db --- /dev/null +++ b/nix/nixos.nix @@ -0,0 +1,267 @@ +# NixOS module for Open Design — secondary interface for shared/server +# installs (e.g. running the daemon as a long-lived service on a team +# build host). For individual developer machines, prefer the Home +# Manager module (nix/home-manager.nix). +# +# Usage: +# imports = [ inputs.open-design.nixosModules.default ]; +# services.open-design = { +# enable = true; +# autoStart = true; +# openFirewall = true; +# webFrontend.enable = true; +# }; +{ + moduleCommon, + flake, +}: { + config, + lib, + pkgs, + ... +}: let + cfg = config.services.open-design; + + commonOpts = moduleCommon { + inherit lib pkgs flake; + defaultDataDir = "/var/lib/open-design"; + }; + + daemonExe = lib.getExe cfg.package; + caddy = pkgs.caddy; + + # See nix/home-manager.nix for the rationale behind these handle + # blocks. The static SPA calls `/api/*`, `/artifacts/*`, `/frames/*` + # at the same origin; caddy proxies those to the daemon. SSE on + # `/api/*` requires no buffering (flush_interval, no encode). + caddyfile = pkgs.writeText "open-design-web.Caddyfile" '' + { + auto_https off + admin off + persist_config off + } + + ${cfg.webFrontend.host}:${toString cfg.webFrontend.port} { + handle /api/* { + reverse_proxy 127.0.0.1:${toString cfg.port} { + flush_interval -1 + transport http { + read_timeout 86400s + write_timeout 86400s + } + } + } + handle /artifacts/* { + reverse_proxy 127.0.0.1:${toString cfg.port} + } + handle /frames/* { + reverse_proxy 127.0.0.1:${toString cfg.port} + } + handle { + root * ${cfg.webFrontend.package} + try_files {path} {path}/ /index.html + file_server + encode gzip + } + } + ''; + + hardening = { + NoNewPrivileges = true; + ProtectSystem = "strict"; + ProtectHome = true; + PrivateTmp = true; + ProtectKernelTunables = true; + ProtectKernelModules = true; + ProtectControlGroups = true; + RestrictSUIDSGID = true; + LockPersonality = true; + }; + + # System systemd units launch with a minimal default PATH that excludes + # NixOS profile dirs where agent CLIs live. The daemon scans + # `process.env.PATH` to discover them, so an explicit PATH is required + # or the UI reports "no agents detected". The service runs as a system + # user, so per-user profile dirs aren't included by default — operators + # who install agents into a specific location should add it via + # `services.open-design.extraBinPaths`. + daemonPathEntries = + [ + "/run/wrappers/bin" + "/run/current-system/sw/bin" + "/nix/var/nix/profiles/default/bin" + "/usr/local/bin" + "/usr/bin" + "/bin" + ] + ++ cfg.extraBinPaths; + + # Conservative loopback check used to gate the allowedOrigins + # assertion. See nix/home-manager.nix for the rationale. + isLoopbackHost = h: + h == "127.0.0.1" + || h == "localhost" + || h == "::1" + || h == "[::1]" + || lib.hasPrefix "127." h; + + daemonEnvironment = + { + OD_PORT = toString cfg.port; + OD_DATA_DIR = toString cfg.dataDir; + PATH = lib.concatStringsSep ":" daemonPathEntries; + } + // lib.optionalAttrs cfg.webFrontend.enable { + # See nix/home-manager.nix — the daemon's /api origin allowlist + # needs to know about the caddy port or it will 403 SPA writes. + OD_WEB_PORT = toString cfg.webFrontend.port; + } + // lib.optionalAttrs (cfg.webFrontend.allowedOrigins != []) { + # Operator-declared external origins for the LAN-exposure escape + # hatch. Honored regardless of `webFrontend.enable` so operators + # exposing the daemon's `/api` directly (no bundled caddy in + # front) — the path documented under `openFirewall` — can still + # widen the daemon's same-origin allowlist via this option. + # Comma-joined; parsed by configuredAllowedOrigins() in + # apps/daemon/src/origin-validation.ts. + OD_ALLOWED_ORIGINS = lib.concatStringsSep "," cfg.webFrontend.allowedOrigins; + } + // cfg.extraEnv; +in { + options.services.open-design = + commonOpts + // { + user = lib.mkOption { + type = lib.types.str; + default = "open-design"; + description = "User the daemon runs as."; + }; + + group = lib.mkOption { + type = lib.types.str; + default = "open-design"; + description = "Group the daemon runs as."; + }; + + openFirewall = lib.mkOption { + type = lib.types.bool; + default = false; + description = '' + Open the daemon `port` in the system firewall, plus + `webFrontend.port` when the bundled web service is enabled. + + Note: by default both the daemon and the bundled web frontend + bind to loopback only, so opening the firewall has no effect + until you also widen the bind address — set + `services.open-design.webFrontend.host = "0.0.0.0"` and + declare `services.open-design.webFrontend.allowedOrigins` so + the daemon's CSRF gate accepts the externally reachable + origin the SPA is loaded from. + + If you also need the daemon's `/api` exposed directly (i.e. + without the bundled caddy in front), set + `extraEnv.OD_BIND_HOST` to the externally reachable address + (e.g. a LAN IP or Tailscale host) — not `0.0.0.0`, since the + daemon's `Origin` allowlist is built from the literal bind + host and browsers send `Origin: http://:`, + not `http://0.0.0.0:`. Alternatively keep + `OD_BIND_HOST = "0.0.0.0"` and add the externally reachable + origin (e.g. `http://laptop.local:7456`) to + `webFrontend.allowedOrigins`, which feeds `OD_ALLOWED_ORIGINS`. + ''; + }; + }; + + config = lib.mkIf cfg.enable (lib.mkMerge [ + { + users.users.${cfg.user} = { + isSystemUser = true; + group = cfg.group; + home = cfg.dataDir; + description = "Open Design daemon"; + }; + users.groups.${cfg.group} = {}; + + systemd.tmpfiles.rules = [ + "d ${toString cfg.dataDir} 0750 ${cfg.user} ${cfg.group} - -" + ]; + + networking.firewall.allowedTCPPorts = + lib.optional cfg.openFirewall cfg.port + ++ lib.optional (cfg.openFirewall && cfg.webFrontend.enable) cfg.webFrontend.port; + + # Fail-closed: if the operator widens the bundled caddy bind to + # a non-loopback interface but does not declare which external + # origins the SPA will be loaded from, the daemon's CSRF gate + # will silently 403 every PUT/POST. Catch that at eval time. + assertions = [ + { + assertion = + !cfg.webFrontend.enable + || isLoopbackHost cfg.webFrontend.host + || cfg.webFrontend.allowedOrigins != []; + message = '' + services.open-design.webFrontend.host = "${cfg.webFrontend.host}" exposes the + bundled web frontend on a non-loopback interface, but + services.open-design.webFrontend.allowedOrigins is empty. + + The daemon's same-origin allowlist would reject every API + write the SPA issues from that host. Either keep the + default loopback bind, or declare every external origin + the SPA will be loaded from, e.g. + + services.open-design.webFrontend.allowedOrigins = [ + "http://laptop.local:''${toString cfg.webFrontend.port}" + ]; + ''; + } + ]; + } + + (lib.mkIf cfg.autoStart { + systemd.services.open-design = { + description = "Open Design daemon"; + wantedBy = ["multi-user.target"]; + after = ["network-online.target"]; + wants = ["network-online.target"]; + + environment = daemonEnvironment; + + serviceConfig = + { + Type = "simple"; + User = cfg.user; + Group = cfg.group; + ExecStart = "${daemonExe} --port ${toString cfg.port} --no-open"; + Restart = "on-failure"; + RestartSec = 3; + ReadWritePaths = [(toString cfg.dataDir)]; + } + // hardening + // lib.optionalAttrs (cfg.environmentFile != null) { + EnvironmentFile = toString cfg.environmentFile; + }; + }; + }) + + (lib.mkIf cfg.webFrontend.enable { + systemd.services.open-design-web = { + description = "Open Design web frontend (static file server)"; + wantedBy = ["multi-user.target"]; + after = ["network-online.target"]; + wants = ["network-online.target"]; + + serviceConfig = + { + Type = "simple"; + User = cfg.user; + Group = cfg.group; + ExecStart = "${lib.getExe caddy} run --config ${caddyfile} --adapter caddyfile"; + Restart = "on-failure"; + RestartSec = 3; + } + // hardening; + }; + }) + ]); +} diff --git a/nix/package-daemon.nix b/nix/package-daemon.nix new file mode 100644 index 0000000000..df98c707fa --- /dev/null +++ b/nix/package-daemon.nix @@ -0,0 +1,178 @@ +{ + lib, + stdenv, + dream2nix, + nixpkgs, + system, + nodejs, + pnpm_10, + fetchPnpmDeps, + pnpmConfigHook, + src, + makeWrapper, + python3, + gnumake, + pkg-config, +}: +# Builds the @open-design/daemon workspace package — produces $out/bin/od. +# +# Implementation note on dream2nix: +# The flake takes `dream2nix` as an input (per the project's Nix +# contract) but the build itself uses stdenv.mkDerivation. dream2nix's +# nodejs builders consume npm's package-lock.json — they have no +# first-class pnpm-lock.yaml + workspace builder yet. When upstream +# ships one, swap this file for a thin dream2nix module — the inputs +# are already wired. +# +# pnpm version note: +# `package.json` declares `engines.pnpm: ">=10.33.2 <11"` and pnpm +# enforces this on `pnpm install` (regardless of `engine-strict`). +# nixpkgs currently ships 10.33.0, which is rejected. The flake +# overrides `pkgs.pnpm_10` to fetch the 10.33.2 tarball from npm — +# see flake.nix for the override and how to bump the hash when +# `packageManager` advances. +# +# Workspace siblings the daemon depends on (contracts, sidecar-proto, +# sidecar, platform) are built in dependency order before the daemon +# itself; tsc emits each package's dist/, which is what the daemon +# resolves at runtime via pnpm's symlinked node_modules. +let + pname = "open-design-daemon"; + version = (lib.importJSON ../package.json).version; + + # Vendored pnpm store. The hash MUST be pinned on first build: + # `nix build .#daemon` will fail with the expected hash printed; copy + # that into `pnpmDepsHash` below. Bump it whenever pnpm-lock.yaml + # changes. + pnpmDepsHash = "sha256-KF3Mld72/iau+pJmA7HvnanRx8VLtDP0N624SKrtrrc="; + # pnpmDepsHash = lib.fakeHash; +in + stdenv.mkDerivation (finalAttrs: { + inherit pname version src; + + nativeBuildInputs = [ + nodejs + pnpm_10 + pnpmConfigHook + makeWrapper + # Required to rebuild better-sqlite3's native binding from source. + # node-gyp drives this via Python; gnumake/pkg-config + the C++ + # compiler from stdenv complete the toolchain. + python3 + gnumake + pkg-config + ]; + + pnpmDeps = fetchPnpmDeps { + inherit (finalAttrs) pname version src; + hash = pnpmDepsHash; + fetcherVersion = 3; + }; + + env.NODE_ENV = "production"; + + # pnpm_10.configHook runs in postConfigureHooks: it unpacks + # `pnpmDeps`, points pnpm at the unpacked store, and runs + # `pnpm install --offline --ignore-scripts --frozen-lockfile`. + # No custom configurePhase needed. + + buildPhase = '' + runHook preBuild + + # Build better-sqlite3's native binding from source. + # + # Why from source on Node 24: + # better-sqlite3 (even 12.9.0, latest as of 2026-05) only + # publishes prebuilds up to node-v131 (Node 22). No v137 + # (Node 24) prebuild exists. `prebuild-install` would itself + # fail the GitHub fetch and fall through to a compile, so we + # skip the download attempt entirely and compile. + # + # Why not `pnpm rebuild`: + # In pnpm 10, `onlyBuiltDependencies` interacts with the + # "approve-builds" consent gate; `pnpm rebuild ` silently + # no-ops in some configurations. Invoke node-gyp directly to + # sidestep all of that. + # + # Env vars: + # * npm_config_nodedir → use the headers shipped with the + # nixpkgs nodejs we're already building against, so node-gyp + # doesn't try to fetch them from nodejs.org/dist (no network + # in the build sandbox). + # * npm_config_build_from_source → tell better-sqlite3's + # prebuild-install fallback chain to skip the CDN download + # and compile. + # + # node-gyp lookup: + # nixpkgs nodejs ships node-gyp bundled inside npm at + # ${nodejs}/lib/node_modules/npm/bin/node-gyp-bin. Putting + # that on PATH gives us a `node-gyp` shim without depending + # on pnpm-exec resolving from inside better-sqlite3's tree + # (better-sqlite3 doesn't list node-gyp as a direct dep). + export npm_config_nodedir=${nodejs} + export npm_config_build_from_source=true + export PATH="${nodejs}/lib/node_modules/npm/bin/node-gyp-bin:$PATH" + + bsq_dir=$(find node_modules/.pnpm -mindepth 2 -maxdepth 4 \ + -type d -path '*/better-sqlite3@*/node_modules/better-sqlite3' \ + -print -quit) + if [ -z "$bsq_dir" ]; then + echo "ERROR: better-sqlite3 not found under node_modules/.pnpm — pnpm install may have failed" >&2 + exit 1 + fi + + echo "Building better-sqlite3 from source at $bsq_dir" + ( cd "$bsq_dir" && node-gyp rebuild --release --build-from-source ) + + # Fail fast if the .node file didn't land where bindings.js + # looks for it. Without this assertion, a silent skip produces + # a "valid" derivation that crashes at runtime with + # "Could not locate the bindings file". + if [ ! -f "$bsq_dir/build/Release/better_sqlite3.node" ]; then + echo "ERROR: better_sqlite3.node was not produced at $bsq_dir/build/Release/" >&2 + find "$bsq_dir" -name '*.node' -print >&2 || true + exit 1 + fi + + for target in \ + packages/contracts \ + packages/sidecar-proto \ + packages/sidecar \ + packages/platform \ + apps/daemon + do + pnpm -C "$target" run --if-present build + done + runHook postBuild + ''; + + installPhase = '' + runHook preInstall + mkdir -p $out/lib/open-design $out/bin + + # Copy the whole workspace tree — pnpm's symlinks under node_modules + # resolve sibling packages by relative paths, so we cannot prune to + # just apps/daemon. + cp -r . $out/lib/open-design/ + + chmod +x $out/lib/open-design/apps/daemon/dist/cli.js + + makeWrapper ${nodejs}/bin/node $out/bin/od \ + --add-flags $out/lib/open-design/apps/daemon/dist/cli.js \ + --set NODE_ENV production + runHook postInstall + ''; + + passthru = { + inherit nodejs; + pnpmDeps = finalAttrs.pnpmDeps; + }; + + meta = with lib; { + description = "Open Design daemon — local agent orchestrator + API (`od` CLI)"; + homepage = "https://github.com/nexu-io/open-design"; + license = licenses.asl20; + mainProgram = "od"; + platforms = platforms.linux ++ platforms.darwin; + }; + }) diff --git a/nix/package-web.nix b/nix/package-web.nix new file mode 100644 index 0000000000..4e8e1f33dd --- /dev/null +++ b/nix/package-web.nix @@ -0,0 +1,91 @@ +{ + lib, + stdenv, + dream2nix, + nixpkgs, + system, + nodejs, + pnpm_10, + fetchPnpmDeps, + pnpmConfigHook, + src, +}: +# Builds the @open-design/web Next.js static export. +# +# Output layout: $out/ contains the contents of `apps/web/out/` (an +# index.html plus _next/ and asset subdirectories). Drop $out into any +# static file server. +# +# OD_DAEMON_URL is set to "" at build time so the bundled JS issues +# relative requests (`/api/*`, `/artifacts/*`, `/frames/*`) instead of +# baking a build-time daemon URL into the export. The serving +# environment is therefore expected to be same-origin with the daemon — +# the bundled caddy in the modules reverse-proxies those paths to +# `127.0.0.1:`, and a custom nginx/caddy must do the same. +let + pname = "open-design-web"; + version = (lib.importJSON ../package.json).version; + + # Vendored pnpm store. The hash MUST be pinned on first build: + # `nix build .#web` will fail with the expected hash printed; copy + # that into `pnpmDepsHash` below. Bump it whenever pnpm-lock.yaml + # changes. + pnpmDepsHash = "sha256-KF3Mld72/iau+pJmA7HvnanRx8VLtDP0N624SKrtrrc="; + # pnpmDepsHash = lib.fakeHash; +in + stdenv.mkDerivation (finalAttrs: { + inherit pname version src; + + nativeBuildInputs = [ + nodejs + pnpm_10 + pnpmConfigHook + ]; + + pnpmDeps = fetchPnpmDeps { + inherit (finalAttrs) pname version src; + hash = pnpmDepsHash; + fetcherVersion = 3; + }; + + env = { + NODE_ENV = "production"; + OD_DAEMON_URL = ""; + }; + + buildPhase = '' + runHook preBuild + for target in \ + packages/contracts \ + packages/sidecar-proto \ + packages/sidecar \ + packages/platform + do + pnpm -C "$target" run --if-present build + done + + # next.config.ts gates static-export emission on NODE_ENV=production + # and writes to apps/web/out/. + pnpm --filter @open-design/web run build + runHook postBuild + ''; + + installPhase = '' + runHook preInstall + mkdir -p $out + cp -r apps/web/out/. $out/ + runHook postInstall + ''; + + passthru = { + inherit nodejs; + pnpmDeps = finalAttrs.pnpmDeps; + }; + + meta = with lib; { + description = "Open Design — Next.js static SPA (apps/web)"; + homepage = "https://github.com/nexu-io/open-design"; + license = licenses.asl20; + platforms = platforms.linux ++ platforms.darwin; + }; + })