diff --git a/.devcell.toml b/.devcell.toml new file mode 100644 index 00000000..d6b097da --- /dev/null +++ b/.devcell.toml @@ -0,0 +1,21 @@ +# .devcell.toml — project-level DevCell overrides (merged on top of ~/.config/devcell/devcell.toml) + +# [cell] +# image_tag = "latest-go" +# gui = true +# timezone = "Europe/Prague" + +[op] +# 1Password items to resolve and inject as env vars. +# Requires `op` CLI on the host. +items = [ + "prod-nmd-trips", + "prod-nmd-finances" +] + +# [env] +# Extra environment variables forwarded into the container. +# MY_VAR = "value" + +# [[volumes]] +# mount = "~/work/secrets:/run/secrets:ro" diff --git a/.env-example b/.env-example deleted file mode 100644 index e33d2697..00000000 --- a/.env-example +++ /dev/null @@ -1,12 +0,0 @@ -# Git identity — prefer [git] section in ~/.config/devcell/devcell.toml instead. -# These env vars still work and take highest priority. -GIT_AUTHOR_NAME="Your Name" -GIT_AUTHOR_EMAIL="your@email.com" -GIT_COMMITTER_NAME="Your Name" -GIT_COMMITTER_EMAIL="your@email.com" -OPENAI_API_KEY=xxxxx -GEMINI_API_KEY=xxxxx - -DEVCELL_GUI_ENABLED=false -DEVCELL_KICAD_ENABLED=false -DEVCELL_NIX_ENABLED=false diff --git a/.github/workflows/build.dev.yml b/.github/workflows/build.dev.yml index dfaefabb..a9cd038a 100644 --- a/.github/workflows/build.dev.yml +++ b/.github/workflows/build.dev.yml @@ -219,6 +219,102 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: gh release edit "${{ env.RELEASE_VERSION }}" --draft=false --latest=false + e2e-install: + name: E2E Install (${{ matrix.arch }}) + needs: [cell-build, docker-manifest] + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-latest + arch: amd64 + - runner: ubuntu-24.04-arm + arch: arm64 + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + steps: + - name: Download cell binary + run: | + TARBALL="cell-linux-${{ matrix.arch }}.tar.gz" + URL="https://github.com/DimmKirr/devcell/releases/download/v0.0.0/${TARBALL}" + echo "Downloading: ${URL}" + curl -fsSL -o "${TARBALL}" "${URL}" + tar xzf "${TARBALL}" + chmod +x cell + sudo mv cell /usr/local/bin/cell + + - name: Verify cell binary + run: | + cell --help + echo "--- cell binary OK ---" + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Run cell claude --version (full pipeline) + run: | + # Simulate a new user in a fresh project dir + mkdir -p /tmp/e2e-project && cd /tmp/e2e-project + + # --plain-text: disable spinners for CI + # Flow: scaffold devcell.toml → pull base image → build user image → start container → print claude version + OUTPUT=$(cell --plain-text claude --version 2>&1) || true + echo "$OUTPUT" + + # Assert cell version string is present + if echo "$OUTPUT" | grep -qE "cell\s+v?[0-9]+\.[0-9]+\.[0-9]+"; then + echo "--- PASS: cell version found ---" + else + echo "--- FAIL: cell version string not found in output ---" + exit 1 + fi + + # Assert the image was built (user image should exist now) + if echo "$OUTPUT" | grep -qiE "(building|built|image)"; then + echo "--- PASS: image build occurred ---" + else + echo "--- WARN: no build output detected (image may have been cached) ---" + fi + + brew-install: + name: Brew Install (${{ matrix.arch }}) + needs: [cell-build] + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-latest + arch: amd64 + - runner: ubuntu-24.04-arm + arch: arm64 + runs-on: ${{ matrix.runner }} + timeout-minutes: 15 + steps: + - name: Install Homebrew + run: | + /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" < /dev/null + echo "/home/linuxbrew/.linuxbrew/bin" >> $GITHUB_PATH + + - name: Brew install devcell + run: | + brew tap DimmKirr/tap + brew install devcell + + - name: Verify version + run: | + INSTALLED=$(cell --version) + echo "Installed: ${INSTALLED}" + if echo "${INSTALLED}" | grep -qE "^cell version [0-9]+\.[0-9]+\.[0-9]+"; then + echo "--- PASS: cell version OK ---" + else + echo "--- FAIL: unexpected version output ---" + exit 1 + fi + deploy-site: name: Deploy Site needs: secrets diff --git a/.github/workflows/build.release.yml b/.github/workflows/build.release.yml index 3ce8964b..c75af64c 100644 --- a/.github/workflows/build.release.yml +++ b/.github/workflows/build.release.yml @@ -89,7 +89,7 @@ jobs: run: | R=${{ env.REGISTRY }}/${{ env.IMAGE_NAME_LC }} V=${{ env.VERSION }} - for suffix in "" "-base"; do + for suffix in "" "-base" "-ultimate"; do docker buildx imagetools create \ -t "${R}:${V}${suffix}" \ "${R}:${V}-amd64${suffix}" \ @@ -100,7 +100,7 @@ jobs: run: | R=${{ env.REGISTRY }}/${{ env.IMAGE_NAME_LC }} V=${{ env.VERSION }} - for tag in "${V}" "${V}-base"; do + for tag in "${V}" "${V}-base" "${V}-ultimate"; do echo "=== ${tag} ===" docker buildx imagetools inspect "${R}:${tag}" done @@ -139,3 +139,59 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: gh release edit "${{ env.RELEASE_VERSION }}" --draft=false --latest=false + + e2e-install: + name: E2E Install (${{ matrix.arch }}) + needs: [cell-release, docker-manifest] + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-latest + arch: amd64 + - runner: ubuntu-24.04-arm + arch: arm64 + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + steps: + - name: Set version + run: echo "VERSION=${GITHUB_REF_NAME}" >> $GITHUB_ENV + + - name: Download cell binary + run: | + TARBALL="cell-linux-${{ matrix.arch }}.tar.gz" + URL="https://github.com/DimmKirr/devcell/releases/download/${VERSION}/${TARBALL}" + echo "Downloading: ${URL}" + curl -fsSL -o "${TARBALL}" "${URL}" + tar xzf "${TARBALL}" + chmod +x cell + sudo mv cell /usr/local/bin/cell + + - name: Verify cell binary + run: | + cell --help + echo "--- cell binary OK ---" + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Run cell claude --version (full pipeline) + run: | + # Simulate a new user in a fresh project dir + mkdir -p /tmp/e2e-project && cd /tmp/e2e-project + + # Flow: scaffold devcell.toml → pull base image → build user image → start container → print claude version + OUTPUT=$(cell --plain-text claude --version 2>&1) || true + echo "$OUTPUT" + + # Assert cell version string is present + if echo "$OUTPUT" | grep -qE "cell\s+v?[0-9]+\.[0-9]+\.[0-9]+"; then + echo "--- PASS: cell version found ---" + else + echo "--- FAIL: cell version string not found in output ---" + exit 1 + fi diff --git a/.gitignore b/.gitignore index a0c83909..d24dc270 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,7 @@ .worktrees nixhome/user.nix .venv -.env +.env.devcell *.egg-info python-tools .vagrant @@ -17,3 +17,5 @@ web/.astro web/src/content/cell/ bin/ .playwright-mcp +test/testdata/**/nixhome +test/results diff --git a/README.md b/README.md index d0aada3d..8e388c89 100644 --- a/README.md +++ b/README.md @@ -1,208 +1,126 @@ # DevCell -Containerized environment for running AI coding agents (Claude Code, Codex, OpenCode) in isolation with a consistent, reproducible toolchain. +Your AI agent can `rm -rf /` and you're fine. -Agents run with full permissions inside the container — your host stays clean. +DevCell is a containerized sandbox for AI coding agents. Run Claude Code, Codex, or OpenCode with full auto-approve. Your SSH keys, other repos, and host credentials stay out of reach. ## Quickstart +**Prerequisites:** [Docker Desktop](https://www.docker.com/products/docker-desktop/) or Docker Engine. + ```bash -brew tap DimmKirr/tap -brew install devcell +brew install DimmKirr/tap/devcell +cd your-project cell claude ``` -On first run, `cell` scaffolds `~/.config/devcell/devcell.toml` and prompts to build the image (~5 min). - -## Commands - -``` -cell init Scaffold ~/.config/devcell/ and optionally build the image -cell build Build (or rebuild) the local devcell image -cell shell Open an interactive shell in a devcell container -cell claude [args...] Run Claude Code (--dangerously-skip-permissions) -cell codex [args...] Run Codex (--dangerously-bypass-approvals-and-sandbox) -cell codex resume Resume a Codex session -cell opencode [args...] Run OpenCode (--dangerously-bypass-approvals-and-sandbox) -cell opencode resume Resume an OpenCode session -cell vnc Open VNC connection to the running container -cell vnc --list List all running cell containers and their VNC ports -cell vnc --app Connect to a specific named container -cell chrome [args...] Open Chromium with a project-scoped profile -``` - -### Global flags - -``` ---build Rebuild image before running ---dry-run Print docker run argv and exit without running ---plain-text Disable spinners, plain log output (for CI/non-TTY) ---debug Plain-text mode + stream full build log to stdout -``` - -### Build flags - -``` -cell build --no-cache Full rebuild, disabling Docker layer cache -``` - -### Init flags - -``` -cell init -y Skip confirmation prompts, proceed with defaults -``` +On first run, `cell` scaffolds `~/.config/devcell/devcell.toml` and builds the image (~5 min). Works with `cell codex` and `cell opencode` too. + +## What you get + +- **Isolated sandbox** - agents edit freely inside your project; your host system is untouched +- **12+ MCP servers** - Yahoo Finance, Google Maps, Linear, KiCad, Inkscape, and more. Backing tools ship in the image alongside their servers +- **Claude Max/Pro support** - runs Claude Code directly, no API key or proxy needed +- **Stealth Chromium** - anti-fingerprint browser with Playwright, passes bot detection out of the box +- **Remote desktop** - VNC and RDP into the container to watch or interact with GUI apps +- **1Password secrets** - API keys resolved at runtime, never written to disk +- **7 image stacks** - from minimal (`base`) to everything-included (`ultimate`) +- **Local ollama models** - route Claude through local models, ranked by SWE-Bench scores + +## Stacks + +Seven stacks, published to `ghcr.io/dimmkirr/devcell`. Multi-arch: linux/amd64, linux/arm64. + +| Stack | What's inside | +|---|---| +| **base** | zsh + starship, git, tmux, ripgrep, jq, sqlite, gnupg, hurl, go-task, gitleaks, mise, nix | +| **go** | base + Go, Terraform, OpenTofu, Packer, Helm | +| **node** | base + Node.js 22, npm, stealth Chromium | +| **python** | base + Python 3.13, uv, stealth Chromium | +| **fullstack** | go + node + python | +| **electronics** | base + GUI desktop + KiCad, ngspice, ESPHome, wokwi-cli | +| **ultimate** | fullstack + GUI desktop, all MCP servers, Inkscape, KiCad *(default)* | + +## MCP servers + +Baked into the image and auto-merged into each agent's config at container startup. User-defined servers are preserved. Where applicable, the backing tools ship too: KiCad, Inkscape, and OpenTofu are installed alongside their MCP servers, so the agent can run `tofu plan`, analyze PCBs, or edit SVGs. New servers ship with image updates. + +| Server | Domain | Auth | +|---|---|---| +| OpenTofu | IaC provider/module docs | None | +| Yahoo Finance | Stock data, financials, options | None | +| EdgarTools | SEC filings: 10-K, 10-Q, 8-K, XBRL | None | +| FRED API | 800K+ US economic time series | Free key | +| Google Maps | Geocoding, routing, places, elevation, weather | API key | +| TripIt | Trip itinerary management | Credentials | +| Inoreader | RSS feeds, articles, search, tagging | OAuth 2.0 | +| KiCad | PCB analysis, netlist extraction, DRC, BOM | None | +| Inkscape | SVG vector graphics and DOM operations | None | +| Linear | Project and issue management | OAuth 2.1 | +| Notion | Database and page management | OAuth 2.1 | +| MCP-NixOS | Nix package search and docs | None | + +## Security + +- Project directory mounted at `/workspace`. Host filesystem is unreachable +- SSH keys, `.env` files outside the project, and host credentials are not mounted +- Session user runs without root privileges +- 1Password secrets injected at runtime, never persisted +- GPG isolation per container (prevents SQLite lock contention) +- Gitleaks pre-commit hook and CI secret scanning ## Configuration -Config lives at `~/.config/devcell/devcell.toml` (global) and optionally `/.devcell.toml` (project-level overrides). The project file is merged on top of the global one. - -```toml -[cell] -# Image profile to use (default: latest-ultimate) -# image_tag = "latest-go" -# Enable GUI (Xvfb + VNC + browser) -gui = true - -[env] -# Extra environment variables forwarded into the container -# ANTHROPIC_BASE_URL = "http://host.docker.internal:8080" -# MY_TOKEN = "abc123" - -# [[volumes]] -# Extra volume mounts appended to docker run -# mount = "~/work/secrets:/run/secrets:ro" - -[packages.npm] -# npm packages installed in the container — edit and run 'cell build' -"@anthropic-ai/claude-code" = "2.1.49" -"@openai/codex" = "^0.96.0" -"opencode-ai" = "^1.1.51" - -[packages.python] -# Python packages installed in the container — edit and run 'cell build' -"pre-commit" = "*" -``` - -## Image Profiles +Global config at `~/.config/devcell/devcell.toml`. Per-project overrides via `.devcell.toml` (create with `cell init .`). See `cell --help` and the [CLI docs](https://devcell.sh/docs/cell) for the full reference. -Images are published to `ghcr.io/dimmkirr/devcell:`. +## Customization -| Tag | Contents | -|-----|----------| -| `latest-nix` | Base tools + Nix | -| `latest-go` | + Go toolchain | -| `latest-node` | + Node.js toolchain | -| `latest-python` | + Python toolchain | -| `latest-fullstack` | Go + Node + Python + infra tools | -| `latest-electronics` | + KiCad, ngspice, OpenSCAD | -| `latest-ultimate` | Everything + GUI/VNC/browser *(default)* | +Start simple, go deeper when you need to. -Override the profile in `devcell.toml`: +**Runtime versions** - drop a `.tool-versions` or `mise.toml` in your project. Runtimes install automatically at startup. No rebuild needed. -```toml -[cell] -image_tag = "latest-go" -``` - -## Preinstalled Tools - -### AI Agents -- [Claude Code](https://claude.ai/claude-code) — Anthropic's CLI -- [Codex](https://github.com/openai/codex) — OpenAI's CLI -- [OpenCode](https://opencode.ai) — open-source coding agent - -### Browsers & Automation -- Firefox ESR, Chromium with ChromeDriver -- [Playwright](https://playwright.dev/) — browser automation and testing - -### Version Management -- **mise** — Node.js, Go, Terraform, OpenTofu -- **Nix** — package manager with flakes support - -### Infrastructure -- Terraform, OpenTofu, kubectl, helm, aws-cli - -### Go Development -- air, golangci-lint, goimports -- Hugo (extended) -- terraform-docs, terraform-mcp-server - -### Media & Presentations -- ffmpeg, ImageMagick -- [Slidev](https://sli.dev/) — Markdown presentations -- [yt-dlp](https://github.com/yt-dlp/yt-dlp) - -### Shell -- **zsh** with autosuggestions and syntax highlighting -- [Starship](https://starship.rs/) — cross-shell prompt (Rust, fast) - -### Electronics *(electronics / ultimate profiles)* -- [KiCad](https://www.kicad.org/) — PCB design -- ngspice — circuit simulation -- OpenSCAD — parametric CAD +**Add packages** - add npm or Python packages in `devcell.toml`, then `cell build`. -## Components +**Extend a stack** - edit `~/.config/devcell/flake.nix` to add nix packages. Run `cell build` to apply. -The image is built from composable Nix home-manager modules (`nixhome/modules/`), assembled into profiles (`nixhome/profiles/`). +**Fork nixhome** - fork the [nixhome](https://github.com/DimmKirr/devcell/tree/main/nixhome) repo, point your flake to your fork. Upstream updates still merge cleanly. -| Module | Description | -|--------|-------------| -| `shell.nix` | zsh + Starship prompt, autosuggestions, syntax highlighting | -| `base.nix` | Universal utilities (git, ripgrep, tmux, jq, fonts) | -| `build.nix` | Native build toolchain (clang, cmake, lld) | -| `go.nix` | Go runtime (mise) + tooling (golangci-lint, gopls) | -| `node.nix` | Node.js runtime (mise) + yarn | -| `python.nix` | Python3 + uv package manager | -| `infra.nix` | Terraform, OpenTofu, packer | -| `web.nix` | Chromium + Hugo + Playwright | -| `desktop/` | X11/VNC desktop (fluxbox, x11vnc, xrdp, xterm) | -| `electronics.nix` | KiCad, ngspice, wokwi-cli | -| `graphics.nix` | Inkscape + inkscape-mcp | -| `nixos.nix` | Nix dev tools (deadnix, statix, nix-tree) | +
+Development -Modules are composed into profiles that map 1:1 to image tags: +### Building images -``` -base → base.nix -go → base + build + go + apple + infra -node → base + node + web -fullstack → go + node + python + web -ultimate → fullstack + desktop + electronics + graphics + nixos +```bash +task image:build # Build base + ultimate (bake matrix) +task image:build:user-local # Layer user config on top +cell build # Rebuild from host +cell build --update # Update nix flake inputs + rebuild ``` -## VNC +### Testing -When `gui = true` in your config, the container runs Xvfb + VNC. Connect with any VNC client or use: +Tests use [testcontainers-go](https://testcontainers.com/guides/getting-started-with-testcontainers-for-go/) and require Docker. ```bash -cell vnc # auto-discovers port, opens on macOS -cell vnc --list # show all running containers and their VNC URLs +task test # Short mode - uses pre-built image +go test -v -timeout 600s ./test/... # Long mode - rebuilds image first ``` -Port discovery checks (in order): container label match → bind-mount match → `cell vnc --list` for manual selection. +| Variable | Purpose | +|---|---| +| `DEVCELL_TEST_IMAGE` | Use this image instead of rebuilding | +| `DEVCELL_TEST_BASE_IMAGE` | Override base image for tests | -## Testing +### Nix modules -Tests use [testcontainers-go](https://testcontainers.com/guides/getting-started-with-testcontainers-for-go/) and require Docker. +The image is built from composable Nix home-manager modules (`nixhome/modules/`), assembled into stacks (`nixhome/profiles/`). Validate after edits: ```bash -# Run against the published image (default: ghcr.io/dimmkirr/devcell:latest-ultimate) -task devcell:test - -# Run against a locally built image -DEVCELL_IMAGE=ghcr.io/dimmkirr/devcell:edge task devcell:test +task nix:validate # Syntax check + attribute resolution across all stacks ``` -Test files are organized by concern: `environment_test.go`, `toolchain_test.go`, `vnc_test.go`, `mcp_test.go`, `workflow_test.go`. +
-## Building the Image Locally - -```bash -# Build the ultimate target for the host platform -task devcell:image:build - -# Full rebuild without cache -cell build --no-cache -``` +## License -CI builds use `docker buildx bake` with `docker-bake.hcl` for multi-platform targets. +Apache 2.0 diff --git a/Taskfile.yml b/Taskfile.yml index 076cd93e..3416c100 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -4,64 +4,77 @@ version: '3' vars: GIT_COMMIT_HASH: sh: git rev-parse --short HEAD 2>/dev/null || echo "unknown" - CELL_LDFLAGS: -s -w -X github.com/DimmKirr/devcell/internal/version.GitCommit={{.GIT_COMMIT_HASH}} + BUILD_DATE: + sh: date -u +%Y%m%dT%H%M%SZ + CELL_LDFLAGS: -s -w -X github.com/DimmKirr/devcell/internal/version.GitCommit={{.GIT_COMMIT_HASH}} -X github.com/DimmKirr/devcell/internal/version.BuildDate={{.BUILD_DATE}} - PORT: '{{.PORT | default "9090"}}' - HTTP_HOST: '{{.HTTP_HOST | default "0.0.0.0"}}' - DOCKER_ARGS: '{{.DOCKER_ARGS }}' - - DOCKERFILE: "Dockerfile" - IMAGE_TAG: "latest-ultimate" - - - APP_NAME: '{{base .USER_WORKING_DIR}}-{{env "CELL_ID" | default (env "TMUX_PANE" | replace "%" "" | default "0")}}' - WORKSPACE: "{{.APP_NAME}}" - BINARY_NAME: "{{.APP_NAME}}" +env: + BUILDKIT_PROGRESS: plain - DEVCELL_DIR: '{{env "DEVCELL_DIR" | default (printf "%s/dev/dimmkirr/devcell" .HOME)}}' +tasks: - CELL_HOME: '{{.HOME}}/.devcell/{{ env "TMUX_SESSION_NAME" | default "sandbox"}}' - PORT_PREFIX: '{{env "SESSION_PORT_PREFIX" | default ""}}{{env "CELL_ID" | default (env "TMUX_PANE" | replace "%" "" | default "")}}' - BASE_DIR: "{{ default .USER_WORKING_DIR }}" + cell:build: + desc: Build cell CLI binary + dir: "{{.TASKFILE_DIR}}" + cmds: + - CGO_ENABLED=0 go build -ldflags "{{.CELL_LDFLAGS}}" -o ~/.local/bin/cell ./cmd/ -env: - APP_NAME: "{{.APP_NAME}}" - BASE_DIR: "{{.BASE_DIR}}" - USER_WORKING_DIR: "{{.USER_WORKING_DIR}}" - DOCKERFILE: "{{.DOCKERFILE}}" - IMAGE_TAG: "{{.IMAGE_TAG}}" + cell:install: + aliases: [install] + desc: Build and install cell binary to ~/.local/bin/cell + dir: "{{.TASKFILE_DIR}}" + cmds: + - task: cell:build + - mkdir -p ~/.local/bin + - echo "Installed to ~/.local/bin/cell" - PORT_PREFIX: "{{.PORT_PREFIX}}" - EXT_VNC_PORT: "{{.PORT_PREFIX}}50" + image:build:base: + desc: Build local base images into local Docker daemon (host platform only, no push) + aliases: [image:build, bake:local] + silent: true + cmds: + - USER_UID=$(id -u) USER_GID=$(id -g) GIT_COMMIT={{.GIT_COMMIT_HASH}} PLATFORMS="" docker buildx bake --file {{.TASKFILE_DIR}}/docker-bake.hcl --load local {{.CLI_ARGS}} - PORT: "{{.PORT}}" - BUILDKIT_PROGRESS: plain - CELL_HOME: "{{.CELL_HOME}}" + image:build:ultimate: + desc: Build ultimate base image locally (FROM base, adds nix ultimate stack + KiCad) + silent: true + cmds: + - USER_UID=$(id -u) USER_GID=$(id -g) GIT_COMMIT={{.GIT_COMMIT_HASH}} PLATFORMS="" docker buildx bake --file {{.TASKFILE_DIR}}/docker-bake.hcl --load local-ultimate {{.CLI_ARGS}} -### -# Include this via: -# includes: -# claude: -# taskfile: ~/dev/Taskfile.yml -# flatten: true + image:build:user-local:dev: + desc: "Build user image for dev/test (wraps: cell init --force --yes --local-nixhome)" + aliases: [image:dev] + dir: "{{.TASKFILE_DIR}}" + env: + XDG_CONFIG_HOME: "{{.TASKFILE_DIR}}/test/testdata/devcell-config-simple" + DEVCELL_BASE_IMAGE: "ghcr.io/dimmkirr/devcell:base-local" + DEVCELL_NIXHOME_PATH: "{{.TASKFILE_DIR}}/nixhome" + cmds: + - cell build --debug {{.CLI_ARGS}} -tasks: + image:build:user-local:force: + desc: "Build user image for dev/test (wraps: cell init --force --yes --local-nixhome)" + aliases: [image:dev] + dir: "{{.TASKFILE_DIR}}" + env: + XDG_CONFIG_HOME: "{{.TASKFILE_DIR}}/test/testdata/devcell-config-simple" + DEVCELL_BASE_IMAGE: "ghcr.io/dimmkirr/devcell:base-local" + cmds: + - cell init --force --yes --local-nixhome {{.TASKFILE_DIR}}/nixhome --debug {{.CLI_ARGS}} - chrome: - desc: Open Chromium with project-scoped profile (saves cookies/passwords to .chrome/) + image:push: + desc: Build ci group and push to registry (multi-arch) silent: true cmds: - - | - echo "Opening Chromium with profile: {{.CELL_HOME}}/.chrome" - open -na "Chromium" --args --user-data-dir="{{.CELL_HOME}}/.chrome" {{.CLI_ARGS}} + - GIT_COMMIT={{.GIT_COMMIT_HASH}} docker buildx bake --file {{.TASKFILE_DIR}}/docker-bake.hcl --push ci {{.CLI_ARGS}} nix:validate: - desc: Validate all nixhome profiles — syntax check then attr check (no build, no activation) + desc: Validate all nixhome stacks — syntax check then attr check (no build, no activation) dir: "{{.TASKFILE_DIR}}" cmds: - | set -e - NIX_BIN=/nix/var/nix/profiles/per-user/devcell/profile/bin + NIX_BIN=/opt/devcell/.local/state/nix/profiles/profile/bin FLAKE="{{.TASKFILE_DIR}}/nixhome" echo "=== 1/2 Syntax check (nix-instantiate --parse) ===" @@ -81,8 +94,8 @@ tasks: echo "=== 2/2 Attr check (nix eval, no build) ===" ARCH=$(uname -m) [ "$ARCH" = "aarch64" ] && SUFFIX="-aarch64" || SUFFIX="" - for profile in base go node python fullstack electronics ultimate; do - CFG="devcell-${profile}${SUFFIX}" + for stack in base go node python fullstack electronics ultimate; do + CFG="devcell-${stack}${SUFFIX}" printf " %-40s" "$CFG" COUNT=$(sudo "$NIX_BIN/nix" eval \ "${FLAKE}#homeConfigurations.${CFG}.config.home.packages" \ @@ -93,55 +106,34 @@ tasks: --apply 'builtins.length' --json 2>&1 | grep 'error:' >&2; exit 1; } done echo "" - echo "All profiles valid." + echo "All stacks valid." - build:image: - desc: Build local group into local Docker daemon (host platform only, no push) - aliases: [build:image, bake:local] + test: + desc: Run the full container integration test suite silent: true cmds: - - USER_UID=$(id -u) USER_GID=$(id -g) PLATFORMS="" docker buildx bake --file {{.DEVCELL_DIR}}/docker-bake.hcl --load local {{.CLI_ARGS}} + - go test -v -timeout 120s ./test/... {{.CLI_ARGS}} - bake:ci: - desc: Build ci group and push to registry (multi-arch) - silent: true + test:vagrant: + desc: Run E2E install test in a clean Debian VM (QEMU). Requires vagrant + vagrant-qemu plugin. + dir: "{{.TASKFILE_DIR}}/test/vagrant" cmds: - - docker buildx bake --file {{.DEVCELL_DIR}}/docker-bake.hcl --push ci {{.CLI_ARGS}} + - vagrant up --provider=qemu + - defer: vagrant destroy -f + env: + CELL_VERSION: '{{.CELL_VERSION | default "0.0.0"}}' web:docs: desc: Generate cell CLI markdown reference for the website (requires Go) silent: true - dir: "{{.DEVCELL_DIR}}" + dir: "{{.TASKFILE_DIR}}" cmds: - go run $(ls cmd/*.go | grep -Ev '(_test|main)\.go') web/src/content/cell web:start: desc: Start Astro dev server for the DevCell landing page silent: true - dir: "{{.DEVCELL_DIR}}/web" + dir: "{{.TASKFILE_DIR}}/web" cmds: - npm install - npm run dev - - install: - desc: Install cell binary to ~/.local/bin/cell - dir: "{{.DEVCELL_DIR}}" - cmds: - - mkdir -p ~/.local/bin - - task: build - - echo "Installed to ~/.local/bin/cell" - - build: - desc: Build cell binary to bin/cell - aliases: [cell:build] - dir: "{{.DEVCELL_DIR}}" - cmds: - - CGO_ENABLED=0 go build -ldflags "{{.CELL_LDFLAGS}}" -o ~/.local/bin/cell ./cmd/ - - ######################################################################################################################## - test: - desc: Run the full container integration test suite - silent: true - cmds: - - go test -v -timeout 120s ./test/... {{.CLI_ARGS}} - diff --git a/cmd/build.go b/cmd/build.go index 89d3de06..4faff4af 100644 --- a/cmd/build.go +++ b/cmd/build.go @@ -16,12 +16,12 @@ var buildCmd = &cobra.Command{ } func init() { - buildCmd.Flags().Bool("no-cache", false, "disable Docker layer cache (full rebuild)") + buildCmd.Flags().Bool("update", false, "update nix flake inputs and rebuild without cache") } func runBuild(cmd *cobra.Command, _ []string) error { applyOutputFlags() - noCache, _ := cmd.Flags().GetBool("no-cache") + update, _ := cmd.Flags().GetBool("update") c, err := config.LoadFromOS() if err != nil { @@ -35,7 +35,18 @@ func runBuild(cmd *cobra.Command, _ []string) error { } } - if err := buildImageWithSpinner(c.ConfigDir, noCache, "Building devcell image", false); err != nil { + // Regenerate package.json and pyproject.toml from devcell.toml. + if err := scaffold.RegeneratePackageFiles(c.ConfigDir); err != nil { + return fmt.Errorf("regenerate package files: %w", err) + } + + if update { + if err := updateFlakeLockWithSpinner(c.ConfigDir, false, "Updating nix flake inputs"); err != nil { + return err + } + } + + if err := buildImageWithSpinner(c.ConfigDir, update, "Building devcell image", false); err != nil { return err } return nil diff --git a/cmd/chrome.go b/cmd/chrome.go deleted file mode 100644 index 5e5692d4..00000000 --- a/cmd/chrome.go +++ /dev/null @@ -1,45 +0,0 @@ -package main - -import ( - "fmt" - "os/exec" - - "github.com/DimmKirr/devcell/internal/config" - "github.com/spf13/cobra" -) - -var chromeCmd = &cobra.Command{ - Use: "chrome [args...]", - Short: "Open Chromium with a project-scoped profile", - Long: `Opens Chromium with a project-scoped browser profile stored in the cell home. - -Each project gets its own isolated Chrome profile so cookies, extensions, and -logins don't bleed across projects. All additional args are forwarded to -Chromium unchanged. - -Examples: - - cell chrome - cell chrome https://example.com`, - DisableFlagParsing: true, - RunE: runChrome, -} - -func runChrome(_ *cobra.Command, args []string) error { - c, err := config.LoadFromOS() - if err != nil { - return fmt.Errorf("load config: %w", err) - } - argv := buildChromeArgv(c.CellHome, args) - cmd := exec.Command(argv[0], argv[1:]...) - return cmd.Run() -} - -func buildChromeArgv(cellHome string, extraArgs []string) []string { - base := []string{ - "open", "-na", "Chromium", - "--args", - "--user-data-dir=" + cellHome + "/.chrome", - } - return append(base, extraArgs...) -} diff --git a/cmd/claude.go b/cmd/claude.go index 14855578..02a3a34c 100644 --- a/cmd/claude.go +++ b/cmd/claude.go @@ -20,7 +20,7 @@ args are forwarded to the claude binary unchanged. Use --ollama to route Claude Code through a local ollama instance (Anthropic Messages API compatibility). This sets ANTHROPIC_BASE_URL to point at ollama on the host. Can also be enabled permanently via -use_ollama = true in the [claude] section of devcell.toml. +use_ollama = true in the [llm] section of devcell.toml. Examples: @@ -44,7 +44,7 @@ func claudeEnv() map[string]string { c, err := config.LoadFromOS() if err == nil { cellCfg := cfg.LoadFromOS(c.ConfigDir, c.BaseDir) - useOllama = cellCfg.Claude.UseOllama + useOllama = cellCfg.LLM.UseOllama } } @@ -56,7 +56,7 @@ func claudeEnv() map[string]string { fmt.Fprintf(os.Stderr, " claude: ollama mode enabled, redirecting API to host ollama\n") } return map[string]string{ - "ANTHROPIC_BASE_URL": "http://host.docker.internal:11434", + "ANTHROPIC_BASE_URL": "http://host.docker.internal:11434", "ANTHROPIC_AUTH_TOKEN": "ollama", "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1", } diff --git a/cmd/claude_test.go b/cmd/claude_test.go index 5eabd850..104bc135 100644 --- a/cmd/claude_test.go +++ b/cmd/claude_test.go @@ -76,14 +76,14 @@ func TestClaude_NoOllama_NoEnv(t *testing.T) { } } -// TestClaude_ConfigUseOllama_InjectsEnv verifies that [claude] use_ollama=true +// TestClaude_ConfigUseOllama_InjectsEnv verifies that [llm] use_ollama=true // in devcell.toml injects the ollama env vars. func TestClaude_ConfigUseOllama_InjectsEnv(t *testing.T) { home := scaffoldedHome(t) cfgDir := filepath.Join(home, ".config", "devcell") tomlContent := `[cell] -[claude] +[llm] use_ollama = true ` if err := os.WriteFile(filepath.Join(cfgDir, "devcell.toml"), []byte(tomlContent), 0644); err != nil { diff --git a/cmd/init.go b/cmd/init.go index 3c886502..5c60d9bd 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -2,6 +2,7 @@ package main import ( "context" + "errors" "fmt" "os" @@ -13,18 +14,24 @@ import ( ) var initCmd = &cobra.Command{ - Use: "init", - Short: "Scaffold ~/.config/devcell/ and optionally build the image", + Use: "init [.]", + Short: "Scaffold ~/.config/devcell/ (or .devcell.toml in current dir with '.')", RunE: runInit, + Args: cobra.MaximumNArgs(1), } func init() { initCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompts and proceed with defaults") initCmd.Flags().Bool("macos", false, "Set up a macOS VM box via UTM + Vagrant") - initCmd.Flags().Bool("force", false, "Overwrite existing Dockerfile, flake.nix, and devcell.toml") + initCmd.Flags().Bool("force", false, "Overwrite existing files and update flake inputs (implies --update)") + initCmd.Flags().Bool("update", false, "update nix flake inputs (pull latest) instead of just resolving") + initCmd.Flags().String("local-nixhome", "", "path to local nixhome to copy and use (generates path:./nixhome flake input)") } -func runInit(cmd *cobra.Command, _ []string) error { +func runInit(cmd *cobra.Command, args []string) error { + if len(args) == 1 && args[0] == "." { + return runInitProject(cmd) + } macos, _ := cmd.Flags().GetBool("macos") if macos { return runInitMacOS() @@ -32,6 +39,7 @@ func runInit(cmd *cobra.Command, _ []string) error { applyOutputFlags() yes, _ := cmd.Flags().GetBool("yes") force, _ := cmd.Flags().GetBool("force") + update, _ := cmd.Flags().GetBool("update") // Override base image tag for scaffold Dockerfile if --base-image is set. if bi, _ := cmd.Flags().GetString("base-image"); bi != "" { @@ -48,12 +56,35 @@ func runInit(cmd *cobra.Command, _ []string) error { modelsSnippet := detectOllamaModels() fmt.Printf(" Scaffolding %s\n", c.ConfigDir) - nixhomePath := os.Getenv("DEVCELL_NIXHOME_PATH") + nixhomePath, _ := cmd.Flags().GetString("local-nixhome") + if nixhomePath == "" { + nixhomePath = os.Getenv("DEVCELL_NIXHOME_PATH") + } if err := scaffold.Scaffold(c.ConfigDir, modelsSnippet, nixhomePath, force); err != nil { return fmt.Errorf("scaffold: %w", err) } + // Copy local nixhome into config dir when --local-nixhome is set. + if nixhomePath != "" { + if err := scaffold.SyncNixhome(nixhomePath, c.ConfigDir); err != nil { + return fmt.Errorf("sync nixhome: %w", err) + } + } fmt.Printf(" Config dir ready: %s\n", c.ConfigDir) + // Resolve flake inputs (generates flake.lock if missing). + // --update or --force pulls latest instead of just resolving. + if force { + update = true + } + lockOnly := !update + label := "Resolving nix flake inputs" + if !lockOnly { + label = "Updating nix flake inputs" + } + if err := updateFlakeLockWithSpinner(c.ConfigDir, lockOnly, label); err != nil { + return err + } + if !yes { ok, promptErr := ux.GetConfirmation("Build image now? (~5 min first time)") if promptErr != nil || !ok { @@ -62,9 +93,37 @@ func runInit(cmd *cobra.Command, _ []string) error { } } - if err := buildImageWithSpinner(c.ConfigDir, false, "Building devcell image", false); err != nil { + if err := buildImageWithSpinner(c.ConfigDir, force, "Building devcell image", false); err != nil { + return err + } + return nil +} + +// runInitProject handles `cell init .` — creates a .devcell.toml in the current directory. +func runInitProject(_ *cobra.Command) error { + cwd, err := os.Getwd() + if err != nil { + return err + } + ok, err := ux.GetConfirmation(fmt.Sprintf("Create %s/.devcell.toml?", cwd)) + if err != nil || !ok { + return nil + } + if err := scaffold.ScaffoldProject(cwd); err != nil { + if errors.Is(err, os.ErrExist) { + overwrite, promptErr := ux.GetConfirmation(".devcell.toml already exists. Overwrite?") + if promptErr != nil || !overwrite { + return nil + } + if err := scaffold.ScaffoldProjectForce(cwd); err != nil { + return err + } + fmt.Println(" Overwrote .devcell.toml") + return nil + } return err } + fmt.Println(" Created .devcell.toml") return nil } diff --git a/cmd/opencode.go b/cmd/opencode.go index ef6c7be5..2b65b640 100644 --- a/cmd/opencode.go +++ b/cmd/opencode.go @@ -31,7 +31,7 @@ All additional args are forwarded to the opencode binary unchanged. On first run, if no opencode config exists at $CELL_HOME/.config/opencode/, locally available ollama models are auto-detected and written there. -When [models] is configured in devcell.toml, those models are used instead. +When [llm.models] is configured in devcell.toml, those models are used instead. Examples: @@ -95,20 +95,20 @@ func opencodeEnv() map[string]string { fmt.Fprintf(os.Stderr, " opencode: config load failed, using minimal config\n") } return map[string]string{ - "OPENCODE_CONFIG_CONTENT": string(buildOpencodeJSON(cfg.ModelsSection{})), + "OPENCODE_CONFIG_CONTENT": string(buildOpencodeJSON(cfg.LLMModelsSection{})), } } - // Resolve models: devcell.toml [models] > auto-detect ollama > empty. + // Resolve models: devcell.toml [llm.models] > auto-detect ollama > empty. cellCfg := cfg.LoadFromOS(c.ConfigDir, c.BaseDir) - models := cellCfg.Models + models := cellCfg.LLM.Models if len(models.Providers) > 0 { if dbg { - fmt.Fprintf(os.Stderr, " opencode: using models from devcell.toml [models]\n") + fmt.Fprintf(os.Stderr, " opencode: using models from devcell.toml [llm.models]\n") } } else { if dbg { - fmt.Fprintf(os.Stderr, " opencode: no [models] in devcell.toml, probing ollama...\n") + fmt.Fprintf(os.Stderr, " opencode: no [llm.models] in devcell.toml, probing ollama...\n") } models = autoDetectOllamaModels() } @@ -185,30 +185,30 @@ func mergeOpencodeConfig(path string, newData []byte, dbg bool) ([]byte, error) } // autoDetectOllamaModels probes the local ollama instance for available models, -// ranks them by SWE-bench fallback ratings, and returns a ModelsSection with +// ranks them by SWE-bench fallback ratings, and returns a LLMModelsSection with // all detected models and the #1 ranked model as default. -func autoDetectOllamaModels() cfg.ModelsSection { +func autoDetectOllamaModels() cfg.LLMModelsSection { dbg := scanFlag("--debug") ctx := context.Background() if !ollama.Detect(ctx, ollama.DefaultBaseURL) { if dbg { fmt.Fprintf(os.Stderr, " opencode: ollama not detected at %s\n", ollama.DefaultBaseURL) } - return cfg.ModelsSection{} + return cfg.LLMModelsSection{} } models, err := ollama.FetchModels(ctx, ollama.DefaultBaseURL) if err != nil || len(models) == 0 { if dbg { fmt.Fprintf(os.Stderr, " opencode: no models found from ollama\n") } - return cfg.ModelsSection{} + return cfg.LLMModelsSection{} } if dbg { fmt.Fprintf(os.Stderr, " opencode: found %d models from ollama\n", len(models)) } ranked := ollama.RankModels(models, 0, nil, nil) if len(ranked) == 0 { - return cfg.ModelsSection{} + return cfg.LLMModelsSection{} } if dbg { @@ -225,7 +225,7 @@ func autoDetectOllamaModels() cfg.ModelsSection { // Only provide the model list — don't auto-select a default. // The user picks in opencode's UI, or sets [models] default in devcell.toml. - return cfg.ModelsSection{ + return cfg.LLMModelsSection{ Providers: map[string]cfg.LLMProvider{ "ollama": {Models: names}, }, @@ -265,17 +265,17 @@ type opencodeJSON struct { type opencodeProviderJSON struct { NPM string `json:"npm"` - Options map[string]string `json:"options"` - Models map[string]opencodeModelJSON `json:"models"` + Options map[string]string `json:"options"` + Models map[string]opencodeModelJSON `json:"models"` } type opencodeModelJSON struct { Name string `json:"name"` } -// buildOpencodeJSON generates opencode config JSON from the [models] section. +// buildOpencodeJSON generates opencode config JSON from the [llm.models] section. // Always includes permission:"allow" for sandbox auto-approval. -func buildOpencodeJSON(ms cfg.ModelsSection) []byte { +func buildOpencodeJSON(ms cfg.LLMModelsSection) []byte { doc := opencodeJSON{ Schema: "https://opencode.ai/config.json", Permission: "allow", diff --git a/cmd/opencode_test.go b/cmd/opencode_test.go index 18ad2246..50ab228b 100644 --- a/cmd/opencode_test.go +++ b/cmd/opencode_test.go @@ -106,17 +106,17 @@ func TestOpencode_ConfigContentEnvInjected(t *testing.T) { } } -// TestOpencode_ConfigContentWithOllama verifies [models] from devcell.toml +// TestOpencode_ConfigContentWithOllama verifies [llm.models] from devcell.toml // is denormalized into OPENCODE_CONFIG_CONTENT. func TestOpencode_ConfigContentWithOllama(t *testing.T) { home := scaffoldedHome(t) - // Write devcell.toml with [models] section + // Write devcell.toml with [llm.models] section cfgDir := filepath.Join(home, ".config", "devcell") tomlContent := `[cell] -[models] +[llm.models] default = "ollama/deepseek-r1:32b" -[models.providers.ollama] +[llm.models.providers.ollama] models = ["deepseek-r1:32b", "qwen3:8b"] ` if err := os.WriteFile(filepath.Join(cfgDir, "devcell.toml"), []byte(tomlContent), 0644); err != nil { @@ -168,7 +168,7 @@ models = ["deepseek-r1:32b", "qwen3:8b"] } } -// TestOpencode_ConfigContentNoModels verifies minimal config when no [models]. +// TestOpencode_ConfigContentNoModels verifies minimal config when no [llm.models]. func TestOpencode_ConfigContentNoModels(t *testing.T) { home := scaffoldedHome(t) @@ -211,12 +211,12 @@ func TestOpencode_ExistingConfigMergesModels(t *testing.T) { t.Fatal(err) } - // Write devcell.toml with [models] so models get injected. + // Write devcell.toml with [llm.models] so models get injected. cfgDir := filepath.Join(home, ".config", "devcell") tomlContent := `[cell] -[models] +[llm.models] default = "ollama/qwen3:8b" -[models.providers.ollama] +[llm.models.providers.ollama] models = ["qwen3:8b"] ` if err := os.WriteFile(filepath.Join(cfgDir, "devcell.toml"), []byte(tomlContent), 0644); err != nil { @@ -265,12 +265,12 @@ models = ["qwen3:8b"] func TestOpencode_WritesConfigToDisk(t *testing.T) { home := scaffoldedHome(t) - // Write devcell.toml with [models] section + // Write devcell.toml with [llm.models] section cfgDir := filepath.Join(home, ".config", "devcell") tomlContent := `[cell] -[models] +[llm.models] default = "ollama/deepseek-r1:32b" -[models.providers.ollama] +[llm.models.providers.ollama] models = ["deepseek-r1:32b"] ` if err := os.WriteFile(filepath.Join(cfgDir, "devcell.toml"), []byte(tomlContent), 0644); err != nil { diff --git a/cmd/root.go b/cmd/root.go index 8d04f5af..575c9a59 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -15,6 +15,7 @@ import ( "github.com/DimmKirr/devcell/internal/backup" "github.com/DimmKirr/devcell/internal/cfg" "github.com/DimmKirr/devcell/internal/config" + "github.com/DimmKirr/devcell/internal/op" "github.com/DimmKirr/devcell/internal/runner" "github.com/DimmKirr/devcell/internal/scaffold" "github.com/DimmKirr/devcell/internal/ux" @@ -29,6 +30,12 @@ var rootCmd = &cobra.Command{ Long: `cell launches AI coding agents (claude, codex, opencode) and utility tools inside a consistent Docker dev environment.`, Args: cobra.ArbitraryArgs, + PersistentPreRun: func(cmd *cobra.Command, args []string) { + debug, _ := cmd.Flags().GetBool("debug") + if debug { + fmt.Fprintf(os.Stderr, "cell %s\n", version.Full()) + } + }, RunE: func(cmd *cobra.Command, args []string) error { if len(args) > 0 { return fmt.Errorf("unknown command %q — run 'cell --help' for usage", args[0]) @@ -39,12 +46,20 @@ tools inside a consistent Docker dev environment.`, func Execute() { if err := rootCmd.Execute(); err != nil { + fmt.Fprintf(os.Stderr, "\n cell %s\n", version.Full()) + baseVer, userVer := runner.ImageVersions(context.Background()) + if baseVer != "" { + fmt.Fprintf(os.Stderr, " Base image: %s\n", baseVer) + } + if userVer != "" { + fmt.Fprintf(os.Stderr, " User image: %s\n", userVer) + } os.Exit(1) } } func init() { - rootCmd.Version = version.Version + rootCmd.Version = version.Full() rootCmd.PersistentFlags().Bool("build", false, "rebuild image before running (forces --no-cache)") rootCmd.PersistentFlags().Bool("dry-run", false, "print docker run argv and exit without running") rootCmd.PersistentFlags().Bool("plain-text", false, "disable spinners, use plain log output (for CI/non-TTY)") @@ -54,6 +69,7 @@ func init() { rootCmd.PersistentFlags().String("vagrant-provider", "utm", "Vagrant provider (e.g. utm)") rootCmd.PersistentFlags().String("vagrant-box", "", "Vagrant box name override") rootCmd.PersistentFlags().String("base-image", "", "base image for scaffold Dockerfile (default: ghcr.io/dimmkirr/devcell:base-local)") + rootCmd.PersistentFlags().String("session-name", "", "session name for persistent home (~/.devcell/)") rootCmd.AddCommand( claudeCmd, codexCmd, @@ -63,7 +79,6 @@ func init() { initCmd, vncCmd, rdpCmd, - chromeCmd, modelsCmd, ) } @@ -104,6 +119,7 @@ var cellStringFlags = map[string]bool{ "--vagrant-provider": true, "--vagrant-box": true, "--base-image": true, + "--session-name": true, } // stripCellFlags removes devcell-specific flags (and their values) from args @@ -155,6 +171,11 @@ func runAgent(binary string, defaultFlags, userArgs []string, extraEnv map[strin os.Setenv("DEVCELL_BASE_IMAGE", bi) } + // Override session name via --session-name flag. + if sn := scanStringFlag("--session-name"); sn != "" { + os.Setenv("DEVCELL_SESSION_NAME", sn) + } + // First-run: scaffold if devcell.toml absent if !scaffold.IsInitialized(c.ConfigDir) { fmt.Printf(" First run — scaffolding %s\n", c.ConfigDir) @@ -186,7 +207,15 @@ func runAgent(binary string, defaultFlags, userArgs []string, extraEnv map[strin cellCfg := cfg.LoadFromOS(c.ConfigDir, c.BaseDir) + // Resolve available GUI ports — probe and bump if already bound + if cellCfg.Cell.GUI { + c.ResolveAvailablePorts() + } + if scanFlag("--build") && !scanFlag("--dry-run") { + if err := scaffold.RegeneratePackageFiles(c.ConfigDir); err != nil { + return fmt.Errorf("regenerate package files: %w", err) + } if err := buildImageWithSpinner(c.ConfigDir, true, "Building devcell image", false); err != nil { return err } @@ -200,6 +229,16 @@ func runAgent(binary string, defaultFlags, userArgs []string, extraEnv map[strin if ux.Verbose { fmt.Printf(" APP_NAME: %s | VNC: localhost:%s | RDP: localhost:%s | HOME: %s\n", c.AppName, c.VNCPort, c.RDPPort, c.CellHome) + baseVer, userVer := runner.ImageVersions(context.Background()) + if baseVer != "" { + fmt.Printf(" Base image: %s\n", baseVer) + } + if userVer != "" { + fmt.Printf(" User image: %s\n", userVer) + } + if baseVer == "" && userVer == "" { + fmt.Printf(" Image versions: not available (missing /etc/devcell/*-image-version)\n") + } } if !ux.Verbose { @@ -229,6 +268,49 @@ func runAgent(binary string, defaultFlags, userArgs []string, extraEnv map[strin imageID = "" } + // Inject system prompt for Claude Code — describes container environment, + // bind mounts, and host path mappings so Claude understands its runtime context. + if binary == "claude" { + prompt := runner.BuildSystemPrompt(c, cellCfg) + defaultFlags = append(defaultFlags, "--append-system-prompt", prompt) + } + + // Resolve git identity from host config (follows symlinks, includes, XDG paths). + // Only if no explicit git env or [git] toml section — those take priority in BuildArgv. + if os.Getenv("GIT_AUTHOR_NAME") == "" && !cellCfg.Git.HasIdentity() { + if extraEnv == nil { + extraEnv = make(map[string]string) + } + if out, err := exec.Command("git", "config", "user.name").Output(); err == nil { + if name := strings.TrimSpace(string(out)); name != "" { + extraEnv["GIT_AUTHOR_NAME"] = name + extraEnv["GIT_COMMITTER_NAME"] = name + } + } + if out, err := exec.Command("git", "config", "user.email").Output(); err == nil { + if email := strings.TrimSpace(string(out)); email != "" { + extraEnv["GIT_AUTHOR_EMAIL"] = email + extraEnv["GIT_COMMITTER_EMAIL"] = email + } + } + } + + // Resolve 1Password items → set in process env so docker inherits via -e KEY + var inheritEnv []string + if len(cellCfg.Op.Items) > 0 { + if _, err := exec.LookPath("op"); err == nil { + resolved, err := op.ResolveItems(cellCfg.Op.Items) + if err != nil { + fmt.Fprintf(os.Stderr, "warning: 1Password: %v\n", err) + } else { + for k, v := range resolved { + os.Setenv(k, v) + inheritEnv = append(inheritEnv, k) + } + } + } + } + spec := runner.RunSpec{ Config: c, CellCfg: cellCfg, @@ -238,6 +320,7 @@ func runAgent(binary string, defaultFlags, userArgs []string, extraEnv map[strin Debug: ux.Verbose, Image: imageID, ExtraEnv: extraEnv, + InheritEnv: inheritEnv, } argv := runner.BuildArgv(spec, runner.OsFS, exec.LookPath) @@ -302,7 +385,6 @@ func scanStringFlag(flag string) string { return "" } - // buildImageWithSpinner runs docker build with a spinner. // In verbose mode (--debug), build output streams to stdout. // In quiet mode, output is captured and replayed to stderr only on failure. @@ -332,6 +414,29 @@ func buildImageWithSpinner(configDir string, noCache bool, label string, silent return nil } +// updateFlakeLockWithSpinner runs nix flake lock/update with a spinner. +// Same pattern as buildImageWithSpinner. +func updateFlakeLockWithSpinner(configDir string, lockOnly bool, label string) error { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + var buf bytes.Buffer + var out io.Writer = &buf + if ux.Verbose { + out = os.Stdout + } + sp := ux.NewProgressSpinner(label) + if err := runner.UpdateFlakeLock(ctx, configDir, lockOnly, ux.Verbose, out); err != nil { + sp.Fail(label + " failed") + if !ux.Verbose && buf.Len() > 0 { + fmt.Fprint(os.Stderr, buf.String()) + } + return err + } + sp.Success(label) + return nil +} + func shellJoin(argv []string) string { var parts []string for _, a := range argv { diff --git a/cmd/utility_test.go b/cmd/utility_test.go index 2e57df64..7cfbe5eb 100644 --- a/cmd/utility_test.go +++ b/cmd/utility_test.go @@ -47,38 +47,6 @@ func TestShell_NoDefaultFlags(t *testing.T) { } } -// --- chrome argv helper --- - -func chromeArgv(cellHome string, extraArgs []string) []string { - base := []string{"open", "-na", "Chromium", "--args", "--user-data-dir=" + cellHome + "/.chrome"} - return append(base, extraArgs...) -} - -func TestChrome_BasicArgv(t *testing.T) { - argv := chromeArgv("/home/bob/.devcell/sandbox", nil) - if argv[0] != "open" || argv[1] != "-na" || argv[2] != "Chromium" { - t.Errorf("unexpected chrome argv start: %v", argv) - } - found := false - for _, a := range argv { - if strings.HasPrefix(a, "--user-data-dir=") { - found = true - break - } - } - if !found { - t.Errorf("missing --user-data-dir flag: %v", argv) - } -} - -func TestChrome_ExtraArgsAppended(t *testing.T) { - argv := chromeArgv("/home/bob/.devcell/sandbox", []string{"--incognito"}) - last := argv[len(argv)-1] - if last != "--incognito" { - t.Errorf("expected --incognito at end, got: %v", argv) - } -} - // --- behavioural: VNC port determinism --- func TestVNCPort_SamePaneSamePort(t *testing.T) { diff --git a/docker-bake.hcl b/docker-bake.hcl index 9b34e9c0..4e7656fa 100644 --- a/docker-bake.hcl +++ b/docker-bake.hcl @@ -40,18 +40,22 @@ variable "PLATFORMS" { # ── Shared inheritance targets (prefixed _ = not buildable directly) ────────── +variable "GIT_COMMIT" { + default = "" +} + target "_base-args" { args = { - USER_NAME = USER_NAME - USER_UID = USER_UID - USER_GID = USER_GID + USER_NAME = USER_NAME + USER_UID = USER_UID + USER_GID = USER_GID + GIT_COMMIT = GIT_COMMIT } - } -# ── Profile image targets ──────────────────────────────────────────────────── -# Each target builds a Dockerfile stage that applies a nix home-manager profile -# plus any language-specific tools (go install, npm, uv) that profile requires. +# ── Stack image targets ────────────────────────────────────────────────────── +# Each target builds a Dockerfile stage that applies a nix home-manager stack +# plus any language-specific tools (go install, npm, uv) that stack requires. target "base" { inherits = ["_base-args"] @@ -151,9 +155,9 @@ group "ci" { targets = ["base", "ultimate"] } -# release: all published variants for a tagged release +# release: all published stacks for a tagged release group "release" { - targets = ["base"] + targets = ["base", "ultimate"] } # local-base: base tagged for local scaffold Dockerfile use (FROM ghcr.io/dimmkirr/devcell:base-local) @@ -166,7 +170,7 @@ target "local-base" { cache-to = [] } -# local-ultimate: ultimate profile for local testing (uses local nixhome/) +# local-ultimate: ultimate stack for local testing (uses local nixhome/) target "local-ultimate" { inherits = ["ultimate"] tags = ["ghcr.io/dimmkirr/devcell:ultimate-local"] diff --git a/go.mod b/go.mod index fbcc3460..e097b69d 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/pterm/pterm v0.12.80 github.com/spf13/cobra v1.8.1 github.com/testcontainers/testcontainers-go v0.40.0 + golang.org/x/mod v0.30.0 ) require ( diff --git a/go.sum b/go.sum index b7d85543..d8f53198 100644 --- a/go.sum +++ b/go.sum @@ -213,6 +213,8 @@ golang.org/x/exp v0.0.0-20250218142911-aa4b98e5adaa h1:t2QcU6V556bFjYgu4L6C+6VrC golang.org/x/exp v0.0.0-20250218142911-aa4b98e5adaa/go.mod h1:BHOTPb3L19zxehTsLoJXVaTktb06DFgmdW6Wb9s8jqk= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= +golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= diff --git a/images/Dockerfile b/images/Dockerfile index 7cfe33b7..9a6ad5ee 100644 --- a/images/Dockerfile +++ b/images/Dockerfile @@ -1,5 +1,16 @@ # syntax=docker/dockerfile:1 +############################################################################### +# Stage: builder +# Compile the cell CLI binary from repo source (CI context = repo root). +############################################################################### +FROM public.ecr.aws/docker/library/golang:1.24-alpine AS builder +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -o /cell ./cmd + ############################################################################### # Stage: base # Apt + user creation + nix + home-manager. Published as base image. @@ -42,6 +53,8 @@ RUN \ chown -R ${USER_UID}:${USER_GID} /opt/devcell && \ echo "${USER_NAME} ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers +COPY --from=builder --chmod=755 /cell /opt/devcell/.local/bin/cell + RUN mkdir -p /config /data /opt/mise /opt/npm-tools /opt/python-tools \ /opt/devcell/.config/devcell && \ chown -R ${USER_UID}:${USER_GID} /config /data /opt/mise /opt/npm-tools /opt/python-tools \ @@ -52,7 +65,7 @@ RUN mkdir -p /config /data /opt/mise /opt/npm-tools /opt/python-tools \ # nix's seccomp BPF program with EINVAL (kernel compatibility issue). # Docker's own isolation is sufficient for build containers. RUN mkdir -p /etc/nix && \ - printf 'sandbox = false\nfilter-syscalls = false\nsandbox-fallback = true\nexperimental-features = nix-command flakes\n' \ + printf 'sandbox = false\nfilter-syscalls = false\nsandbox-fallback = true\nexperimental-features = nix-command flakes\nmax-substitution-jobs = 128\nhttp-connections = 128\n' \ > /etc/nix/nix.conf USER ${USER_UID}:${USER_GID} @@ -72,32 +85,41 @@ ENV USER=${USER_NAME} RUN export NIX_CONFIG="$(printf 'experimental-features = nix-command flakes\nsandbox = false\nfilter-syscalls = false\nsandbox-fallback = true')" && \ curl -L https://nixos.org/nix/install | sh -s -- --no-daemon && \ mkdir -p "${HOME}/.config/nix" && \ - printf 'experimental-features = nix-command flakes\nsandbox = false\nfilter-syscalls = false\nsandbox-fallback = true\n' \ + printf 'experimental-features = nix-command flakes\nsandbox = false\nfilter-syscalls = false\nsandbox-fallback = true\nmax-substitution-jobs = 128\nhttp-connections = 128\n' \ > "${HOME}/.config/nix/nix.conf" # Nix 2.15+ uses XDG paths. Add nix-profile to PATH for subsequent RUN commands. ENV PATH="${HOME}/.nix-profile/bin:${PATH}" -# Install home-manager and create nix per-user profile compat symlink. +# Install home-manager. # Pin nixpkgs to the exact commit from nixhome/flake.lock to avoid GitHub API # branch→commit resolution (anonymous rate limit is 60 req/hr per IP; shared # GHA runners exhaust this). Update this hash when bumping nixhome/flake.lock. ARG NIXPKGS_REV=c217913993d6c6f6805c3b1a3bda5e639adfde6d -RUN nix profile install "github:NixOS/nixpkgs/${NIXPKGS_REV}#home-manager" && \ - mkdir -p /nix/var/nix/profiles/per-user/devcell && \ - ln -sfT "$(readlink -f $HOME/.nix-profile)" \ - /nix/var/nix/profiles/per-user/devcell/profile +RUN nix profile install "github:NixOS/nixpkgs/${NIXPKGS_REV}#home-manager" ENV MISE_DATA_DIR=/opt/mise +# Use home-manager's native profile path — automatically updated on every +# `home-manager switch`, no manual symlink management needed. # Mise installs go to /opt/mise/installs///bin. Child stages create # stable symlinks (/opt/mise/ → installs//) and append to PATH # so that tools are available without shims (shims need `mise exec` resolution # which fails in plain /bin/sh RUN steps). -ENV PATH="/nix/var/nix/profiles/per-user/devcell/profile/bin:${PATH}" +ENV PATH="/opt/devcell/.local/state/nix/profiles/profile/bin:${PATH}" + +# Stamp base image version (commit SHA + build date) — needs root for /etc +USER 0 +ARG GIT_COMMIT=unknown +RUN mkdir -p /etc/devcell && \ + chown ${USER_UID}:${USER_GID} /etc/devcell && \ + echo "${GIT_COMMIT}-$(date -u +%Y%m%dT%H%M%SZ)" > /etc/devcell/base-image-version # Entrypoint last — changes here don't bust the nix/home-manager cache above COPY --chmod=755 images/entrypoint.sh /usr/local/bin/entrypoint.sh +# Restore to devcell user — child stages (go, node, etc.) inherit this USER +USER ${USER_UID}:${USER_GID} + WORKDIR / ENTRYPOINT ["tini", "--", "/usr/local/bin/entrypoint.sh"] @@ -115,8 +137,6 @@ COPY --chown=${USER_UID}:${USER_GID} nixhome/ /opt/nixhome/ RUN ARCH=$(uname -m) && \ [ "$ARCH" = "aarch64" ] && ARCH_SUFFIX="-aarch64" || ARCH_SUFFIX="" && \ home-manager switch --flake "/opt/nixhome#devcell-go${ARCH_SUFFIX}" && \ - ln -sfT "$(readlink -f $HOME/.nix-profile)" \ - /nix/var/nix/profiles/per-user/devcell/profile && \ cd "$HOME" && MISE_DATA_DIR=/opt/mise MISE_YES=1 mise install && \ for tool_dir in /opt/mise/installs/*/; do \ tool=$(basename "$tool_dir"); \ @@ -138,8 +158,6 @@ COPY --chown=${USER_UID}:${USER_GID} nixhome/ /opt/nixhome/ RUN ARCH=$(uname -m) && \ [ "$ARCH" = "aarch64" ] && ARCH_SUFFIX="-aarch64" || ARCH_SUFFIX="" && \ home-manager switch --flake "/opt/nixhome#devcell-node${ARCH_SUFFIX}" && \ - ln -sfT "$(readlink -f $HOME/.nix-profile)" \ - /nix/var/nix/profiles/per-user/devcell/profile && \ cd "$HOME" && MISE_DATA_DIR=/opt/mise MISE_YES=1 mise install && \ for tool_dir in /opt/mise/installs/*/; do \ tool=$(basename "$tool_dir"); \ @@ -160,9 +178,7 @@ ARG USER_GID=1000 COPY --chown=${USER_UID}:${USER_GID} nixhome/ /opt/nixhome/ RUN ARCH=$(uname -m) && \ [ "$ARCH" = "aarch64" ] && ARCH_SUFFIX="-aarch64" || ARCH_SUFFIX="" && \ - home-manager switch --flake "/opt/nixhome#devcell-python${ARCH_SUFFIX}" && \ - ln -sfT "$(readlink -f $HOME/.nix-profile)" \ - /nix/var/nix/profiles/per-user/devcell/profile + home-manager switch --flake "/opt/nixhome#devcell-python${ARCH_SUFFIX}" ENV DEVCELL_PROFILE=devcell-python @@ -172,11 +188,6 @@ RUN cd /opt/python-tools && uv sync SHELL ["/bin/sh", "-c"] ENV PATH="/opt/python-tools/.venv/bin:${PATH}" -ENV PLAYWRIGHT_MCP_BROWSER=chromium -ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 -ENV PLAYWRIGHT_BROWSERS_PATH=0 -ENV PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH="/nix/var/nix/profiles/per-user/devcell/profile/bin/chromium" - ############################################################################### # Stage: electronics # devcell-electronics profile: Build tools + KiCad, ngspice, libspnav, poppler. @@ -188,9 +199,7 @@ ARG USER_GID=1000 COPY --chown=${USER_UID}:${USER_GID} nixhome/ /opt/nixhome/ RUN ARCH=$(uname -m) && \ [ "$ARCH" = "aarch64" ] && ARCH_SUFFIX="-aarch64" || ARCH_SUFFIX="" && \ - home-manager switch --flake "/opt/nixhome#devcell-electronics${ARCH_SUFFIX}" && \ - ln -sfT "$(readlink -f $HOME/.nix-profile)" \ - /nix/var/nix/profiles/per-user/devcell/profile + home-manager switch --flake "/opt/nixhome#devcell-electronics${ARCH_SUFFIX}" ENV DEVCELL_PROFILE=devcell-electronics @@ -206,8 +215,6 @@ COPY --chown=${USER_UID}:${USER_GID} nixhome/ /opt/nixhome/ RUN ARCH=$(uname -m) && \ [ "$ARCH" = "aarch64" ] && ARCH_SUFFIX="-aarch64" || ARCH_SUFFIX="" && \ home-manager switch --flake "/opt/nixhome#devcell-fullstack${ARCH_SUFFIX}" && \ - ln -sfT "$(readlink -f $HOME/.nix-profile)" \ - /nix/var/nix/profiles/per-user/devcell/profile && \ cd "$HOME" && MISE_DATA_DIR=/opt/mise MISE_YES=1 mise install && \ for tool_dir in /opt/mise/installs/*/; do \ tool=$(basename "$tool_dir"); \ @@ -232,11 +239,11 @@ RUN cd /opt/python-tools && uv sync SHELL ["/bin/sh", "-c"] ENV PATH="/opt/python-tools/.venv/bin:${PATH}" -# Playwright runtime env -ENV PLAYWRIGHT_MCP_BROWSER=chromium -ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 -ENV PLAYWRIGHT_BROWSERS_PATH=0 -ENV PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH="/nix/var/nix/profiles/per-user/devcell/profile/bin/chromium" +# Patchright browser — download its own patched Chromium (stealth: no webdriver leak). +# Do NOT set PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH — that overrides the patched binary. +# System deps are provided by nix (desktop/default.nix) via LD_LIBRARY_PATH in the +# patchright-mcp-cell wrapper — no apt install-deps needed. +RUN cd /opt/npm-tools && npx patchright install chromium ############################################################################### # Stage: ultimate @@ -248,9 +255,7 @@ ARG USER_UID=1000 ARG USER_GID=1000 RUN ARCH=$(uname -m) && \ [ "$ARCH" = "aarch64" ] && ARCH_SUFFIX="-aarch64" || ARCH_SUFFIX="" && \ - home-manager switch --flake "/opt/nixhome#devcell-ultimate${ARCH_SUFFIX}" && \ - ln -sfT "$(readlink -f $HOME/.nix-profile)" \ - /nix/var/nix/profiles/per-user/devcell/profile + home-manager switch --flake "/opt/nixhome#devcell-ultimate${ARCH_SUFFIX}" ENV DEVCELL_PROFILE=devcell-ultimate ENV DEVCELL_GUI_ENABLED=true diff --git a/images/entrypoint.sh b/images/entrypoint.sh index f509de3f..d50c0dc5 100755 --- a/images/entrypoint.sh +++ b/images/entrypoint.sh @@ -1,6 +1,9 @@ #!/bin/bash # Entrypoint script — runs as root, drops to HOST_USER via gosu at the end. # +# MINIMAL BOOTSTRAP ONLY. All tool-specific logic lives in nix-generated +# fragments sourced from /etc/devcell/entrypoint.d/ — see nixhome/modules/. +# # /opt/devcell — nix environment home (owned by devcell, read-only for session user) # /home/$HOST_USER — session user's personal home (writable) @@ -11,8 +14,6 @@ export USER="$HOST_USER" export HOME="/home/$HOST_USER" # ── Verbose logging — only active when DEVCELL_DEBUG=true ───────────────────── -# Default is silent so containers launched without the flag stay quiet. -# Debug mode includes elapsed time since entrypoint start for profiling. _ENTRYPOINT_T0=$(($(date +%s%N) / 1000000)) if [ "${DEVCELL_DEBUG:-false}" = "true" ]; then log() { @@ -23,8 +24,9 @@ else log() { :; } fi - log "Entrypoint start (user=$HOST_USER app=${APP_NAME:-})" +log "Base image: $(cat /etc/devcell/base-image-version 2>/dev/null || echo 'unknown')" +log "User image: $(cat /etc/devcell/user-image-version 2>/dev/null || echo 'unknown')" # ── Create session user if needed ───────────────────────────────────────────── if ! id "$HOST_USER" &>/dev/null; then @@ -33,9 +35,6 @@ if ! id "$HOST_USER" &>/dev/null; then fi # ── Grant docker socket access to session user ──────────────────────────────── -# The socket GID varies per host; resolve it at runtime and add HOST_USER to -# whatever group owns the socket (creating it if it doesn't exist inside the -# container yet). if [ -S /var/run/docker.sock ]; then DOCKER_GID=$(stat -c '%g' /var/run/docker.sock) DOCKER_GROUP=$(getent group "$DOCKER_GID" | cut -d: -f1) @@ -49,562 +48,51 @@ fi mkdir -p "$HOME/.local/bin" "$HOME/tmp" chown "$HOST_USER" "$HOME/.local" "$HOME/.local/bin" "$HOME/tmp" -# ── Give session user access to devcell's nix environment ───────────────────── -# nix.sh resolves $HOME/.nix-profile to find nix tools. Create a symlink so -# the session user's login shell gets nix in PATH without running home-manager. -ln -sfT "$(readlink -f /opt/devcell/.nix-profile)" "$HOME/.nix-profile" - -# ── Copy shell init files, override write-path vars for session user ────────── -# .profile sources nix.sh from the literal /opt/devcell path — copy as-is so -# nix stays in PATH. hm-session-vars.sh hardcodes GOPATH=/opt/devcell/go, so -# we append explicit overrides pointing write paths at session $HOME. -for file in .bashrc .zshrc .zshenv .profile; do - if [ -f "$DEVCELL_HOME/$file" ]; then - cp "$DEVCELL_HOME/$file" "$HOME/$file" - # Override write-path vars set by hm-session-vars.sh, and session identity. - # Explicitly include nix profile bin via compat symlink (world-readable); - # /opt/devcell/ is 700 so testuser cannot follow the .nix-profile symlink there. - printf '\n# -- devcell session user overrides --\nexport USER="%s"\nexport GOPATH="%s/go"\nexport MISE_DATA_DIR="%s/.local/share/mise"\nexport HISTFILE="%s/.zsh_history"\nexport PATH="%s/go/bin:/nix/var/nix/profiles/per-user/devcell/profile/bin:%s/.local/share/mise/shims:/opt/python-tools/.venv/bin:/opt/npm-tools/node_modules/.bin${PATH:+:}${PATH}"\n' \ - "$HOST_USER" "$HOME" "$HOME" "$HOME" "$HOME" "$HOME" >> "$HOME/$file" - chown "$HOST_USER" "$HOME/$file" - fi -done - -for file in .tool-versions; do - [ -e "$DEVCELL_HOME/$file" ] && [ ! -e "$HOME/$file" ] \ - && cp -r "$DEVCELL_HOME/$file" "$HOME/$file" && chown -R "$HOST_USER" "$HOME/$file" -done - -# Copy mise config from nix build to session user home -if [ -d "$DEVCELL_HOME/.config/mise" ] && [ ! -d "$HOME/.config/mise" ]; then - mkdir -p "$HOME/.config" - cp -r "$DEVCELL_HOME/.config/mise" "$HOME/.config/" - chown -R "$HOST_USER" "$HOME/.config/mise" -fi - -# Copy default-npm-packages for auto-install after Node.js -if [ -f "$DEVCELL_HOME/.default-npm-packages" ] && [ ! -f "$HOME/.default-npm-packages" ]; then - cp "$DEVCELL_HOME/.default-npm-packages" "$HOME/.default-npm-packages" - chown "$HOST_USER" "$HOME/.default-npm-packages" -fi - -# ── Setup ~/.local/share/mise (user-persisted MISE_DATA_DIR) ───────────────── -# /opt/mise holds baked-in installs (ephemeral, reset on each container start). -# ~/.local/share/mise (CellHome, bind-mounted) holds user-installed versions -# that persist. Baked-in versions are symlinked per-version so mise resolves -# both through a single MISE_DATA_DIR without copying data. -setup_mise_home() { - local baked="/opt/mise" - local user_mise="$HOME/.local/share/mise" - local mise_bin="/nix/var/nix/profiles/per-user/devcell/profile/bin/mise" - - mkdir -p "$user_mise/installs" "$user_mise/shims" - - # Symlink each baked-in tool version individually so user installs can coexist - # as real directories alongside the symlinks. - for tool_dir in "$baked/installs"/*/; do - [ -d "$tool_dir" ] || continue - tool_name=$(basename "$tool_dir") - mkdir -p "$user_mise/installs/$tool_name" - - # Remove dangling symlinks left by superseded image versions. - # Use * (not */) so dangling symlinks (no live target) are included. - for link in "$user_mise/installs/$tool_name"/*; do - if [ -L "$link" ] && [ ! -e "$link" ]; then rm -f "$link"; fi - done - - # Symlink current baked-in versions (skip real dirs — user installs). - for ver_dir in "$tool_dir"*/; do - [ -d "$ver_dir" ] || continue - ver_name=$(basename "$ver_dir") - dest="$user_mise/installs/$tool_name/$ver_name" - # Never overwrite a real directory (user-installed version). - [ -d "$dest" ] && [ ! -L "$dest" ] && continue - ln -sfT "$ver_dir" "$dest" - done +# ── Isolate GPG per container ──────────────────────────────────────────────── +# Persistent $HOME is shared across containers. GnuPG 2.4+ uses keyboxd with +# SQLite which breaks under concurrent access from different PID namespaces. +# Redirect GNUPGHOME to container-local storage to avoid lock contention. +export GNUPGHOME="$HOME/tmp/.gnupg" +mkdir -p "$GNUPGHOME" +chmod 700 "$GNUPGHOME" +chown "$HOST_USER" "$GNUPGHOME" + +# ── Clean up stale nix-store symlinks from persistent $HOME ────────────────── +# $HOME is a persistent bind mount. Home-manager symlinks from previous image +# builds dangle after nix GC removes old store paths. Remove them so fragments +# can write fresh configs. See DIMM-24. +if [ -d "$HOME" ]; then + find "$HOME" -maxdepth 4 -type l -not -path "*/tmp/*" 2>/dev/null | while IFS= read -r _link; do + _target=$(readlink "$_link" 2>/dev/null) + case "$_target" in /nix/store/*) + if [ ! -e "$_link" ]; then + log "Removing stale symlink: $_link -> $_target" + rm -f "$_link" + fi + ;; esac done - - chown -R "$HOST_USER" "$user_mise" - - # Regenerate shims for all currently visible installs. - MISE_DATA_DIR="$user_mise" HOME="$HOME" "$mise_bin" reshim 2>/dev/null || true - - # Install any versions listed in ~/.tool-versions that aren't baked. - # Skips if the file hasn't changed since the last successful install - # (checksum stored in mise data dir). First start or edits trigger a full check. - if [ -f "$HOME/.tool-versions" ]; then - local tv_sha - tv_sha=$(sha256sum "$HOME/.tool-versions" 2>/dev/null | cut -d' ' -f1) - if [ -f "$user_mise/.tv-global.sha" ] && [ "$(cat "$user_mise/.tv-global.sha" 2>/dev/null)" = "$tv_sha" ]; then - log "Global .tool-versions unchanged, skipping install" - else - log "Installing global tool versions from ~/.tool-versions..." - (cd "$HOME" && MISE_DATA_DIR="$user_mise" HOME="$HOME" USER="$HOST_USER" \ - "$mise_bin" install -y 2>&1) | while IFS= read -r line; do log "$line"; done || true - chown -R "$HOST_USER" "$user_mise" - echo "$tv_sha" > "$user_mise/.tv-global.sha" - fi - fi - - # If the workspace has a .tool-versions, install any missing versions now so - # they land in ~/.local/share/mise (CellHome) and persist — no re-download on next start. - local workspace="/${APP_NAME:-}" - if [ -n "$APP_NAME" ] && [ -f "$workspace/.tool-versions" ]; then - local ws_sha - ws_sha=$(sha256sum "$workspace/.tool-versions" 2>/dev/null | cut -d' ' -f1) - if [ -f "$user_mise/.tv-workspace.sha" ] && [ "$(cat "$user_mise/.tv-workspace.sha" 2>/dev/null)" = "$ws_sha" ]; then - log "Workspace .tool-versions unchanged, skipping install" - else - log "Installing workspace tool versions from $workspace/.tool-versions..." - MISE_DATA_DIR="$user_mise" "$mise_bin" trust "$workspace/.tool-versions" 2>/dev/null || true - (cd "$workspace" && MISE_DATA_DIR="$user_mise" HOME="$HOME" USER="$HOST_USER" \ - "$mise_bin" install -y 2>&1) | while IFS= read -r line; do log "$line"; done || true - chown -R "$HOST_USER" "$user_mise" - echo "$ws_sha" > "$user_mise/.tv-workspace.sha" - fi - fi -} -setup_mise_home - -if [ -d "$DEVCELL_HOME/.config/nix" ] && [ ! -d "$HOME/.config/nix" ]; then - mkdir -p "$HOME/.config" - cp -r "$DEVCELL_HOME/.config/nix" "$HOME/.config/" - chown -R "$HOST_USER" "$HOME/.config" -fi - -# ── Copy starship config — project homedir wins over nix default ───────────── -if [ -f "$REPO_HOMEDIR/.config/starship.toml" ]; then - mkdir -p "$HOME/.config" - cp "$REPO_HOMEDIR/.config/starship.toml" "$HOME/.config/" - chown "$HOST_USER" "$HOME/.config/starship.toml" -elif [ -f "$DEVCELL_HOME/.config/starship.toml" ]; then - mkdir -p "$HOME/.config" - cp "$DEVCELL_HOME/.config/starship.toml" "$HOME/.config/" - chown "$HOST_USER" "$HOME/.config/starship.toml" fi -# ── Copy from repo's homedir/ (project-specific overrides) ─────────────────── -merge_claude_settings() { - local template_file="$1" target_file="$2" - [ -f "$template_file" ] || return 1 - mkdir -p "$(dirname "$target_file")" - if [ ! -f "$target_file" ]; then - log "Creating Claude settings from template" - cp "$template_file" "$target_file" - return 0 - fi - local backup_file="${target_file}.backup-$(date +%Y%m%d-%H%M%S)" - cp "$target_file" "$backup_file" - log "✓ Created backup: $(basename "$backup_file")" - ls -t "${target_file}.backup-"* 2>/dev/null | tail -n +6 | xargs rm -f 2>/dev/null || true - log "Merging Claude settings (preserving existing configuration)" - local temp_file=$(mktemp) - jq -s ' - if .[0].hooks.PermissionRequest then .[0] - else .[0] * .[1] - end - ' "$target_file" "$template_file" > "$temp_file" 2>/dev/null - if [ $? -eq 0 ] && [ -s "$temp_file" ] && jq empty "$temp_file" 2>/dev/null; then - mv "$temp_file" "$target_file" - grep -q "PermissionRequest" "$target_file" \ - && log "✓ Claude settings updated (PermissionRequest hook configured)" \ - || log "✓ Claude settings preserved (custom PermissionRequest hook detected)" - else - echo "⚠ Failed to merge Claude settings, restoring from backup" - cp "$backup_file" "$target_file" - rm -f "$temp_file" - fi -} - -merge_claude_nix() { - local nix_hooks_dir="/etc/claude-code/hooks" - local nix_settings="/etc/claude-code/nix-settings.json" - if [ -d "$nix_hooks_dir" ] && [ -n "$(ls -A "$nix_hooks_dir" 2>/dev/null)" ]; then - mkdir -p "$HOME/.claude/hooks" - cp -r "$nix_hooks_dir/"* "$HOME/.claude/hooks/" 2>/dev/null || true - find "$HOME/.claude/hooks" -type f -name "*.sh" -exec chmod +x {} \; 2>/dev/null || true - chown -R "$HOST_USER" "$HOME/.claude" - log "✓ Claude hooks installed from nix" - fi - if [ -f "$nix_settings" ]; then - merge_claude_settings "$nix_settings" "$HOME/.claude/settings.json" - fi -} - -merge_claude_mcp() { - local target_file="$1" - local nix_file="/etc/claude-code/nix-mcp-servers.json" - - # No nix MCP servers configured — nothing to do. - [ -f "$nix_file" ] || return 0 - - # Validate nix source file before touching anything. - if ! jq empty "$nix_file" 2>/dev/null; then - echo "⚠ nix-mcp-servers.json is invalid JSON — skipping MCP merge" - return 1 - fi - - local backup_before_merge - backup_before_merge=$(jq -r '.backupBeforeMerge // true' "$nix_file") - - mkdir -p "$(dirname "$target_file")" - - # Fresh start — no existing ~/.claude.json. - if [ ! -f "$target_file" ]; then - log "Creating ~/.claude.json with nix MCP servers" - local temp_file - temp_file=$(mktemp) - jq '{mcpServers: (.mcpServers // {})}' "$nix_file" > "$temp_file" - if [ -s "$temp_file" ] && jq empty "$temp_file" 2>/dev/null; then - mv "$temp_file" "$target_file" - log "✓ ~/.claude.json created ($(jq '.mcpServers | length' "$target_file") server(s))" - else - rm -f "$temp_file" - echo "⚠ Failed to create ~/.claude.json from nix MCP servers" - return 1 - fi - return 0 - fi - - # Existing file is corrupt — back it up and recreate rather than abort. - if ! jq empty "$target_file" 2>/dev/null; then - local corrupt_bak="${target_file}.corrupt-$(date +%Y%m%d-%H%M%S)" - cp "$target_file" "$corrupt_bak" - log "⚠ ~/.claude.json was corrupt — saved to $(basename "$corrupt_bak"), recreating" - local temp_file - temp_file=$(mktemp) - jq '{mcpServers: (.mcpServers // {})}' "$nix_file" > "$temp_file" - if [ -s "$temp_file" ] && jq empty "$temp_file" 2>/dev/null; then - mv "$temp_file" "$target_file" - log "✓ ~/.claude.json recreated" - else - rm -f "$temp_file" - echo "⚠ Failed to recreate ~/.claude.json" - return 1 - fi - return 0 - fi - - # Optional pre-merge backup. - local backup_file="" - if [ "$backup_before_merge" = "true" ]; then - backup_file="${target_file}.backup-$(date +%Y%m%d-%H%M%S)" - cp "$target_file" "$backup_file" - log "✓ Created backup: $(basename "$backup_file")" - # Keep only 5 most recent backups. - ls -t "${target_file}.backup-"* 2>/dev/null | tail -n +6 | xargs rm -f 2>/dev/null || true - fi - - # Merge: nix servers are written over same-named user entries (infra wins). - # User servers with unique names are preserved unchanged. - local temp_file - temp_file=$(mktemp) - jq -s ' - .[0] as $existing | - .[1].mcpServers as $nix | - $existing | .mcpServers = (($existing.mcpServers // {}) + ($nix // {})) - ' "$target_file" "$nix_file" > "$temp_file" 2>/dev/null - if [ $? -eq 0 ] && [ -s "$temp_file" ] && jq empty "$temp_file" 2>/dev/null; then - mv "$temp_file" "$target_file" - log "✓ MCP servers merged into ~/.claude.json ($(jq '.mcpServers | length' "$target_file") total)" - else - rm -f "$temp_file" - echo "⚠ Failed to merge MCP servers — keeping original" - if [ -n "$backup_file" ] && [ -f "$backup_file" ]; then - cp "$backup_file" "$target_file" - echo "✓ Restored from backup" - fi - return 1 - fi -} - -merge_claude_nix - -if [ -d "$REPO_HOMEDIR" ]; then - log "Found repo homedir at $REPO_HOMEDIR" - find "$REPO_HOMEDIR" -mindepth 1 -maxdepth 1 | while read -r item; do - basename_item=$(basename "$item") - [ "$basename_item" = ".claude" ] && continue - dest="$HOME/$basename_item" - if [ ! -e "$dest" ]; then - log "Copying $basename_item from repo to ~/" - cp -r "$item" "$dest" - fi - done -fi - -merge_opencode_providers() { - local target_file="$1" - local nix_file="/etc/opencode/nix-providers.json" - - [ -f "$nix_file" ] || return 0 - - if ! jq empty "$nix_file" 2>/dev/null; then - echo "⚠ nix-providers.json (OpenCode) is invalid JSON — skipping provider merge" - return 1 - fi - - mkdir -p "$(dirname "$target_file")" - - if [ ! -f "$target_file" ]; then - # No user config yet — seed it from nix providers - local temp_file - temp_file=$(mktemp) - jq '{"$schema":"https://opencode.ai/config.json","provider": .provider}' "$nix_file" > "$temp_file" - if [ -s "$temp_file" ] && jq empty "$temp_file" 2>/dev/null; then - mv "$temp_file" "$target_file" - log "✓ ~/opencode.json created with $(jq '.provider | length' "$target_file") nix provider(s)" - else - rm -f "$temp_file" - echo "⚠ Failed to create ~/opencode.json" - fi - return 0 - fi - - if ! jq empty "$target_file" 2>/dev/null; then - echo "⚠ ~/opencode.json is invalid JSON — skipping provider merge" - return 1 - fi - - # Merge: inject nix providers only where the key is absent in user config - local temp_file - temp_file=$(mktemp) - jq -s ' - .[0] as $existing | - .[1].provider as $nix | - $existing | .provider = (($nix // {}) + ($existing.provider // {})) - ' "$target_file" "$nix_file" > "$temp_file" 2>/dev/null - if [ $? -eq 0 ] && [ -s "$temp_file" ] && jq empty "$temp_file" 2>/dev/null; then - mv "$temp_file" "$target_file" - log "✓ OpenCode providers merged into ~/opencode.json" - else - rm -f "$temp_file" - echo "⚠ Failed to merge OpenCode providers — keeping original" - fi -} - -merge_opencode_mcp() { - local target_file="$1" - local nix_file="/etc/opencode/nix-mcp-servers.json" - - [ -f "$nix_file" ] || return 0 - - if ! jq empty "$nix_file" 2>/dev/null; then - echo "⚠ nix-mcp-servers.json (OpenCode) is invalid JSON — skipping MCP merge" - return 1 - fi - - local backup_before_merge - backup_before_merge=$(jq -r '.backupBeforeMerge // true' "$nix_file") - - mkdir -p "$(dirname "$target_file")" - - if [ ! -f "$target_file" ]; then - log "Creating ~/.opencode.json with nix MCP servers" - local temp_file - temp_file=$(mktemp) - jq '{mcp: (.mcp // {})}' "$nix_file" > "$temp_file" - if [ -s "$temp_file" ] && jq empty "$temp_file" 2>/dev/null; then - mv "$temp_file" "$target_file" - log "✓ ~/.opencode.json created ($(jq '.mcp | length' "$target_file") server(s))" - else - rm -f "$temp_file" - echo "⚠ Failed to create ~/.opencode.json" - return 1 - fi - return 0 - fi - - if ! jq empty "$target_file" 2>/dev/null; then - local corrupt_bak="${target_file}.corrupt-$(date +%Y%m%d-%H%M%S)" - cp "$target_file" "$corrupt_bak" - log "⚠ ~/.opencode.json was corrupt — saved to $(basename "$corrupt_bak"), recreating" - local temp_file - temp_file=$(mktemp) - jq '{mcp: (.mcp // {})}' "$nix_file" > "$temp_file" - if [ -s "$temp_file" ] && jq empty "$temp_file" 2>/dev/null; then - mv "$temp_file" "$target_file" - else - rm -f "$temp_file" - return 1 - fi - return 0 - fi - - local backup_file="" - if [ "$backup_before_merge" = "true" ]; then - backup_file="${target_file}.backup-$(date +%Y%m%d-%H%M%S)" - cp "$target_file" "$backup_file" - log "✓ Created backup: $(basename "$backup_file")" - ls -t "${target_file}.backup-"* 2>/dev/null | tail -n +6 | xargs rm -f 2>/dev/null || true - fi - - local temp_file - temp_file=$(mktemp) - jq -s ' - .[0] as $existing | - .[1].mcp as $nix | - $existing | .mcp = (($existing.mcp // {}) + ($nix // {})) - ' "$target_file" "$nix_file" > "$temp_file" 2>/dev/null - if [ $? -eq 0 ] && [ -s "$temp_file" ] && jq empty "$temp_file" 2>/dev/null; then - mv "$temp_file" "$target_file" - log "✓ MCP servers merged into ~/.opencode.json ($(jq '.mcp | length' "$target_file") total)" - else - rm -f "$temp_file" - echo "⚠ Failed to merge MCP servers into ~/.opencode.json — keeping original" - if [ -n "$backup_file" ] && [ -f "$backup_file" ]; then - cp "$backup_file" "$target_file" - echo "✓ Restored from backup" - fi - return 1 - fi -} - -merge_codex_mcp() { - local target_file="$1" - local nix_file="/etc/codex/nix-mcp-servers.toml" - - [ -f "$nix_file" ] || return 0 - - if ! command -v python3 &>/dev/null; then - echo "⚠ python3 not available — skipping Codex MCP merge" - return 1 - fi - - if ! python3 -c "import tomllib; tomllib.load(open('$nix_file','rb'))" 2>/dev/null; then - echo "⚠ nix-mcp-servers.toml (Codex) is invalid TOML — skipping MCP merge" - return 1 - fi - - local backup_before_merge - backup_before_merge=$(python3 -c " -import tomllib, sys -with open('$nix_file', 'rb') as f: - d = tomllib.load(f) -print('true' if d.get('backupBeforeMerge', True) else 'false') -" 2>/dev/null || echo "true") - - mkdir -p "$(dirname "$target_file")" - - if [ -f "$target_file" ] && ! python3 -c "import tomllib; tomllib.load(open('$target_file','rb'))" 2>/dev/null; then - local corrupt_bak="${target_file}.corrupt-$(date +%Y%m%d-%H%M%S)" - cp "$target_file" "$corrupt_bak" - log "⚠ ~/.codex/config.toml was corrupt — saved to $(basename "$corrupt_bak"), recreating" - rm -f "$target_file" - fi - - local backup_file="" - if [ -f "$target_file" ] && [ "$backup_before_merge" = "true" ]; then - backup_file="${target_file}.backup-$(date +%Y%m%d-%H%M%S)" - cp "$target_file" "$backup_file" - log "✓ Created backup: $(basename "$backup_file")" - ls -t "${target_file}.backup-"* 2>/dev/null | tail -n +6 | xargs rm -f 2>/dev/null || true - fi - - local temp_file - temp_file=$(mktemp --suffix=.toml) - - python3 - "$nix_file" "$target_file" "$temp_file" 2>/dev/null << 'PYEOF' -import sys, tomllib, os - -nix_path, target_path, temp_path = sys.argv[1], sys.argv[2], sys.argv[3] - -def toml_val(v): - if isinstance(v, str): return f'"{v}"' - if isinstance(v, bool): return 'true' if v else 'false' - if isinstance(v, int): return str(v) - if isinstance(v, float): return repr(v) - if isinstance(v, list): return '[' + ', '.join(toml_val(x) for x in v) + ']' - raise ValueError(f"unsupported type: {type(v)}") - -def write_toml(data, out): - # Scalars first (skip internal keys and tables) - skip = {'mcp_servers', 'backupBeforeMerge'} - for k, v in data.items(): - if k not in skip and not isinstance(v, dict): - out.write(f'{k} = {toml_val(v)}\n') - # mcp_servers section - if 'mcp_servers' in data: - for srv, sdata in data['mcp_servers'].items(): - out.write(f'\n[mcp_servers.{srv}]\n') - for sk, sv in sdata.items(): - if not isinstance(sv, dict): - out.write(f'{sk} = {toml_val(sv)}\n') - for sk, sv in sdata.items(): - if isinstance(sv, dict): - out.write(f'\n[mcp_servers.{srv}.{sk}]\n') - for ek, ev in sv.items(): - out.write(f'{ek} = {toml_val(ev)}\n') - # Other tables - for k, v in data.items(): - if k not in skip and isinstance(v, dict): - out.write(f'\n[{k}]\n') - for sk, sv in v.items(): - if not isinstance(sv, dict): - out.write(f'{sk} = {toml_val(sv)}\n') - -with open(nix_path, 'rb') as f: - nix = tomllib.load(f) - -try: - with open(target_path, 'rb') as f: - existing = tomllib.load(f) -except FileNotFoundError: - existing = {} - -merged = dict(existing) -merged['mcp_servers'] = {**existing.get('mcp_servers', {}), **nix.get('mcp_servers', {})} - -with open(temp_path, 'w') as f: - write_toml(merged, f) - -print(f"merged {len(merged.get('mcp_servers', {}))} server(s)", file=sys.stderr) -PYEOF - - if [ $? -eq 0 ] && [ -s "$temp_file" ] && python3 -c "import tomllib; tomllib.load(open('$temp_file','rb'))" 2>/dev/null; then - mv "$temp_file" "$target_file" - log "✓ MCP servers merged into ~/.codex/config.toml" - else - rm -f "$temp_file" - echo "⚠ Failed to merge MCP servers into ~/.codex/config.toml — keeping original" - if [ -n "$backup_file" ] && [ -f "$backup_file" ]; then - cp "$backup_file" "$target_file" - echo "✓ Restored from backup" - fi - return 1 - fi -} - -# ── Merge nix MCP servers into user config files (additive, nix wins on conflict) ── -merge_claude_mcp "$HOME/.claude.json" -[ -f "$HOME/.claude.json" ] && chown "$HOST_USER" "$HOME/.claude.json" - -merge_opencode_providers "$HOME/opencode.json" -[ -f "$HOME/opencode.json" ] && chown "$HOST_USER" "$HOME/opencode.json" - -merge_opencode_mcp "$HOME/.opencode.json" -[ -f "$HOME/.opencode.json" ] && chown "$HOST_USER" "$HOME/.opencode.json" - -merge_codex_mcp "$HOME/.codex/config.toml" -[ -d "$HOME/.codex" ] && chown -R "$HOST_USER" "$HOME/.codex" - -# ── Module entrypoint fragments (nix-generated) ───────────────────────────── +# ── Source entrypoint fragments (nix-generated) ────────────────────────────── # Modules drop shell scripts into /etc/devcell/entrypoint.d/ via home-manager. # Each fragment guards its own preconditions (e.g. DEVCELL_GUI_ENABLED). +# +# Fragment numbering convention: +# 05-* — shell rc + nix profile setup +# 10-* — runtime setup (mise) +# 20-* — home directory setup (homedir, browser env) +# 30-* — tool config merges (claude, opencode, codex) +# 50-* — services (GUI, xrdp) if [ -d /etc/devcell/entrypoint.d ]; then for f in /etc/devcell/entrypoint.d/*.sh; do - [ -x "$f" ] && . "$f" + [ -x "$f" ] && { . "$f" || log "⚠ Fragment failed: $f (exit $?)"; } done fi -export CHROMIUM_PROFILE_PATH="${HOME}/.chrome-${APP_NAME:-cell}" -export PLAYWRIGHT_MCP_USER_DATA_DIR="${HOME}/.playwright-${APP_NAME:-cell}" - -# Ensure MISE_DATA_DIR and shims are correct for exec'd processes (e.g. claude) -# that don't source shell rc files and would otherwise inherit the container ENV -# which still points at the ephemeral /opt/mise. -export MISE_DATA_DIR="${HOME}/.local/share/mise" -export PATH="${HOME}/.local/share/mise/shims:${PATH}" +# ── Fix ownership for files created by fragments ────────────────────────────── +# Fragments run as root and may create files in $HOME without chown. +# Catch any missed files (max depth 2 to avoid expensive deep traversal). +find "$HOME" -maxdepth 2 -user root -not -path "*/tmp/*" -exec chown "$HOST_USER" {} + 2>/dev/null || true log "Entrypoint ready — exec $*" diff --git a/internal/cfg/cfg.go b/internal/cfg/cfg.go index e7c3b1ab..92425f31 100644 --- a/internal/cfg/cfg.go +++ b/internal/cfg/cfg.go @@ -10,6 +10,7 @@ import ( type CellSection struct { ImageTag string `toml:"image_tag"` GUI bool `toml:"gui"` + Timezone string `toml:"timezone"` // IANA tz (e.g. "Europe/Prague"); default: host $TZ } // VolumeMount holds a single [[volumes]] entry. @@ -23,22 +24,23 @@ type PackagesSection struct { Python map[string]string `toml:"python"` } -// LLMProvider holds a single provider entry under [models.providers.]. +// LLMProvider holds a single provider entry under [llm.models.providers.]. type LLMProvider struct { BaseURL string `toml:"base_url"` Models []string `toml:"models"` } -// ModelsSection holds [models] config — generic LLM provider/model declarations -// denormalized into agent-specific config files (opencode.json, etc.) at scaffold time. -type ModelsSection struct { - Default string `toml:"default"` - Providers map[string]LLMProvider `toml:"providers"` +// LLMModelsSection holds [llm.models] config — provider/model declarations. +type LLMModelsSection struct { + Default string `toml:"default"` + Providers map[string]LLMProvider `toml:"providers"` } -// ClaudeSection holds [claude] config. -type ClaudeSection struct { - UseOllama bool `toml:"use_ollama"` +// LLMSection holds [llm] config — all AI agent settings in one place. +type LLMSection struct { + SystemPrompt string `toml:"system_prompt"` + UseOllama bool `toml:"use_ollama"` + Models LLMModelsSection `toml:"models"` } // GitSection holds [git] config for git identity inside the container. @@ -71,16 +73,21 @@ func (g GitSection) ResolvedCommitterEmail() string { return g.AuthorEmail } +// OpSection holds [op] config for 1Password secret injection. +type OpSection struct { + Items []string `toml:"items"` // 1Password item names to resolve via `op item get` +} + // CellConfig is the merged configuration from all TOML layers. type CellConfig struct { Cell CellSection - Claude ClaudeSection + LLM LLMSection `toml:"llm"` Git GitSection `toml:"git"` + Op OpSection `toml:"op"` Env map[string]string Mise map[string]string `toml:"mise"` // [mise] — keys map to MISE_ env vars Volumes []VolumeMount Packages PackagesSection - Models ModelsSection `toml:"models"` } // LoadFile parses a TOML file into CellConfig. @@ -133,11 +140,17 @@ func Merge(global, project CellConfig) CellConfig { if project.Cell.GUI { out.Cell.GUI = true } + if project.Cell.Timezone != "" { + out.Cell.Timezone = project.Cell.Timezone + } - // Claude: project wins when true - out.Claude = global.Claude - if project.Claude.UseOllama { - out.Claude.UseOllama = true + // LLM: project wins for scalars, providers accumulate + out.LLM = global.LLM + if project.LLM.SystemPrompt != "" { + out.LLM.SystemPrompt = project.LLM.SystemPrompt + } + if project.LLM.UseOllama { + out.LLM.UseOllama = true } // Git: project wins when non-zero @@ -155,21 +168,32 @@ func Merge(global, project CellConfig) CellConfig { out.Git.CommitterEmail = project.Git.CommitterEmail } + // Op items: accumulate, project appended after global (deduped) + seen := make(map[string]bool, len(global.Op.Items)) + for _, item := range global.Op.Items { + out.Op.Items = append(out.Op.Items, item) + seen[item] = true + } + for _, item := range project.Op.Items { + if !seen[item] { + out.Op.Items = append(out.Op.Items, item) + } + } + // Slices accumulate: global first, then project out.Volumes = append(global.Volumes, project.Volumes...) - // Models: project default wins, providers accumulate (project wins on key conflict) - out.Models = global.Models - if project.Models.Default != "" { - out.Models.Default = project.Models.Default + // LLM models: project default wins, providers accumulate (project wins on key conflict) + if project.LLM.Models.Default != "" { + out.LLM.Models.Default = project.LLM.Models.Default } - if len(global.Models.Providers) > 0 || len(project.Models.Providers) > 0 { - out.Models.Providers = make(map[string]LLMProvider) - for k, v := range global.Models.Providers { - out.Models.Providers[k] = v + if len(global.LLM.Models.Providers) > 0 || len(project.LLM.Models.Providers) > 0 { + out.LLM.Models.Providers = make(map[string]LLMProvider) + for k, v := range global.LLM.Models.Providers { + out.LLM.Models.Providers[k] = v } - for k, v := range project.Models.Providers { - out.Models.Providers[k] = v + for k, v := range project.LLM.Models.Providers { + out.LLM.Models.Providers[k] = v } } diff --git a/internal/cfg/cfg_test.go b/internal/cfg/cfg_test.go index bad74a4a..c51f2b01 100644 --- a/internal/cfg/cfg_test.go +++ b/internal/cfg/cfg_test.go @@ -3,6 +3,7 @@ package cfg_test import ( "os" "path/filepath" + "strings" "testing" "github.com/DimmKirr/devcell/internal/cfg" @@ -243,18 +244,22 @@ mount = "~/work/secrets:/run/secrets:ro" } } -// --- Models section --- +// --- LLM section (replaces [claude] + [models]) --- -func TestLoadFile_ModelsSection(t *testing.T) { +func TestLoadFile_LLMSection(t *testing.T) { dir := t.TempDir() writeTOML(t, dir, "devcell.toml", ` -[models] +[llm] +use_ollama = true +system_prompt = "This project uses Go 1.22." + +[llm.models] default = "ollama/deepseek-r1:32b" -[models.providers.ollama] +[llm.models.providers.ollama] models = ["deepseek-r1:32b", "qwen3:8b"] -[models.providers.lmstudio] +[llm.models.providers.lmstudio] base_url = "http://host.docker.internal:1235/v1" models = ["deepseek-r1:32b"] `) @@ -262,10 +267,16 @@ models = ["deepseek-r1:32b"] if err != nil { t.Fatal(err) } - if c.Models.Default != "ollama/deepseek-r1:32b" { - t.Errorf("default: want ollama/deepseek-r1:32b, got %q", c.Models.Default) + if !c.LLM.UseOllama { + t.Error("expected UseOllama=true") + } + if c.LLM.SystemPrompt != "This project uses Go 1.22." { + t.Errorf("system_prompt: got %q", c.LLM.SystemPrompt) } - ollama, ok := c.Models.Providers["ollama"] + if c.LLM.Models.Default != "ollama/deepseek-r1:32b" { + t.Errorf("default: want ollama/deepseek-r1:32b, got %q", c.LLM.Models.Default) + } + ollama, ok := c.LLM.Models.Providers["ollama"] if !ok { t.Fatal("ollama provider not found") } @@ -275,7 +286,7 @@ models = ["deepseek-r1:32b"] if ollama.BaseURL != "" { t.Errorf("ollama base_url should be empty (use default), got %q", ollama.BaseURL) } - lms, ok := c.Models.Providers["lmstudio"] + lms, ok := c.LLM.Models.Providers["lmstudio"] if !ok { t.Fatal("lmstudio provider not found") } @@ -287,98 +298,125 @@ models = ["deepseek-r1:32b"] } } -func TestLoadFile_ModelsEmpty(t *testing.T) { - dir := t.TempDir() - writeTOML(t, dir, "devcell.toml", `[cell]`) - c, err := cfg.LoadFile(filepath.Join(dir, "devcell.toml")) - if err != nil { - t.Fatal(err) - } - if c.Models.Default != "" { - t.Errorf("expected empty default, got %q", c.Models.Default) - } - if len(c.Models.Providers) != 0 { - t.Errorf("expected no providers, got %v", c.Models.Providers) - } -} - -func TestMerge_ModelsProjectWins(t *testing.T) { - global := cfg.CellConfig{ - Models: cfg.ModelsSection{ - Default: "ollama/qwen3:8b", - Providers: map[string]cfg.LLMProvider{ - "ollama": {Models: []string{"qwen3:8b"}}, - }, - }, - } - project := cfg.CellConfig{ - Models: cfg.ModelsSection{ - Default: "ollama/deepseek-r1:32b", - Providers: map[string]cfg.LLMProvider{ - "ollama": {Models: []string{"deepseek-r1:32b"}}, - "lmstudio": {Models: []string{"deepseek-r1:32b"}}, - }, - }, - } - merged := cfg.Merge(global, project) - if merged.Models.Default != "ollama/deepseek-r1:32b" { - t.Errorf("default: project should win, got %q", merged.Models.Default) - } - if len(merged.Models.Providers) != 2 { - t.Errorf("want 2 providers, got %d", len(merged.Models.Providers)) - } - if merged.Models.Providers["ollama"].Models[0] != "deepseek-r1:32b" { - t.Errorf("ollama models should be project's, got %v", merged.Models.Providers["ollama"].Models) - } -} - -// --- Claude section --- - -func TestLoadFile_ClaudeUseOllama(t *testing.T) { +func TestLoadFile_LLMMultilineSystemPrompt(t *testing.T) { dir := t.TempDir() writeTOML(t, dir, "devcell.toml", ` -[claude] -use_ollama = true +[llm] +system_prompt = """ +This project uses PostgreSQL 16 with pgx/v5. +API endpoints follow REST conventions at /api/v2/. +""" `) c, err := cfg.LoadFile(filepath.Join(dir, "devcell.toml")) if err != nil { t.Fatal(err) } - if !c.Claude.UseOllama { - t.Error("expected UseOllama=true after parsing use_ollama=true") + if c.LLM.SystemPrompt == "" { + t.Fatal("expected non-empty system_prompt") + } + if !contains(c.LLM.SystemPrompt, "PostgreSQL 16") { + t.Errorf("system_prompt missing PostgreSQL 16: %q", c.LLM.SystemPrompt) + } + if !contains(c.LLM.SystemPrompt, "/api/v2/") { + t.Errorf("system_prompt missing /api/v2/: %q", c.LLM.SystemPrompt) } } -func TestLoadFile_ClaudeDefaultsFalse(t *testing.T) { +func TestLoadFile_LLMDefaultsEmpty(t *testing.T) { dir := t.TempDir() writeTOML(t, dir, "devcell.toml", `[cell]`) c, err := cfg.LoadFile(filepath.Join(dir, "devcell.toml")) if err != nil { t.Fatal(err) } - if c.Claude.UseOllama { + if c.LLM.UseOllama { t.Error("expected UseOllama=false when not set") } + if c.LLM.SystemPrompt != "" { + t.Errorf("expected empty system_prompt, got %q", c.LLM.SystemPrompt) + } + if c.LLM.Models.Default != "" { + t.Errorf("expected empty default, got %q", c.LLM.Models.Default) + } + if len(c.LLM.Models.Providers) != 0 { + t.Errorf("expected no providers, got %v", c.LLM.Models.Providers) + } } -func TestMerge_ClaudeProjectEnablesOverGlobal(t *testing.T) { - global := cfg.CellConfig{Claude: cfg.ClaudeSection{UseOllama: false}} - project := cfg.CellConfig{Claude: cfg.ClaudeSection{UseOllama: true}} +func TestMerge_LLMUseOllamaProjectWins(t *testing.T) { + global := cfg.CellConfig{LLM: cfg.LLMSection{UseOllama: false}} + project := cfg.CellConfig{LLM: cfg.LLMSection{UseOllama: true}} merged := cfg.Merge(global, project) - if !merged.Claude.UseOllama { + if !merged.LLM.UseOllama { t.Error("expected project use_ollama=true to win over global false") } } -func TestMerge_ClaudeGlobalKeptWhenProjectUnset(t *testing.T) { - global := cfg.CellConfig{Claude: cfg.ClaudeSection{UseOllama: true}} +func TestMerge_LLMGlobalKeptWhenProjectUnset(t *testing.T) { + global := cfg.CellConfig{LLM: cfg.LLMSection{UseOllama: true}} project := cfg.CellConfig{} merged := cfg.Merge(global, project) - if !merged.Claude.UseOllama { + if !merged.LLM.UseOllama { t.Error("expected global use_ollama=true to be preserved when project unset") } } +func TestMerge_LLMSystemPromptProjectReplaces(t *testing.T) { + global := cfg.CellConfig{LLM: cfg.LLMSection{SystemPrompt: "global context"}} + project := cfg.CellConfig{LLM: cfg.LLMSection{SystemPrompt: "project context"}} + merged := cfg.Merge(global, project) + if merged.LLM.SystemPrompt != "project context" { + t.Errorf("want project context, got %q", merged.LLM.SystemPrompt) + } +} + +func TestMerge_LLMSystemPromptGlobalKeptWhenProjectEmpty(t *testing.T) { + global := cfg.CellConfig{LLM: cfg.LLMSection{SystemPrompt: "global context"}} + project := cfg.CellConfig{} + merged := cfg.Merge(global, project) + if merged.LLM.SystemPrompt != "global context" { + t.Errorf("want global context, got %q", merged.LLM.SystemPrompt) + } +} + +func TestMerge_LLMModelsProjectWins(t *testing.T) { + global := cfg.CellConfig{ + LLM: cfg.LLMSection{ + Models: cfg.LLMModelsSection{ + Default: "ollama/qwen3:8b", + Providers: map[string]cfg.LLMProvider{ + "ollama": {Models: []string{"qwen3:8b"}}, + }, + }, + }, + } + project := cfg.CellConfig{ + LLM: cfg.LLMSection{ + Models: cfg.LLMModelsSection{ + Default: "ollama/deepseek-r1:32b", + Providers: map[string]cfg.LLMProvider{ + "ollama": {Models: []string{"deepseek-r1:32b"}}, + "lmstudio": {Models: []string{"deepseek-r1:32b"}}, + }, + }, + }, + } + merged := cfg.Merge(global, project) + if merged.LLM.Models.Default != "ollama/deepseek-r1:32b" { + t.Errorf("default: project should win, got %q", merged.LLM.Models.Default) + } + if len(merged.LLM.Models.Providers) != 2 { + t.Errorf("want 2 providers, got %d", len(merged.LLM.Models.Providers)) + } + if merged.LLM.Models.Providers["ollama"].Models[0] != "deepseek-r1:32b" { + t.Errorf("ollama models should be project's, got %v", merged.LLM.Models.Providers["ollama"].Models) + } +} + +func contains(s, sub string) bool { + return len(s) >= len(sub) && len(sub) > 0 && strings.Contains(s, sub) +} + // --- Git section --- func TestLoadFile_GitSection(t *testing.T) { @@ -472,3 +510,50 @@ func TestGitSection_ExplicitCommitterOverridesAuthor(t *testing.T) { t.Errorf("want bot@ci.com, got %q", g.ResolvedCommitterEmail()) } } + +// --- Op section --- + +func TestLoadFile_OpSection(t *testing.T) { + dir := t.TempDir() + writeTOML(t, dir, "devcell.toml", ` +[op] +items = ["prod-nmd-trips", "dev-api-keys"] +`) + c, err := cfg.LoadFile(filepath.Join(dir, "devcell.toml")) + if err != nil { + t.Fatal(err) + } + if len(c.Op.Items) != 2 { + t.Fatalf("want 2 op items, got %d", len(c.Op.Items)) + } + if c.Op.Items[0] != "prod-nmd-trips" || c.Op.Items[1] != "dev-api-keys" { + t.Errorf("unexpected op items: %v", c.Op.Items) + } +} + +func TestLoadFile_OpDefaultsEmpty(t *testing.T) { + dir := t.TempDir() + writeTOML(t, dir, "devcell.toml", `[cell]`) + c, err := cfg.LoadFile(filepath.Join(dir, "devcell.toml")) + if err != nil { + t.Fatal(err) + } + if len(c.Op.Items) != 0 { + t.Errorf("expected no op items when [op] not set, got %v", c.Op.Items) + } +} + +func TestMerge_OpItemsAccumulateDeduped(t *testing.T) { + global := cfg.CellConfig{Op: cfg.OpSection{Items: []string{"shared-keys", "global-only"}}} + project := cfg.CellConfig{Op: cfg.OpSection{Items: []string{"shared-keys", "project-only"}}} + merged := cfg.Merge(global, project) + want := []string{"shared-keys", "global-only", "project-only"} + if len(merged.Op.Items) != len(want) { + t.Fatalf("want %v, got %v", want, merged.Op.Items) + } + for i, w := range want { + if merged.Op.Items[i] != w { + t.Errorf("item[%d]: want %q, got %q", i, w, merged.Op.Items[i]) + } + } +} diff --git a/internal/config/config.go b/internal/config/config.go index a108e80a..21dbe4be 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -2,8 +2,10 @@ package config import ( "fmt" + "net" "os" "path/filepath" + "strconv" "strings" ) @@ -81,6 +83,9 @@ func resolveCellID(getenv func(string) string) string { } func resolveSessionName(getenv func(string) string) string { + if s := getenv("DEVCELL_SESSION_NAME"); s != "" { + return s + } if s := getenv("TMUX_SESSION_NAME"); s != "" { return s } @@ -97,3 +102,39 @@ func resolveConfigDir(getenv func(string) string) string { } return getenv("HOME") + "/.config/devcell" } + +// ResolveAvailablePorts checks whether VNCPort and RDPPort are free and +// replaces them with nearby available ports when they are already bound. +func (c *Config) ResolveAvailablePorts() { + c.VNCPort = resolveAvailablePort(c.VNCPort) + c.RDPPort = resolveAvailablePort(c.RDPPort) +} + +// resolveAvailablePort returns preferred if it's free, otherwise scans +// upward (up to 100 attempts) for the next available port. +func resolveAvailablePort(preferred string) string { + port, err := strconv.Atoi(preferred) + if err != nil { + return preferred + } + for i := 0; i < 100; i++ { + candidate := port + i + if candidate > 65535 { + break + } + if isPortAvailable(candidate) { + return strconv.Itoa(candidate) + } + } + return preferred +} + +// isPortAvailable reports whether a TCP port can be bound on all interfaces. +func isPortAvailable(port int) bool { + ln, err := net.Listen("tcp", ":"+strconv.Itoa(port)) + if err != nil { + return false + } + ln.Close() + return true +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 17bb0449..0472144e 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1,6 +1,7 @@ package config_test import ( + "net" "strconv" "strings" "testing" @@ -72,6 +73,20 @@ func TestAppName_WithSpaces(t *testing.T) { // --- SessionName / CellHome --- +func TestCellHome_WithDevcellSessionName(t *testing.T) { + c := config.Load("/cwd", env("DEVCELL_SESSION_NAME", "myproject", "HOME", "/home/bob")) + if c.CellHome != "/home/bob/.devcell/myproject" { + t.Errorf("want /home/bob/.devcell/myproject, got %q", c.CellHome) + } +} + +func TestCellHome_DevcellSessionOverridesTmux(t *testing.T) { + c := config.Load("/cwd", env("DEVCELL_SESSION_NAME", "override", "TMUX_SESSION_NAME", "work", "HOME", "/home/bob")) + if c.CellHome != "/home/bob/.devcell/override" { + t.Errorf("want /home/bob/.devcell/override, got %q", c.CellHome) + } +} + func TestCellHome_WithTmuxSession(t *testing.T) { c := config.Load("/cwd", env("TMUX_SESSION_NAME", "work", "HOME", "/home/bob")) if c.CellHome != "/home/bob/.devcell/work" { @@ -167,6 +182,37 @@ func TestImage_Default(t *testing.T) { } } +// --- ResolveAvailablePorts --- + +func TestResolveAvailablePorts_FreePortUnchanged(t *testing.T) { + c := config.Load("/cwd", env("CELL_ID", "3")) + orig := c.VNCPort + c.ResolveAvailablePorts() + // Port 350 is almost certainly free in test — should stay the same + if c.VNCPort != orig { + t.Errorf("free port should not change: want %s, got %s", orig, c.VNCPort) + } +} + +func TestResolveAvailablePorts_OccupiedPortBumps(t *testing.T) { + c := config.Load("/cwd", env("CELL_ID", "3")) + // Occupy the preferred VNC port + ln, err := net.Listen("tcp", ":"+c.VNCPort) + if err != nil { + t.Skipf("cannot bind port %s: %v", c.VNCPort, err) + } + defer ln.Close() + + c.ResolveAvailablePorts() + if c.VNCPort == "350" { + t.Error("VNCPort should have been bumped away from occupied 350") + } + port, err := strconv.Atoi(c.VNCPort) + if err != nil || port <= 350 { + t.Errorf("VNCPort should be > 350, got %q", c.VNCPort) + } +} + // --- Purity --- func TestLoad_Idempotent(t *testing.T) { diff --git a/internal/ollama/ollama.go b/internal/ollama/ollama.go index 49c9fc2b..86c65035 100644 --- a/internal/ollama/ollama.go +++ b/internal/ollama/ollama.go @@ -182,9 +182,9 @@ func FormatActiveTOMLSnippet(ranked []RankedModel) string { } var b strings.Builder - b.WriteString("[models]\n") + b.WriteString("[llm.models]\n") b.WriteString(fmt.Sprintf("default = \"ollama/%s\"\n", ranked[0].Name)) - b.WriteString("\n[models.providers.ollama]\n") + b.WriteString("\n[llm.models.providers.ollama]\n") b.WriteString(fmt.Sprintf("models = [%s]\n", strings.Join(names, ", "))) return b.String() diff --git a/internal/ollama/ollama_test.go b/internal/ollama/ollama_test.go index abf3547f..9193776d 100644 --- a/internal/ollama/ollama_test.go +++ b/internal/ollama/ollama_test.go @@ -324,13 +324,13 @@ func TestFormatActiveTOMLSnippet_ProducesUncommentedConfig(t *testing.T) { if !contains(snippet, "deepseek-r1:70b") || !contains(snippet, "qwen3:32b") { t.Error("expected both models in snippet") } - // Should have [models] header - if !contains(snippet, "[models]") { - t.Error("expected [models] section header") + // Should have [llm.models] header + if !contains(snippet, "[llm.models]") { + t.Error("expected [llm.models] section header") } - // Should have [models.providers.ollama] header - if !contains(snippet, "[models.providers.ollama]") { - t.Error("expected [models.providers.ollama] section header") + // Should have [llm.models.providers.ollama] header + if !contains(snippet, "[llm.models.providers.ollama]") { + t.Error("expected [llm.models.providers.ollama] section header") } } diff --git a/internal/op/resolve.go b/internal/op/resolve.go new file mode 100644 index 00000000..acb5eec2 --- /dev/null +++ b/internal/op/resolve.go @@ -0,0 +1,41 @@ +package op + +import ( + "encoding/json" + "fmt" + "os/exec" +) + +// field is the structure of a single field in `op item get --format json` output. +type field struct { + Label string `json:"label"` + Value string `json:"value"` +} + +// item is the top-level JSON structure from `op item get --format json`. +type item struct { + Fields []field `json:"fields"` +} + +// ResolveItems calls `op item get` for each item name and returns a merged +// map of label→value for all fields that have both a label and a value. +// Items later in the slice win on key conflict. +func ResolveItems(items []string) (map[string]string, error) { + env := make(map[string]string) + for _, name := range items { + out, err := exec.Command("op", "item", "get", name, "--format", "json", "--reveal", "--cache").Output() + if err != nil { + return nil, fmt.Errorf("op item get %s: %w", name, err) + } + var it item + if err := json.Unmarshal(out, &it); err != nil { + return nil, fmt.Errorf("op item get %s: parse JSON: %w", name, err) + } + for _, f := range it.Fields { + if f.Label != "" && f.Value != "" { + env[f.Label] = f.Value + } + } + } + return env, nil +} diff --git a/internal/rdp/rdp.go b/internal/rdp/rdp.go index 7ff739e7..82a8be83 100644 --- a/internal/rdp/rdp.go +++ b/internal/rdp/rdp.go @@ -44,8 +44,8 @@ func RDPUrl(port string) string { } // RoyalTSXUrl returns a Royal TSX URI for the given port and credentials. -// macOS format: rtsx://rdp://user:pass@host:port -// (query string params not supported on macOS) +// Note: macOS Royal TSX does not support property_* query params in adhoc URIs. +// Retina must be enabled via Application → Default Settings → Remote Desktop → Display. func RoyalTSXUrl(port, user, password string) string { return "rtsx://rdp://" + user + ":" + password + "@127.0.0.1:" + port } diff --git a/internal/runner/runner.go b/internal/runner/runner.go index 9d3c42d9..86e46ccd 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -13,6 +13,7 @@ import ( "github.com/DimmKirr/devcell/internal/cfg" "github.com/DimmKirr/devcell/internal/config" + "github.com/DimmKirr/devcell/internal/version" ) const ( @@ -64,9 +65,10 @@ type RunSpec struct { Binary string DefaultFlags []string UserArgs []string - Debug bool // pass DEVCELL_DEBUG=true into the container - Image string // image ID or tag to run; defaults to UserImageTag - ExtraEnv map[string]string // additional env vars injected by the command handler + Debug bool // pass DEVCELL_DEBUG=true into the container + Image string // image ID or tag to run; defaults to UserImageTag + ExtraEnv map[string]string // additional env vars injected by the command handler + InheritEnv []string // env var names to inherit from host (passed as -e KEY with no value) Getenv func(string) string // env lookup; defaults to os.Getenv when nil } @@ -134,24 +136,17 @@ func BuildArgv(spec RunSpec, fs FS, lookPath func(string) (string, error)) []str e("GIT_COMMITTER_NAME", gitCfg.ResolvedCommitterName()) e("GIT_COMMITTER_EMAIL", gitCfg.ResolvedCommitterEmail()) } else { - gitConfigDir := filepath.Join(c.HostHome, ".config", "git") - gitConfigFile := filepath.Join(gitConfigDir, "config") - if err := fs.Stat(gitConfigFile); err == nil { - v(gitConfigDir + ":/etc/devcell/git:ro") - e("GIT_CONFIG_GLOBAL", "/etc/devcell/git/config") - } else { - e("GIT_AUTHOR_NAME", "DevCell") - e("GIT_AUTHOR_EMAIL", "devcell@devcell.io") - e("GIT_COMMITTER_NAME", "DevCell") - e("GIT_COMMITTER_EMAIL", "devcell@devcell.io") - } + e("GIT_AUTHOR_NAME", "DevCell") + e("GIT_AUTHOR_EMAIL", "devcell@devcell.io") + e("GIT_COMMITTER_NAME", "DevCell") + e("GIT_COMMITTER_EMAIL", "devcell@devcell.io") } // Optional .env file — resolve self-referencing vars (KEY=${KEY}) by passing // -e KEY so Docker inherits the real value from the host environment. // Literal KEY=value lines are passed as-is via -e KEY=value. // Comments and blank lines are skipped. - envFile := filepath.Join(c.BaseDir, ".env") + envFile := filepath.Join(c.BaseDir, ".env.devcell") if envData, err := os.ReadFile(envFile); err == nil { for _, line := range strings.Split(string(envData), "\n") { line = strings.TrimSpace(line) @@ -180,6 +175,13 @@ func BuildArgv(spec RunSpec, fs FS, lookPath func(string) (string, error)) []str argv = append(argv, "-e", "DEVCELL_DEBUG=true") } + // Timezone: config wins, then host $TZ + if tz := spec.CellCfg.Cell.Timezone; tz != "" { + argv = append(argv, "-e", "TZ="+tz) + } else if tz := os.Getenv("TZ"); tz != "" { + argv = append(argv, "-e", "TZ="+tz) + } + // cfg [env] entries for k, v := range spec.CellCfg.Env { argv = append(argv, "-e", k+"="+v) @@ -195,6 +197,11 @@ func BuildArgv(spec RunSpec, fs FS, lookPath func(string) (string, error)) []str argv = append(argv, "-e", k+"="+v) } + // Inherit env vars from host (secrets resolved by caller, set via os.Setenv) + for _, k := range spec.InheritEnv { + argv = append(argv, "-e", k) + } + // Standard volumes v(c.BaseDir + ":" + c.BaseDir) v(c.BaseDir + ":/" + c.AppName) @@ -275,7 +282,9 @@ func BuildImage(ctx context.Context, configDir string, noCache bool, verbose boo if verbose { progress = "--progress=plain" } - args := []string{"build", "-t", UserImageTag(), progress} + args := []string{"build", "-t", UserImageTag(), progress, + "--build-arg", "GIT_COMMIT=" + version.GitCommit, + } if noCache { args = append(args, "--no-cache", "--build-arg", "NIX_REFRESH=--refresh") } @@ -353,6 +362,64 @@ func LocalImageID(ctx context.Context) (string, error) { return strings.TrimSpace(string(out)), nil } +// ImageVersions reads /etc/devcell/base-image-version and user-image-version +// from the user image. Returns (base, user) strings; empty string if file is missing. +func ImageVersions(ctx context.Context) (base, user string) { + out, err := exec.CommandContext(ctx, "docker", "run", "--rm", "--entrypoint", "sh", + UserImageTag(), "-c", + "cat /etc/devcell/base-image-version 2>/dev/null; echo '---'; cat /etc/devcell/user-image-version 2>/dev/null", + ).Output() + if err != nil { + return "", "" + } + parts := strings.SplitN(strings.TrimSpace(string(out)), "---", 2) + if len(parts) == 2 { + base = strings.TrimSpace(parts[0]) + user = strings.TrimSpace(parts[1]) + } + return +} + +// UpdateFlakeLock runs nix flake lock (or update) inside a temp base container +// with configDir bind-mounted. When lockOnly is true, runs "nix flake lock" +// (resolves inputs, generates lock if missing, doesn't update existing pins). +// When lockOnly is false, runs "nix flake update" (pulls latest for all inputs). +func UpdateFlakeLock(ctx context.Context, configDir string, lockOnly bool, verbose bool, out io.Writer) error { + nixCmd := "nix flake update" + if lockOnly { + nixCmd = "nix flake lock" + } + args := []string{ + "run", "--rm", + "-v", configDir + ":/opt/devcell/.config/devcell", + "--entrypoint", "sh", + BaseImageTag(), + "-c", "cd /opt/devcell/.config/devcell && " + nixCmd, + } + cmd := exec.CommandContext(ctx, "docker", args...) + cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} + cmd.Cancel = func() error { + if cmd.Process != nil { + return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) + } + return nil + } + if verbose { + cmd.Stdout = out + cmd.Stderr = out + } else { + cmd.Stdout = io.Discard + cmd.Stderr = out + } + if err := cmd.Run(); err != nil { + if ctx.Err() != nil { + return fmt.Errorf("nix flake: interrupted") + } + return fmt.Errorf("nix flake: %w", err) + } + return nil +} + func envOrDefault(key, def string) string { if v := os.Getenv(key); v != "" { return v diff --git a/internal/runner/runner_test.go b/internal/runner/runner_test.go index 7ec99516..6746e6f0 100644 --- a/internal/runner/runner_test.go +++ b/internal/runner/runner_test.go @@ -165,12 +165,16 @@ func TestArgv_Labels(t *testing.T) { func TestArgv_EnvFileSelfRef(t *testing.T) { dir := t.TempDir() - envFile := filepath.Join(dir, ".env") + envFile := filepath.Join(dir, ".env.devcell") os.WriteFile(envFile, []byte("# comment\nMY_SECRET=${MY_SECRET}\nLITERAL=hello\n"), 0644) spec := runner.RunSpec{ Config: config.Load(dir, func(k string) string { - if k == "USER" { return "bob" } - if k == "HOME" { return "/home/bob" } + if k == "USER" { + return "bob" + } + if k == "HOME" { + return "/home/bob" + } return "" }), CellCfg: cfg.CellConfig{}, @@ -194,7 +198,31 @@ func TestArgv_EnvFileSelfRef(t *testing.T) { func TestArgv_EnvFileAbsent(t *testing.T) { argv := buildArgv(t) if hasArg(argv, "--env-file") { - t.Error("--env-file should not be present when .env does not exist") + t.Error("--env-file should not be present when .env.devcell does not exist") + } +} + +// --- InheritEnv --- + +func TestArgv_InheritEnv(t *testing.T) { + spec := runner.RunSpec{ + Config: baseConfig(), + CellCfg: cfg.CellConfig{}, + Binary: "bash", + InheritEnv: []string{"SECRET_A", "SECRET_B"}, + } + argv := runner.BuildArgv(spec, noopFS(), noopLookPath) + if !hasConsecutive(argv, "-e", "SECRET_A") { + t.Errorf("expected -e SECRET_A (inherit) in argv: %v", argv) + } + if !hasConsecutive(argv, "-e", "SECRET_B") { + t.Errorf("expected -e SECRET_B (inherit) in argv: %v", argv) + } + // Values should NOT appear in argv (security: no secrets in ps aux) + for _, a := range argv { + if a == "SECRET_A=" || a == "SECRET_B=" { + t.Errorf("secret value should not appear in argv: %v", argv) + } } } @@ -202,9 +230,9 @@ func TestArgv_EnvFileAbsent(t *testing.T) { func TestArgv_OpPrefixWhenOpFound(t *testing.T) { spec := runner.RunSpec{ - Config: baseConfig(), - CellCfg: cfg.CellConfig{}, - Binary: "claude", + Config: baseConfig(), + CellCfg: cfg.CellConfig{}, + Binary: "claude", DefaultFlags: []string{"--dangerously-skip-permissions"}, } argv := runner.BuildArgv(spec, noopFS(), opLookPath) @@ -382,27 +410,23 @@ func TestArgv_GitEnvVarsFromToml(t *testing.T) { } } -func TestArgv_GitConfigMounted(t *testing.T) { - // fs.Stat checks the file; mount is the parent directory (avoids Docker mkdir-file bug) - gitConfigFile := "/home/bob/.config/git/config" - spec := runner.RunSpec{ - Config: baseConfig(), - CellCfg: cfg.CellConfig{}, - Binary: "bash", - Getenv: func(string) string { return "" }, - } - argv := runner.BuildArgv(spec, existFS(gitConfigFile), noopLookPath) - expectedMount := "/home/bob/.config/git:/etc/devcell/git:ro" - if !hasConsecutive(argv, "-v", expectedMount) { - t.Errorf("expected git config dir mount %q: %v", expectedMount, argv) - } - if !hasArg(argv, "GIT_CONFIG_GLOBAL=/etc/devcell/git/config") { - t.Errorf("expected GIT_CONFIG_GLOBAL env var: %v", argv) - } - for _, a := range argv { - if strings.HasPrefix(a, "GIT_AUTHOR_NAME=") { - t.Errorf("should not inject GIT_AUTHOR_NAME when mounting config: %v", argv) +func TestArgv_GitExtraEnvOverridesDefaults(t *testing.T) { + // Git identity resolved by cmd/root.go is passed via ExtraEnv; + // it should override the hardcoded "DevCell" defaults. + argv := buildArgv(t, func(s *runner.RunSpec) { + s.Getenv = func(string) string { return "" } + s.ExtraEnv = map[string]string{ + "GIT_AUTHOR_NAME": "Alice", + "GIT_AUTHOR_EMAIL": "alice@test.com", + "GIT_COMMITTER_NAME": "Alice", + "GIT_COMMITTER_EMAIL": "alice@test.com", } + }) + if !hasArg(argv, "GIT_AUTHOR_NAME=Alice") { + t.Errorf("expected ExtraEnv GIT_AUTHOR_NAME=Alice: %v", argv) + } + if !hasArg(argv, "GIT_AUTHOR_EMAIL=alice@test.com") { + t.Errorf("expected ExtraEnv GIT_AUTHOR_EMAIL=alice@test.com: %v", argv) } } diff --git a/internal/runner/systemprompt.go b/internal/runner/systemprompt.go new file mode 100644 index 00000000..cad2d012 --- /dev/null +++ b/internal/runner/systemprompt.go @@ -0,0 +1,78 @@ +package runner + +import ( + "fmt" + "strings" + + "github.com/DimmKirr/devcell/internal/cfg" + "github.com/DimmKirr/devcell/internal/config" +) + +// BuildSystemPrompt generates the --append-system-prompt content for Claude Code. +// It describes the container environment, bind mounts, and host path mappings +// so Claude understands its runtime context. +func BuildSystemPrompt(c config.Config, cellCfg cfg.CellConfig) string { + var b strings.Builder + + appDir := "/" + c.AppName // e.g. /devcell-85 + hostDir := c.BaseDir // e.g. /Users/dmitry/dev/dimmkirr/devcell + homeDir := "/home/" + c.HostUser + + // Container and project identity + fmt.Fprintf(&b, "Environment: Docker container (cell-%s)\n", c.AppName) + fmt.Fprintf(&b, "Project: %s (alias for %s on host)\n", appDir, hostDir) + fmt.Fprintf(&b, "Both paths are bind-mounted from the same host directory and resolve to the same filesystem.\n") + fmt.Fprintf(&b, "Working directory is %s. If the user mentions host paths like %s/..., they map to %s/...\n", appDir, hostDir, appDir) + b.WriteString("\n") + + // Bind mounts — standard + b.WriteString("Bind mounts:\n") + fmt.Fprintf(&b, " %s = %s (project source, read-write)\n", appDir, hostDir) + fmt.Fprintf(&b, " %s (persistent home, survives container restarts)\n", homeDir) + fmt.Fprintf(&b, " %s/.claude/skills (read-write)\n", homeDir) + fmt.Fprintf(&b, " %s/.claude/commands (read-only, from host)\n", homeDir) + fmt.Fprintf(&b, " %s/.claude/agents (read-only, from host)\n", homeDir) + fmt.Fprintf(&b, " /etc/devcell/config = %s (user build config)\n", c.ConfigDir) + + // User-defined volumes from devcell.toml [[volumes]] + for _, vol := range cellCfg.Volumes { + parts := strings.SplitN(vol.Mount, ":", 3) + if len(parts) >= 2 { + mode := "read-write" + if len(parts) == 3 && parts[2] == "ro" { + mode = "read-only" + } + fmt.Fprintf(&b, " %s = %s (%s, from devcell.toml)\n", parts[1], parts[0], mode) + } + } + b.WriteString("\n") + + // Host path mapping + b.WriteString("Host path mapping (use these to translate paths the user mentions):\n") + fmt.Fprintf(&b, " host: %s → container: %s\n", hostDir, hostDir) + fmt.Fprintf(&b, " host: %s → container: %s\n", c.HostHome, homeDir) + for _, vol := range cellCfg.Volumes { + parts := strings.SplitN(vol.Mount, ":", 3) + if len(parts) >= 2 { + fmt.Fprintf(&b, " host: %s → container: %s\n", parts[0], parts[1]) + } + } + b.WriteString("\n") + + // Key constraints + b.WriteString("Constraints:\n") + b.WriteString(" - /opt/devcell is the nix environment — do not modify at runtime\n") + fmt.Fprintf(&b, " - Nix profile: /opt/devcell/.local/state/nix/profiles/profile\n") + + // Custom system prompt from [llm] system_prompt + if cellCfg.LLM.SystemPrompt != "" { + b.WriteString("\n") + b.WriteString("Project context:\n") + b.WriteString(cellCfg.LLM.SystemPrompt) + if !strings.HasSuffix(cellCfg.LLM.SystemPrompt, "\n") { + b.WriteString("\n") + } + } + + return b.String() +} diff --git a/internal/runner/systemprompt_test.go b/internal/runner/systemprompt_test.go new file mode 100644 index 00000000..492e00e0 --- /dev/null +++ b/internal/runner/systemprompt_test.go @@ -0,0 +1,96 @@ +package runner + +import ( + "strings" + "testing" + + "github.com/DimmKirr/devcell/internal/cfg" + "github.com/DimmKirr/devcell/internal/config" +) + +func TestBuildSystemPrompt(t *testing.T) { + c := config.Config{ + AppName: "devcell-85", + BaseDir: "/Users/dmitry/dev/dimmkirr/devcell", + HostUser: "dmitry", + HostHome: "/Users/dmitry", + } + cellCfg := cfg.CellConfig{ + Volumes: []cfg.VolumeMount{ + {Mount: "~/work/secrets:/run/secrets:ro"}, + }, + } + + prompt := BuildSystemPrompt(c, cellCfg) + + checks := []struct { + name string + want string + }{ + {"container identity", "Docker container"}, + {"project alias", "/devcell-85"}, + {"host base dir", "/Users/dmitry/dev/dimmkirr/devcell"}, + {"same filesystem", "same filesystem"}, + {"host path mapping", "host paths"}, + {"persistent home", "/home/dmitry"}, + {"skills mount", ".claude/skills"}, + {"user volume", "/run/secrets"}, + {"user volume ro", "read-only"}, + {"host mapping", "host: /Users/dmitry/dev/dimmkirr/devcell"}, + {"nix constraint", "/opt/devcell"}, + } + + for _, tc := range checks { + if !strings.Contains(prompt, tc.want) { + t.Errorf("%s: prompt missing %q\n\nFull prompt:\n%s", tc.name, tc.want, prompt) + } + } +} + +func TestBuildSystemPrompt_WithCustomPrompt(t *testing.T) { + c := config.Config{ + AppName: "myproject-1", + BaseDir: "/Users/dev/myproject", + HostUser: "dev", + HostHome: "/Users/dev", + } + cellCfg := cfg.CellConfig{ + LLM: cfg.LLMSection{ + SystemPrompt: "This project uses PostgreSQL 16 with pgx/v5.\nAPI at /api/v2/.", + }, + } + + prompt := BuildSystemPrompt(c, cellCfg) + + if !strings.Contains(prompt, "Project context:") { + t.Error("prompt should contain 'Project context:' header") + } + if !strings.Contains(prompt, "PostgreSQL 16") { + t.Error("prompt should contain custom system prompt content") + } + if !strings.Contains(prompt, "/api/v2/") { + t.Error("prompt should contain custom system prompt content") + } + // Custom prompt should come after container environment + envIdx := strings.Index(prompt, "Docker container") + customIdx := strings.Index(prompt, "Project context:") + if customIdx <= envIdx { + t.Error("custom prompt should appear after container environment section") + } +} + +func TestBuildSystemPrompt_EmptyCustomPrompt(t *testing.T) { + c := config.Config{ + AppName: "myproject-1", + BaseDir: "/Users/dev/myproject", + HostUser: "dev", + HostHome: "/Users/dev", + } + cellCfg := cfg.CellConfig{} + + prompt := BuildSystemPrompt(c, cellCfg) + + if strings.Contains(prompt, "Project context:") { + t.Error("prompt should NOT contain 'Project context:' when system_prompt is empty") + } +} diff --git a/internal/scaffold/scaffold.go b/internal/scaffold/scaffold.go index 65866a9c..5dd414c2 100644 --- a/internal/scaffold/scaffold.go +++ b/internal/scaffold/scaffold.go @@ -24,6 +24,9 @@ var flakeNixContent []byte //go:embed templates/devcell.toml.tmpl var devcellTomlContent []byte +//go:embed templates/devcell.project.toml.tmpl +var devcellProjectTomlContent []byte + //go:embed templates/starship.toml.tmpl var starshipTomlContent []byte @@ -37,14 +40,14 @@ type scaffoldFile struct { // defaultModelsSection is the generic commented example used when no // ollama models are detected. -const defaultModelsSection = `# [models] +const defaultModelsSection = `# [llm.models] # Default LLM model (format: provider/model). Used by opencode and other agents. # default = "ollama/deepseek-r1:32b" -# [models.providers.ollama] +# [llm.models.providers.ollama] # models = ["deepseek-r1:32b", "qwen3:8b"] -# [models.providers.lmstudio] +# [llm.models.providers.lmstudio] # base_url = "http://host.docker.internal:1234/v1" # models = ["deepseek-r1:32b"]` @@ -59,9 +62,9 @@ func scaffoldFiles(modelsSnippet, nixhomePath string) []scaffoldFile { []byte(`inputs.devcell.url = "github:DimmKirr/devcell/`+version.Version+`?dir=nixhome";`), []byte(`inputs.devcell.url = "path:./nixhome";`)) - // Insert COPY nixhome/ before the existing COPY flake.nix line + // Insert COPY nixhome/ before the existing COPY flake.* line nixhomeCopy := []byte("COPY --chown=devcell:usergroup nixhome/ /opt/devcell/.config/devcell/nixhome/\n") - flakeCopyLine := []byte("COPY --chown=devcell:usergroup flake.nix") + flakeCopyLine := []byte("COPY --chown=devcell:usergroup flake.*") dockerfile = bytes.Replace(dockerfile, flakeCopyLine, append(nixhomeCopy, flakeCopyLine...), 1) } @@ -126,6 +129,9 @@ func generatePyprojectTOML(pkgs map[string]string) []byte { // SyncNixhome copies the nixhome directory from srcPath into configDir/nixhome/. // It replaces any existing nixhome copy to ensure fresh content each build. +// Also removes the outer flake.lock so nix regenerates it from the inner +// nixhome's inputs — prevents stale lock from pinning different nixpkgs +// than the base image, which would cause a full re-download. func SyncNixhome(srcPath, configDir string) error { if _, err := os.Stat(srcPath); err != nil { return fmt.Errorf("nixhome source %s: %w", srcPath, err) @@ -135,6 +141,9 @@ func SyncNixhome(srcPath, configDir string) error { if err := os.RemoveAll(dest); err != nil { return fmt.Errorf("remove old nixhome: %w", err) } + // Remove stale outer flake.lock — inner nixhome has its own lock + // that matches the base image's nix store. + os.Remove(filepath.Join(configDir, "flake.lock")) return copyDir(srcPath, dest) } @@ -204,6 +213,26 @@ func Scaffold(dir string, modelsSnippet string, nixhomePath string, force bool) return nil } +// RegeneratePackageFiles regenerates package.json and pyproject.toml from devcell.toml. +// Call this before any build to ensure derived files are in sync with config. +func RegeneratePackageFiles(configDir string) error { + c, err := cfg.LoadFile(filepath.Join(configDir, "devcell.toml")) + if err != nil { + return fmt.Errorf("load config: %w", err) + } + generated := []scaffoldFile{ + {"package.json", generatePackageJSON(c.Packages.Npm)}, + {"pyproject.toml", generatePyprojectTOML(c.Packages.Python)}, + } + for _, f := range generated { + dest := filepath.Join(configDir, f.name) + if err := os.WriteFile(dest, f.content, 0644); err != nil { + return fmt.Errorf("write %s: %w", f.name, err) + } + } + return nil +} + // statErr returns the error from os.Stat (nil if file exists). func statErr(path string) error { _, err := os.Stat(path) @@ -236,3 +265,18 @@ func ScaffoldVagrantfile(dir, vagrantBox, nixhomePath string) error { } return nil } + +// ScaffoldProject writes a .devcell.toml in the given directory. +// Returns os.ErrExist if the file already exists. +func ScaffoldProject(dir string) error { + dest := filepath.Join(dir, ".devcell.toml") + if _, err := os.Stat(dest); err == nil { + return os.ErrExist + } + return os.WriteFile(dest, devcellProjectTomlContent, 0644) +} + +// ScaffoldProjectForce writes a .devcell.toml, overwriting if it exists. +func ScaffoldProjectForce(dir string) error { + return os.WriteFile(filepath.Join(dir, ".devcell.toml"), devcellProjectTomlContent, 0644) +} diff --git a/internal/scaffold/scaffold_test.go b/internal/scaffold/scaffold_test.go index 0f4b639d..53eb0c02 100644 --- a/internal/scaffold/scaffold_test.go +++ b/internal/scaffold/scaffold_test.go @@ -99,7 +99,7 @@ func TestScaffold_DockerfileDoesNotInstallHomeManager(t *testing.T) { } // TestScaffold_DockerfileRunsHomeManagerSwitch — user Dockerfile must run -// home-manager switch to activate the profile from the user flake. +// home-manager switch to activate the stack from the user flake. func TestScaffold_DockerfileRunsHomeManagerSwitch(t *testing.T) { dir := t.TempDir() if err := scaffold.Scaffold(dir, "", "", false); err != nil { @@ -246,14 +246,14 @@ func TestScaffold_EmptySnippet_UsesDefaultModelsSection(t *testing.T) { data, _ := os.ReadFile(filepath.Join(dir, "devcell.toml")) s := string(data) // Default template has the generic commented example - if !strings.Contains(s, "# [models]") { - t.Errorf("expected default models section in devcell.toml, got:\n%s", s) + if !strings.Contains(s, "# [llm.models]") { + t.Errorf("expected default llm.models section in devcell.toml, got:\n%s", s) } } func TestScaffold_WithSnippet_StillValidTOML(t *testing.T) { dir := t.TempDir() - snippet := "# [models]\n# default = \"ollama/deepseek-r1:70b\"\n# [models.providers.ollama]\n# models = [\"deepseek-r1:70b\"]\n" + snippet := "# [llm.models]\n# default = \"ollama/deepseek-r1:70b\"\n# [llm.models.providers.ollama]\n# models = [\"deepseek-r1:70b\"]\n" if err := scaffold.Scaffold(dir, snippet, "", false); err != nil { t.Fatal(err) } @@ -345,11 +345,11 @@ func TestScaffold_WithNixhomePath_DockerfileCopiesNixhome(t *testing.T) { if !strings.Contains(s, nixhomeCopyLine) { t.Errorf("Dockerfile must COPY nixhome/ when nixhomePath is set, got:\n%s", s) } - // nixhome COPY must appear before flake.nix COPY + // nixhome COPY must appear before flake.* COPY nixhomeIdx := strings.Index(s, nixhomeCopyLine) - flakeCopyIdx := strings.Index(s, "COPY --chown=devcell:usergroup flake.nix") + flakeCopyIdx := strings.Index(s, "COPY --chown=devcell:usergroup flake.*") if nixhomeIdx < 0 || flakeCopyIdx < 0 || nixhomeIdx > flakeCopyIdx { - t.Errorf("nixhome/ COPY must appear before flake.nix COPY in Dockerfile") + t.Errorf("nixhome/ COPY must appear before flake.* COPY in Dockerfile") } } diff --git a/internal/scaffold/templates/Dockerfile.tmpl b/internal/scaffold/templates/Dockerfile.tmpl index 5feded89..56a58440 100644 --- a/internal/scaffold/templates/Dockerfile.tmpl +++ b/internal/scaffold/templates/Dockerfile.tmpl @@ -1,20 +1,28 @@ FROM {{BASE_IMAGE}} -# Copy the user's flake (fetches nixhome from GitHub). -# Edit flake.nix to customise packages, then run 'cell build'. -COPY --chown=devcell:usergroup flake.nix /opt/devcell/.config/devcell/ +# Stamp user image version (commit SHA + build date) +ARG GIT_COMMIT=unknown +USER 0 +RUN mkdir -p /etc/devcell && \ + echo "${GIT_COMMIT}-$(date -u +%Y%m%dT%H%M%SZ)" > /etc/devcell/user-image-version +USER devcell -# Activate the profile and install language runtimes. +# Copy flake + lock. The glob (flake.*) makes flake.lock optional — first build +# won't have one yet; nix creates it and subsequent builds reuse it, pinning +# inputs so the base image's /nix/store paths are found without re-downloading. +COPY --chown=devcell:usergroup flake.* /opt/devcell/.config/devcell/ + +# Activate the nix profile. # NIX_REFRESH is set to "--refresh" by `cell build --no-cache` to bust nix flake cache. ARG NIX_REFRESH="" RUN ARCH=$(uname -m) && \ [ "$ARCH" = "aarch64" ] && ARCH_SUFFIX="-aarch64" || ARCH_SUFFIX="" && \ home-manager switch \ --flake "/opt/devcell/.config/devcell#devcell-ultimate${ARCH_SUFFIX}" \ - --impure $NIX_REFRESH && \ - ln -sfT "$(readlink -f /opt/devcell/.nix-profile)" \ - /nix/var/nix/profiles/per-user/devcell/profile && \ - (mkdir -p /opt/mise 2>/dev/null || sudo mkdir -p /opt/mise) && \ + --impure $NIX_REFRESH + +# Install language runtimes via mise (separate layer — cached when only nix config changes). +RUN (mkdir -p /opt/mise 2>/dev/null || sudo mkdir -p /opt/mise) && \ cd /opt/devcell && MISE_DATA_DIR=/opt/mise MISE_YES=1 mise install && \ for tool_dir in /opt/mise/installs/*/; do \ tool=$(basename "$tool_dir"); \ diff --git a/internal/scaffold/templates/devcell.project.toml.tmpl b/internal/scaffold/templates/devcell.project.toml.tmpl new file mode 100644 index 00000000..9fc689be --- /dev/null +++ b/internal/scaffold/templates/devcell.project.toml.tmpl @@ -0,0 +1,28 @@ +# .devcell.toml — project-level DevCell overrides (merged on top of ~/.config/devcell/devcell.toml) + +# [cell] +# image_tag = "latest-go" +# gui = true +# timezone = "Europe/Prague" + +# Project context injected into all AI agents (Claude, Codex, OpenCode). +# Sits on top of CLAUDE.md — use for personal/local instructions not checked into the repo. +# [llm] +# system_prompt = """ +# This project uses PostgreSQL 16 with pgx/v5. +# API endpoints follow REST conventions at /api/v2/. +# """ + +# 1Password items whose fields are passed into the container as env vars. +# Requires `op` CLI on the host. Each field in the item becomes an env var: +# e.g. a field labeled "API_KEY" with value "sk-123" → env var API_KEY=sk-123. +# [op] +# items = ["my-project-secrets"] + +# Extra environment variables forwarded into the container. +# [env] +# MY_VAR = "value" + +# Extra volume mounts appended to docker run. +# [[volumes]] +# mount = "~/work/secrets:/run/secrets:ro" diff --git a/internal/scaffold/templates/devcell.toml.tmpl b/internal/scaffold/templates/devcell.toml.tmpl index b956b13c..0f2ed151 100644 --- a/internal/scaffold/templates/devcell.toml.tmpl +++ b/internal/scaffold/templates/devcell.toml.tmpl @@ -2,39 +2,58 @@ # Global DevCell configuration. Project-level overrides go in /.devcell.toml [cell] -# Override the default image tag. -# Available tags: latest-base, latest-go, latest-node, latest-python, -# latest-electronics, latest-fullstack, latest-ultimate (default) -# image_tag = "latest-go" +# Base stack (one of: base, go, node, python, fullstack, electronics, ultimate) +# stack = "ultimate" +# +# Addon modules (from nixhome/modules/): electronics, desktop, financial, +# graphics, infra, news, nixos, travel, scraping, go, node, python +# modules = ["electronics", "desktop"] +# # Enable GUI (Xvfb + VNC + browser). Injects DEVCELL_GUI_ENABLED=true. gui = true +# Timezone (IANA format). If omitted, inherits host $TZ. +# timezone = "Europe/Prague" -[claude] +# AI agent configuration — all LLM settings in one place. +# [llm] # Route Claude Code through a local ollama instance (Anthropic API compat). # Equivalent to: cell claude --ollama # use_ollama = false +# +# Project context injected into all AI agents (Claude, Codex, OpenCode). +# Sits on top of CLAUDE.md and agent-native config files. +# system_prompt = """ +# This project uses PostgreSQL 16 with pgx/v5. +# API endpoints follow REST conventions at /api/v2/. +# """ -# [git] # Git identity forwarded into the container. # If omitted and no GIT_AUTHOR_* env vars are set, ~/.config/git/config is mounted read-only instead. +# [git] # author_name = "Your Name" # author_email = "you@example.com" # committer_name = "Your Name" # defaults to author_name if omitted # committer_email = "you@example.com" # defaults to author_email if omitted -[env] +# 1Password items whose fields are passed into the container as env vars. +# Requires `op` CLI on the host. Each field in the item becomes an env var: +# e.g. a field labeled "API_KEY" with value "sk-123" → env var API_KEY=sk-123. +# [op] +# items = ["prod-api-keys", "dev-secrets"] + # Extra environment variables forwarded into the container. +[env] # GITHUB_TOKEN = "ghp_xxx" # ANTHROPIC_API_KEY = "sk-ant-xxx" -[mise] # mise runtime settings forwarded as MISE_ env vars. # See: https://mise.jdx.dev/configuration.html +[mise] # trusted_config_paths = "/" # idiomatic_version_file = "true" -# [[volumes]] # Extra volume mounts appended to docker run. +# [[volumes]] # mount = "~/work/secrets:/run/secrets:ro" # # [[volumes]] @@ -42,18 +61,18 @@ gui = true {{MODELS_SECTION}} -[packages.npm] # npm packages installed in the container. Edit and run 'cell build'. -"@anthropic-ai/claude-code" = "2.1.49" +# NOTE: claude-code and opencode are managed via nix (nixhome/modules/base.nix). +# Only add packages here that are NOT in nixhome. +[packages.npm] "@openai/codex" = "^0.96.0" "@opentofu/opentofu-mcp-server" = "^0.1.5" "patchright-mcp" = "^0.0.68" "@playwright/test" = "^1.57.0" "@slidev/cli" = "^52.11.0" -"opencode-ai" = "^1.1.51" -[packages.python] # Python packages installed in the container. Edit and run 'cell build'. +[packages.python] "pre-commit" = "*" # "httpie" = "*" # "pipx" = "*" diff --git a/internal/scaffold/templates/flake.nix.tmpl b/internal/scaffold/templates/flake.nix.tmpl index 812346ed..064bf8eb 100644 --- a/internal/scaffold/templates/flake.nix.tmpl +++ b/internal/scaffold/templates/flake.nix.tmpl @@ -1,5 +1,5 @@ { - description = "DevCell user profile — customise and run 'cell build'"; + description = "DevCell user stack — customise and run 'cell build'"; # Follows main branch by default. To pin a specific release: # inputs.devcell.url = "github:DimmKirr/devcell/v1.0.0?dir=nixhome"; diff --git a/internal/version/version.go b/internal/version/version.go index 2c81fcd5..1d33d652 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -3,4 +3,9 @@ package version var ( Version = "v0.0.0" GitCommit = "none" + BuildDate = "unknown" ) + +func Full() string { + return Version + "-" + BuildDate + "-" + GitCommit +} diff --git a/nixhome/flake.nix b/nixhome/flake.nix index fc018821..c138c2ee 100644 --- a/nixhome/flake.nix +++ b/nixhome/flake.nix @@ -1,5 +1,5 @@ { - description = "devcell container tool profiles"; + description = "devcell container tool stacks"; inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11"; @@ -36,7 +36,7 @@ nixCfg = { inherit system; config.allowUnfreePredicate = pkg: - builtins.elem (lib.getName pkg) ["claude-code" "packer" "terraform"]; + builtins.elem (lib.getName pkg) ["claude-code" "corefonts" "packer" "terraform"]; }; pkgsUnstable = import nixpkgs-unstable nixCfg; pkgsEdge = import nixpkgs-edge nixCfg; @@ -55,19 +55,19 @@ ++ modules; }; - # Map of profile name → module list - profiles = { - "devcell-base" = [./profiles/base.nix]; - "devcell-go" = [./profiles/go.nix]; - "devcell-node" = [./profiles/node.nix]; - "devcell-python" = [./profiles/python.nix]; - "devcell-fullstack" = [./profiles/fullstack.nix]; - "devcell-electronics" = [./profiles/electronics.nix]; - "devcell-ultimate" = [./profiles/ultimate.nix]; + # Map of stack name → module list + stacks = { + "devcell-base" = [./stacks/base.nix]; + "devcell-go" = [./stacks/go.nix]; + "devcell-node" = [./stacks/node.nix]; + "devcell-python" = [./stacks/python.nix]; + "devcell-fullstack" = [./stacks/fullstack.nix]; + "devcell-electronics" = [./stacks/electronics.nix]; + "devcell-ultimate" = [./stacks/ultimate.nix]; }; # Generate homeConfigurations for x86_64-linux and aarch64-linux. - # aarch64 variants use a "-aarch64" suffix so the Dockerfile can select + # aarch64 stacks use a "-aarch64" suffix so the Dockerfile can select # the right config via TARGETARCH: # ARCH_SUFFIX=$([ "$TARGETARCH" = "arm64" ] && echo "-aarch64" || echo "") # home-manager switch --flake .#devcell-fullstack${ARCH_SUFFIX} @@ -80,7 +80,7 @@ // {"${name}-aarch64" = mkHome "aarch64-linux" mods;} ) {} - profiles; + stacks; in { homeConfigurations = mkAllConfigs; diff --git a/nixhome/hosts/macos/home.nix b/nixhome/hosts/macos/home.nix index 4d6a75db..8034f836 100644 --- a/nixhome/hosts/macos/home.nix +++ b/nixhome/hosts/macos/home.nix @@ -1,8 +1,8 @@ # hosts/macos/home.nix — home-manager config for the vagrant user on the devcell macOS VM -# Reuses the devcell base profile (tmux, jq, ripgrep, go-task, git-lfs, etc.) +# Reuses the devcell base stack (tmux, jq, ripgrep, go-task, git-lfs, etc.) { mcp-nixos, ... }: { imports = [ - ../../profiles/base.nix + ../../stacks/base.nix ]; home.username = "vagrant"; diff --git a/nixhome/modules/base.nix b/nixhome/modules/base.nix index 4400815f..1f2b500e 100644 --- a/nixhome/modules/base.nix +++ b/nixhome/modules/base.nix @@ -1,43 +1,39 @@ -# base.nix — utilities present in every profile -{pkgs, ...}: { +# base.nix — utilities present in every stack +{pkgs, lib, pkgsUnstable, ...}: { imports = [ ./shell.nix - ./entrypoint.nix - ./managed-mcp.nix - ./managed-claude.nix - ./managed-opencode.nix + ./llm ]; - devcell.managedOpencode.providers = { - lmstudio = { - npm = "@ai-sdk/openai-compatible"; - name = "LM Studio (local)"; - options.baseURL = "http://127.0.0.1:1234/v1"; - models = { - "google/gemma-3n-e4b".name = "Gemma 3n-e4b (local)"; - "zai-org_glm-4.7-flash".name = "GLM-4.7 (local)"; - }; - }; - }; + # ── Stage entrypoint fragments to /etc/devcell/entrypoint.d/ ─────────────── + # Any module can drop a fragment into ~/.config/devcell/entrypoint.d/ via home.file. + # This activation script copies them to /etc/devcell/entrypoint.d/ where the base + # entrypoint sources them at container startup. + # + # Numbering convention: + # 10-* — early setup (future: mise extraction) + # 50-* — services (GUI, xrdp) + # 90-* — late setup (future: custom user hooks) + home.activation.stageEntrypoints = lib.hm.dag.entryAfter ["writeBoundary"] '' + export PATH="/usr/bin:/bin:$PATH" + $DRY_RUN_CMD sudo mkdir -p /etc/devcell/entrypoint.d + if [ -d "$HOME/.config/devcell/entrypoint.d" ]; then + $DRY_RUN_CMD sudo ${pkgs.rsync}/bin/rsync -a --chmod=+x --delete \ + "$HOME/.config/devcell/entrypoint.d/" /etc/devcell/entrypoint.d/ + fi + ''; - devcell.managedClaude = { - hookScripts."auto-approve-all.sh" = '' - #!/bin/bash - # Auto-approve all permission requests (unrestricted mode for background agents) - echo '{"decision":"allow","applyPermissionRule":true}' - ''; - settings = { - hooks.PermissionRequest = [ - { - matcher = "*"; - hooks = [ - { - type = "command"; - command = "bash ~/.claude/hooks/auto-approve-all.sh"; - } - ]; - } - ]; + home.file = { + # ── Entrypoint fragments ───────────────────────────────────────────────── + # Standalone shell scripts sourced by entrypoint.sh at container start. + # See fragments/ directory for the actual shell code. + ".config/devcell/entrypoint.d/05-shell-rc.sh" = { + executable = true; + source = ./fragments/05-shell-rc.sh; + }; + ".config/devcell/entrypoint.d/20-homedir.sh" = { + executable = true; + source = ./fragments/20-homedir.sh; }; }; @@ -48,8 +44,6 @@ iosevka-bin # best block element coverage; designed for terminals noto-fonts # comprehensive Unicode incl. Noto Sans Mono - claude-code # AI coding assistant CLI - opencode # AI coding agent for terminal aria2 # download tool dasel # JSON/TOML/YAML/XML processor with TOML output support ffmpeg # media processing @@ -60,7 +54,9 @@ go-task # task runner (Taskfile) pre-commit # git hook manager jq # JSON processor + pandoc # document converter (use: pandoc input.md -o output.pdf) ripgrep # fast grep + sqlite # SQLite CLI (use: sqlite3) expect # provides unbuffer — forces PTY for commands that need a TTY tini # minimal init for containers tmux # terminal multiplexer @@ -68,6 +64,7 @@ tree # directory listing unzip # archive extraction wget # HTTP downloader + rsync # fast file sync (used by entrypoint fragment staging) yq-go # TOML/YAML/JSON processor ]; } diff --git a/nixhome/modules/desktop/default.nix b/nixhome/modules/desktop/default.nix index 2c25bc42..072abb55 100644 --- a/nixhome/modules/desktop/default.nix +++ b/nixhome/modules/desktop/default.nix @@ -19,20 +19,89 @@ # libglu1-mesa / -dev → pkgs.libGLU # libtiff5-dev → pkgs.libtiff # libwxgtk3.2-1, libwxgtk-webview3.2-1 → pkgs.wxGTK32 (pname="wxwidgets" 3.2.x) -{pkgs, lib, ...}: +{pkgs, lib, config, ...}: let # Import theme — palette (c), fonts (f), and generated fluxbox cfg. theme = import ./themes/main/theme.nix { inherit lib pkgs; }; inherit (theme) c f cfg init xresources wallpaper pixmaps; + # UA architecture — must match what Chromium puts in navigator.userAgent. + # Chrome's "UA reduction" always reports "x86_64" regardless of real CPU, + # but getHighEntropyValues().architecture leaks the real arch ("arm" on aarch64). + # Detection scripts compare these and flag the mismatch. + # Always "x86" because that's what Chrome's UA string claims. + uaArch = "x86"; + + # Static LD_LIBRARY_PATH fallback for the patchright-mcp-cell wrapper. + # This wrapper is a nix derivation baked at eval time — can't source files at runtime. + # All other contexts (entrypoint services, interactive shells) use the full-closure + # /opt/devcell/.nix-ld-library-path generated by home.activation.generateNixLdPath. + runtimeLibs = with pkgs; [ + glib + nspr + nss + atk + at-spi2-atk + dbus + cups + libxkbcommon + at-spi2-core + xorg.libX11 # libX11 + libX11-xcb — core X11 client lib (Electron SIGTRAP without it) + xorg.libXcomposite + xorg.libXcursor + xorg.libXdamage + xorg.libXext + xorg.libXfixes + xorg.libXi + xorg.libXrandr + xorg.libXtst + xorg.libxkbfile + libgbm # GBM buffer manager — mesa itself does NOT provide libgbm.so + mesa # Mesa 3D — llvmpipe software rasterizer + cairo + pango + alsa-lib + pulseaudio # PulseAudio client lib + gcc.cc.lib # libgomp (OpenMP runtime) + gtk3 # GTK 3 — needed by Electron/Chromium-based GUI apps + ]; + runtimeLibPath = pkgs.lib.makeLibraryPath runtimeLibs; + + # Patchright MCP config — Chromium launch args for X11 display. + # No --ozone-platform needed (auto-detects X11 from DISPLAY). + # WebGL via Mesa Lavapipe: ANGLE → Vulkan → lvp (CPU software renderer). + # --ignore-gpu-blocklist prevents Chromium from disabling WebGL on software renderers. + patchrightConfig = pkgs.writeTextFile { + name = "patchright-mcp-config.json"; + text = builtins.toJSON { + browser.launchOptions.args = [ + "--use-gl=angle" + "--use-angle=vulkan" + "--ignore-gpu-blocklist" + "--window-size=1920,1040" + "--force-device-scale-factor=1" + "--disable-features=AudioServiceSandbox" + "--autoplay-policy=no-user-gesture-required" + "--disable-blink-features=AutomationControlled" + ]; + # Block ServiceWorkers — they run in a separate scope unreachable by init-script. + # Forces detection scripts to fall back to SharedWorker, which we CAN intercept. + browser.contextOptions.serviceWorkers = "block"; + }; + }; + # Stealth init-script — injected via --init-script to spoof JS-level fingerprints. # Patchright handles CDP layer (Runtime.enable, launch flags); # this script handles what page JS can detect. stealthInitScript = pkgs.writeTextFile { name = "stealth-init.js"; text = '' - // Patch navigator.webdriver (backup — Patchright handles via flag) - Object.defineProperty(navigator, 'webdriver', { get: () => undefined }); + // Patch navigator.webdriver on the PROTOTYPE (instance-level patch doesn't stick + // because Chromium defines it on Navigator.prototype, not the instance) + Object.defineProperty(Navigator.prototype, 'webdriver', { + get: () => undefined, + configurable: true + }); // Mock chrome.runtime window.chrome = { @@ -41,14 +110,46 @@ let csi: function() { return {}; } }; - // Mock plugins - Object.defineProperty(navigator, 'plugins', { - get: () => [ - { name: 'Chrome PDF Plugin', filename: 'internal-pdf-viewer' }, - { name: 'Chrome PDF Viewer', filename: 'mhjfbmdgcfjbbpaeojofohoefgiehjai' }, - { name: 'Native Client', filename: 'internal-nacl-plugin' } - ] - }); + // --- Fix toString leaks (must be early — WebGL patching uses _nativeFnNames) --- + const origToString = Function.prototype.toString; + const _nativeFnNames = new WeakMap(); + Function.prototype.toString = function() { + const name = _nativeFnNames.get(this); + if (name !== undefined) return 'function ' + name + '() { [native code] }'; + return origToString.call(this); + }; + _nativeFnNames.set(Function.prototype.toString, 'toString'); + // Register webdriver getter + const wdDesc = Object.getOwnPropertyDescriptor(Navigator.prototype, 'webdriver'); + if (wdDesc && wdDesc.get) _nativeFnNames.set(wdDesc.get, 'get webdriver'); + // Register chrome.runtime functions + if (window.chrome && window.chrome.runtime) { + if (window.chrome.runtime.connect) _nativeFnNames.set(window.chrome.runtime.connect, 'connect'); + if (window.chrome.runtime.sendMessage) _nativeFnNames.set(window.chrome.runtime.sendMessage, 'sendMessage'); + if (window.chrome.loadTimes) _nativeFnNames.set(window.chrome.loadTimes, 'loadTimes'); + if (window.chrome.csi) _nativeFnNames.set(window.chrome.csi, 'csi'); + } + + // Fix plugins + mimeTypes — headless Chrome may have empty arrays. + if (navigator.plugins.length === 0) { + const pdfMime = { type: 'application/pdf', suffixes: 'pdf', description: 'Portable Document Format' }; + const fakePlugins = [ + { name: 'Chrome PDF Plugin', filename: 'internal-pdf-viewer', description: 'Portable Document Format', length: 1, 0: pdfMime }, + { name: 'Chrome PDF Viewer', filename: 'mhjfbmdgcfjbbpaeojofohoefgiehjai', description: ' ', length: 1, 0: pdfMime }, + { name: 'Native Client', filename: 'internal-nacl-plugin', description: ' ', length: 1, 0: pdfMime } + ]; + Object.setPrototypeOf(fakePlugins, PluginArray.prototype); + Object.defineProperty(navigator, 'plugins', { get: () => fakePlugins }); + } + if (navigator.mimeTypes.length === 0) { + const fakeMimes = [ + { type: 'application/pdf', suffixes: 'pdf', description: 'Portable Document Format', enabledPlugin: navigator.plugins[0] } + ]; + Object.setPrototypeOf(fakeMimes, MimeTypeArray.prototype); + Object.defineProperty(navigator, 'mimeTypes', { get: () => fakeMimes }); + } + // Spoof pdfViewerEnabled (headless=new has false) + Object.defineProperty(navigator, 'pdfViewerEnabled', { get: () => true, configurable: true }); // Mock languages Object.defineProperty(navigator, 'languages', { get: () => ['en-US', 'en'] }); @@ -60,17 +161,504 @@ let ? Promise.resolve({ state: Notification.permission }) : origQuery(params); - // Spoof WebGL renderer (hide SwiftShader / Mesa llvmpipe) - const getParam = WebGLRenderingContext.prototype.getParameter; - WebGLRenderingContext.prototype.getParameter = function(p) { - if (p === 37445) return 'Intel Inc.'; - if (p === 37446) return 'Intel Iris OpenGL Engine'; - return getParam.call(this, p); + // Spoof userAgentData high-entropy values — Chromium's userAgent says "x86_64" + // (UA reduction) but getHighEntropyValues() leaks the real arch on arm64. + // Detection scripts compare these and flag the mismatch. + // Architecture value injected at nix build time: "${uaArch}" + // Must use Object.defineProperty on prototype — direct assignment is a no-op + // because the property is non-writable on NavigatorUAData.prototype. + if (typeof NavigatorUAData !== 'undefined') { + const origGetHigh = NavigatorUAData.prototype.getHighEntropyValues; + Object.defineProperty(NavigatorUAData.prototype, 'getHighEntropyValues', { + value: async function(hints) { + const values = await origGetHigh.call(this, hints); + values.architecture = '${uaArch}'; + return values; + }, + writable: true, + configurable: true + }); + } + + // --- Web Share API stubs (noWebShare signal) --- + if (!navigator.share) { + navigator.share = function(data) { + return Promise.reject(new DOMException('Share canceled', 'AbortError')); + }; + } + if (!navigator.canShare) { + navigator.canShare = function(data) { return true; }; + } + + // --- Media devices mock (headless has 0 devices → bot signal) --- + if (navigator.mediaDevices) { + const _origEnum = navigator.mediaDevices.enumerateDevices; + navigator.mediaDevices.enumerateDevices = async function() { + const real = await _origEnum.call(this); + if (real.length > 0) return real; + return [ + { deviceId: 'default', kind: 'audioinput', label: "", groupId: 'default' }, + { deviceId: 'communications', kind: 'audiooutput', label: "", groupId: 'default' }, + { deviceId: 'default', kind: 'videoinput', label: "", groupId: 'camera1' } + ]; + }; + } + + // Spoof WebGL renderer + parameters (hide SwiftShader fingerprint) + // Use Object.defineProperty on WebGL prototypes — works on ALL contexts + // regardless of how they're created (Canvas, OffscreenCanvas, iframe). + // Proxy-wrapping getContext gets bypassed by CreepJS; prototype patching doesn't. + const _wglVendor = 'Intel Inc.'; + const _wglRenderer = 'Intel Iris OpenGL Engine'; + // Intel-realistic parameter overrides (SwiftShader defaults in comments) + const _wglParams = { + 37445: _wglVendor, // UNMASKED_VENDOR_WEBGL + 37446: _wglRenderer, // UNMASKED_RENDERER_WEBGL + 3379: 16384, // MAX_TEXTURE_SIZE (SwiftShader: 8192) + 3386: 'viewport', // MAX_VIEWPORT_DIMS — special handling below + 34076: 16384, // MAX_CUBE_MAP_TEXTURE_SIZE (SwiftShader: 8192) + 34024: 16384, // MAX_RENDERBUFFER_SIZE (SwiftShader: 8192) + 34047: 16, // MAX_TEXTURE_MAX_ANISOTROPY_EXT + 36349: 1024, // MAX_FRAGMENT_UNIFORM_VECTORS (SwiftShader: 221) + 36347: 1024, // MAX_VERTEX_UNIFORM_VECTORS (SwiftShader: 256) + 36348: 30, // MAX_VARYING_VECTORS (SwiftShader: 15) + 36183: 8, // MAX_SAMPLES (SwiftShader: 4) + 7936: 'WebKit', // VENDOR + 7937: 'WebKit WebGL', // RENDERER + 7938: 'WebGL 1.0 (OpenGL ES 2.0 Chromium)', // VERSION + 35724: 'WebGL GLSL ES 1.0 (OpenGL ES GLSL ES 1.0 Chromium)', // SHADING_LANGUAGE_VERSION + }; + const _wgl2Extras = { + 7938: 'WebGL 2.0 (OpenGL ES 3.0 Chromium)', + 35724: 'WebGL GLSL ES 3.00 (OpenGL ES GLSL ES 3.0 Chromium)', + 32883: 2048, 33000: 1048576, 33001: 1048576, 34852: 8, + 35657: 4096, 35658: 4096, 35071: 2048, 35077: 7, + 35659: 120, 35968: 4, 35978: 120, 35979: 4, 36063: 8, + 35371: 12, 35373: 12, 35374: 24, 35375: 24, 35376: 65536, + }; + const _extraExts = ['EXT_texture_filter_anisotropic', 'WEBGL_compressed_texture_s3tc', 'WEBGL_compressed_texture_s3tc_srgb']; + + // Patch getParameter on WebGL prototypes directly + function _patchWebGL(Proto, params) { + const origGP = Proto.prototype.getParameter; + const newGP = function(p) { + if (p === 3386) return new Int32Array([16384, 16384]); + if (p in params) return params[p]; + return origGP.call(this, p); + }; + Object.defineProperty(Proto.prototype, 'getParameter', { + value: newGP, writable: true, configurable: true, enumerable: true + }); + _nativeFnNames.set(newGP, 'getParameter'); + + const origGSE = Proto.prototype.getSupportedExtensions; + const newGSE = function() { + const exts = origGSE.call(this) || []; + const set = new Set(exts); + _extraExts.forEach(e => set.add(e)); + return [...set]; + }; + Object.defineProperty(Proto.prototype, 'getSupportedExtensions', { + value: newGSE, writable: true, configurable: true, enumerable: true + }); + _nativeFnNames.set(newGSE, 'getSupportedExtensions'); + + const origGE = Proto.prototype.getExtension; + const newGE = function(name) { + const ext = origGE.call(this, name); + if (!ext && name === 'EXT_texture_filter_anisotropic') { + return { TEXTURE_MAX_ANISOTROPY_EXT: 34046, MAX_TEXTURE_MAX_ANISOTROPY_EXT: 34047 }; + } + return ext; + }; + Object.defineProperty(Proto.prototype, 'getExtension', { + value: newGE, writable: true, configurable: true, enumerable: true + }); + _nativeFnNames.set(newGE, 'getExtension'); + } + + const _wgl2AllParams = Object.assign({}, _wglParams, _wgl2Extras); + _patchWebGL(WebGLRenderingContext, _wglParams); + if (typeof WebGL2RenderingContext !== 'undefined') { + _patchWebGL(WebGL2RenderingContext, _wgl2AllParams); + } + + // --- Patch Web Workers (spoof WebGL + UAData in worker scope) --- + // Workers run in a separate global; init-script patches don't reach them. + // Intercept Worker constructor to prepend spoof code into worker scripts. + const _workerPatch = ` + (function() { + if (typeof WebGLRenderingContext !== 'undefined') { + var params = {37445:'Intel Inc.',37446:'Intel Iris OpenGL Engine',7936:'WebKit',7937:'WebKit WebGL',3379:16384,34076:16384,34024:16384,36183:8}; + function patchGL(P) { + var orig = P.prototype.getParameter; + P.prototype.getParameter = function(p) { + if (p === 3386) return new Int32Array([16384, 16384]); + if (p in params) return params[p]; + return orig.call(this, p); + }; + } + patchGL(WebGLRenderingContext); + if (typeof WebGL2RenderingContext !== 'undefined') patchGL(WebGL2RenderingContext); + } + if (typeof NavigatorUAData !== 'undefined') { + var origGetHigh = NavigatorUAData.prototype.getHighEntropyValues; + Object.defineProperty(NavigatorUAData.prototype, 'getHighEntropyValues', { + value: async function(hints) { + var values = await origGetHigh.call(this, hints); + values.architecture = '${uaArch}'; + return values; + }, + writable: true, + configurable: true + }); + } + })();\n`; + const _origWorker = window.Worker; + const _origBlob = window.Blob; + window.Worker = function(url, opts) { + try { + // Handle Blob URLs — read the blob content, prepend patch + if (typeof url === 'string' && url.startsWith('blob:')) { + const xhr = new XMLHttpRequest(); + xhr.open('GET', url, false); + xhr.send(); + if (xhr.status === 200) { + const blob = new _origBlob([_workerPatch + xhr.responseText], {type: 'application/javascript'}); + return new _origWorker(URL.createObjectURL(blob), opts); + } + } + // Handle regular URLs — fetch script, prepend patch + if (typeof url === 'string' || (url instanceof URL)) { + const urlStr = url instanceof URL ? url.href : url; + const xhr = new XMLHttpRequest(); + xhr.open('GET', urlStr, false); + xhr.send(); + if (xhr.status === 200) { + const blob = new _origBlob([_workerPatch + xhr.responseText], {type: 'application/javascript'}); + return new _origWorker(URL.createObjectURL(blob), opts); + } + } + } catch(e) {} + return new _origWorker(url, opts); + }; + window.Worker.prototype = _origWorker.prototype; + _nativeFnNames.set(window.Worker, 'Worker'); + + // --- Patch SharedWorker (same interception as Worker) --- + // When ServiceWorkers are blocked, detection scripts fall back to SharedWorker. + // Intercept SharedWorker constructor to inject the same spoof code. + if (typeof SharedWorker !== 'undefined') { + const _origSharedWorker = window.SharedWorker; + window.SharedWorker = function(url, opts) { + try { + if (typeof url === 'string' && url.startsWith('blob:')) { + const xhr = new XMLHttpRequest(); + xhr.open('GET', url, false); + xhr.send(); + if (xhr.status === 200) { + const blob = new _origBlob([_workerPatch + xhr.responseText], {type: 'application/javascript'}); + return new _origSharedWorker(URL.createObjectURL(blob), opts); + } + } + if (typeof url === 'string' || (url instanceof URL)) { + const urlStr = url instanceof URL ? url.href : url; + const xhr = new XMLHttpRequest(); + xhr.open('GET', urlStr, false); + xhr.send(); + if (xhr.status === 200) { + const blob = new _origBlob([_workerPatch + xhr.responseText], {type: 'application/javascript'}); + return new _origSharedWorker(URL.createObjectURL(blob), opts); + } + } + } catch(e) {} + return new _origSharedWorker(url, opts); + }; + window.SharedWorker.prototype = _origSharedWorker.prototype; + _nativeFnNames.set(window.SharedWorker, 'SharedWorker'); + } + + // --- Patch document.createElement to catch unappended iframes --- + // CreepJS creates iframes via createElement('iframe') and accesses + // contentWindow WITHOUT appending to DOM. Our appendChild hook never fires. + // Override contentWindow getter on each new iframe to auto-patch its window. + const _origCreateElement = document.createElement.bind(document); + document.createElement = function(tag, opts) { + const el = _origCreateElement(tag, opts); + if (tag.toLowerCase() === 'iframe') { + const _origDesc = Object.getOwnPropertyDescriptor(HTMLIFrameElement.prototype, 'contentWindow'); + if (_origDesc && _origDesc.get) { + const _origGet = _origDesc.get; + Object.defineProperty(el, 'contentWindow', { + get: function() { + const w = _origGet.call(this); + if (w) _patchIframeWindow(w); + return w; + }, + configurable: true + }); + } + } + return el; + }; + _nativeFnNames.set(document.createElement, 'createElement'); + + // --- Patch iframes recursively (CreepJS uses nested "phantom" iframes) --- + // CreepJS creates hidden iframes to access unpolluted prototypes. + // Intercept appendChild/append to patch WebGL in each iframe window. + function _patchIframeWindow(iWin) { + try { + if (!iWin || !iWin.WebGLRenderingContext) return; + if (iWin.__wglPatched) return; + iWin.__wglPatched = true; + _patchWebGL(iWin.WebGLRenderingContext, _wglParams); + if (iWin.WebGL2RenderingContext) _patchWebGL(iWin.WebGL2RenderingContext, _wgl2AllParams); + // Recursively hook appendChild in iframe for nested iframes + _hookAppendChild(iWin); + } catch(e) {} + } + function _scanForIframes(node) { + if (!node) return; + const tag = node.tagName; + if (tag === 'IFRAME') { + try { _patchIframeWindow(node.contentWindow); } catch(e) {} + } + if (node.querySelectorAll) { + try { + node.querySelectorAll('iframe').forEach(function(iframe) { + try { _patchIframeWindow(iframe.contentWindow); } catch(e) {} + }); + } catch(e) {} + } + } + function _collectIframes(node) { + const iframes = []; + if (!node) return iframes; + if (node.tagName === 'IFRAME') iframes.push(node); + if (node.querySelectorAll) { + try { node.querySelectorAll('iframe').forEach(function(f) { iframes.push(f); }); } catch(e) {} + } + return iframes; + } + function _hookAppendChild(win) { + try { + const Proto = win.Node.prototype; + const origAC = Proto.appendChild; + Proto.appendChild = function(node) { + // Collect iframes BEFORE append (DocumentFragment empties after) + const iframes = _collectIframes(node); + const result = origAC.call(this, node); + // After append, contentWindow is available + iframes.forEach(function(f) { try { _patchIframeWindow(f.contentWindow); } catch(e) {} }); + return result; + }; + _nativeFnNames.set(Proto.appendChild, 'appendChild'); + // Also hook insertBefore + const origIB = Proto.insertBefore; + Proto.insertBefore = function(node, ref) { + const iframes = _collectIframes(node); + const result = origIB.call(this, node, ref); + iframes.forEach(function(f) { try { _patchIframeWindow(f.contentWindow); } catch(e) {} }); + return result; + }; + _nativeFnNames.set(Proto.insertBefore, 'insertBefore'); + } catch(e) {} + } + _hookAppendChild(window); + window.__wglPatched = true; + + // --- Screen, viewport, and window dimension spoofs --- + // Ensures consistent dimensions even if Xvfb resolution changes. + // With X11/Xvfb at 1920x1080 these match reality but act as safety net. + + // Screen prototype + const screenDims = { width: 1920, height: 1080, availWidth: 1920, availHeight: 1045 }; + for (const [prop, val] of Object.entries(screenDims)) { + Object.defineProperty(Screen.prototype, prop, { + get: () => val, configurable: true + }); + } + for (const prop of ['colorDepth', 'pixelDepth']) { + Object.defineProperty(Screen.prototype, prop, { + get: () => 24, configurable: true + }); + } + + // Window dimensions — realistic maximized Chrome on 1920x1080 + // outerWidth > innerWidth is normal (scrollbar), outerHeight > innerHeight (chrome UI) + const vpDims = { + outerWidth: 1920, outerHeight: 1040, + innerWidth: 1903, innerHeight: 969, + screenX: 0, screenY: 0, screenLeft: 0, screenTop: 0 }; + for (const [prop, val] of Object.entries(vpDims)) { + Object.defineProperty(window, prop, { + get: () => val, configurable: true + }); + } + + // VisualViewport — matches innerWidth/innerHeight + if (window.visualViewport) { + for (const [prop, val] of Object.entries({ + width: 1903, height: 969, + offsetLeft: 0, offsetTop: 0, + pageLeft: 0, pageTop: 0, scale: 1 + })) { + Object.defineProperty(window.visualViewport, prop, { + get: () => val, configurable: true + }); + } + } + + // ScreenOrientation — 1920x1080 = landscape + if (screen.orientation) { + Object.defineProperty(screen.orientation, 'type', { + get: () => 'landscape-primary', configurable: true + }); + Object.defineProperty(screen.orientation, 'angle', { + get: () => 0, configurable: true + }); + } + + // matchMedia — proxy dimension queries to match our spoofed viewport. + // CSS @media is compositor-side (ozone's real screen), but matchMedia + // is JS-side. We override to be consistent with our viewport spoofs. + const _origMM = window.matchMedia; + const _vw = 1903, _vh = 969, _dw = 1920, _dh = 1080; + window.matchMedia = function(q) { + const r = _origMM.call(window, q); + // Only intercept dimension queries + if (!/(?:width|height)/.test(q)) return r; + // Evaluate query against our dimensions + let m = true; + q.replace(/\(\s*(min-|max-)?(device-)?(width|height)\s*:\s*(\d+)/g, + (_, prefix, device, dim, val) => { + const v = parseInt(val); + const ref = device + ? (dim === 'width' ? _dw : _dh) + : (dim === 'width' ? _vw : _vh); + if (prefix === 'min-') m = m && ref >= v; + else if (prefix === 'max-') m = m && ref <= v; + else m = m && ref === v; + }); + return new Proxy(r, { + get(t, p) { + if (p === 'matches') return m; + const v = t[p]; return typeof v === 'function' ? v.bind(t) : v; + } + }); + }; + + // Register remaining spoofed functions for toString + if (navigator.share) _nativeFnNames.set(navigator.share, 'share'); + if (navigator.canShare) _nativeFnNames.set(navigator.canShare, 'canShare'); + if (navigator.mediaDevices && navigator.mediaDevices.enumerateDevices) { + _nativeFnNames.set(navigator.mediaDevices.enumerateDevices, 'enumerateDevices'); + } + if (window.navigator.permissions.query) { + _nativeFnNames.set(window.navigator.permissions.query, 'query'); + } + if (window.matchMedia) _nativeFnNames.set(window.matchMedia, 'matchMedia'); + + // --- Notification API (some detectors check permission state) --- + if (typeof Notification !== 'undefined') { + Object.defineProperty(Notification, 'permission', { + get: () => 'default', configurable: true + }); + } + + // --- Fix hasKnownBgColor: headless returns rgb(255,0,0) for CSS ActiveText --- + const _origGetCS = window.getComputedStyle; + window.getComputedStyle = function(el, pseudo) { + const result = _origGetCS.call(window, el, pseudo); + if (el && el.getAttribute && el.getAttribute('style')?.includes('ActiveText')) { + return new Proxy(result, { + get(target, prop) { + if (prop === 'backgroundColor') return 'rgb(0, 102, 204)'; + const v = target[prop]; + return typeof v === 'function' ? v.bind(target) : v; + } + }); + } + return result; + }; + _nativeFnNames.set(window.getComputedStyle, 'getComputedStyle'); + + // prefers-color-scheme: no longer overridden. + // Under X11/Xvfb the compositor reports light mode consistently. + // Previously forced dark in matchMedia causing mediaConsistent: false + // (CSS @media hash != matchMedia hash). Removing fixes the mismatch. + + // --- Fix noContentIndex: stub ContentIndex class --- + if (!('ContentIndex' in window)) { + window.ContentIndex = function ContentIndex() {}; + _nativeFnNames.set(window.ContentIndex, 'ContentIndex'); + } + + // --- Fix noContactsManager: stub ContactsManager class --- + if (!('ContactsManager' in window)) { + window.ContactsManager = function ContactsManager() {}; + _nativeFnNames.set(window.ContactsManager, 'ContactsManager'); + } + + // --- Fix noDownlinkMax: mock NetworkInformation.downlinkMax --- + if (navigator.connection) { + Object.defineProperty(navigator.connection, 'downlinkMax', { + get: () => Infinity, + configurable: true + }); + } ''; }; in { + # LD_LIBRARY_PATH for non-nix binaries (Chromium, Electron, downloaded tools). + # 2 layers: + # Layer 1: 06-nix-ldpath.sh entrypoint fragment — sources full closure path at container start. + # Inherited by all services (50-gui.sh), fluxbox apps, xrdp sessions. + # Layer 2: zsh initContent — sources same file for interactive shells (docker exec). + # Fallback: patchright-mcp-cell wrapper — static runtimeLibPath for non-entrypoint contexts. + # + # The activation script (generateNixLdPath) scans the full profile closure at + # home-manager switch time and writes /opt/devcell/.nix-ld-library-path into the image. + # No circular dependency: the scan runs at activation time (after profile is built), + # not at evaluation time. + + # Activation script: scan the profile closure for lib/ dirs containing .so files. + # Output: /opt/devcell/.nix-ld-library-path (baked into image, not on bind mount). + # + # Uses config.home.path (the NEW profile's nix store path) instead of + # config.home.profileDirectory (a symlink that may still point to the old + # generation when this script runs). This avoids DAG ordering issues — + # we can safely run after "writeBoundary" since the store path is resolved + # at nix eval time, not via the runtime symlink. + # + # CRITICAL: exclude glibc from the path. Nix's glibc version differs from the system + # (Debian) glibc. Including it causes system binaries (tr, grep, wc, etc.) to load + # nix's libc.so.6 via LD_LIBRARY_PATH → symbol errors → crashes. + home.activation.generateNixLdPath = lib.hm.dag.entryAfter ["writeBoundary"] '' + _ldPaths="" + for _pkg in $(${pkgs.nix}/bin/nix-store -qR "${config.home.path}"); do + case "$_pkg" in *-glibc-*) continue ;; esac + if [ -d "$_pkg/lib" ] && ls "$_pkg/lib/"*.so* >/dev/null 2>/dev/null; then + _ldPaths="$_ldPaths''${_ldPaths:+:}$_pkg/lib" + fi + done + echo "$_ldPaths" > "$HOME/.nix-ld-library-path" + + # Stable symlink for mesa DRI drivers — avoids hardcoded nix store hash in 50-gui.sh. + # Without this, LIBGL_DRIVERS_PATH becomes stale after mesa version changes. + ln -sfT "${pkgs.mesa}/lib/dri" "$HOME/.mesa-dri" + ''; + + # Source the auto-generated path at shell init for interactive shells (docker exec). + # These don't inherit from the entrypoint, so they need their own export. + programs.zsh.initContent = lib.mkAfter '' + if [ -f "/opt/devcell/.nix-ld-library-path" ]; then + export LD_LIBRARY_PATH="$(cat /opt/devcell/.nix-ld-library-path)''${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" + fi + ''; + # Contribute patchright MCP server to the system-level managed-mcp.json. # Patchright = stealth Playwright fork — patches CDP Runtime.enable, adds # playwright-extra + puppeteer-extra-plugin-stealth (triple stealth stack). @@ -79,11 +667,16 @@ in command = "patchright-mcp-cell"; args = [ "--browser" "chromium" - "--executable-path" "${pkgs.chromium}/bin/chromium" - "--init-script" "${stealthInitScript}" + # No --config or --init-script here: the wrapper auto-discovers them + # from share/patchright/ in the nix profile, which always resolves to + # the latest generation. This avoids nix store hash pinning in MCP args. ]; }; + home.packages = with pkgs; [ + # Audio — PulseAudio with null sink for headless audio (Chromium AudioContext) + pulseaudio # (use: pulseaudio --start --exit-idle-time=-1) + # VNC/RDP server stack — used by entrypoint.sh when DEVCELL_GUI_ENABLED=true x11vnc # VNC server for X11 xrdp # RDP server — gateway to VNC session (entrypoint starts on port 3389) @@ -127,13 +720,18 @@ in libGLU # OpenGL utility library (libglu1-mesa) libtiff # TIFF image library (libtiff5) + # GTK 3 — required by many GUI apps and dialogs + gtk3 + # wxWidgets GUI toolkit (libwxgtk3.2-1, libwxgtk-webview3.2-1) wxGTK32 # wxWidgets 3.2.x; attribute = wxGTK32, pname = "wxwidgets" - # Fonts — required for Chromium and other GUI apps - noto-fonts - dejavu_fonts - nerd-fonts.jetbrains-mono # neobrutalist UI font — fluxbox theme and xterm + # Fonts — required for Chromium and other GUI apps. + # A real desktop has 50+ fonts. Headless environments with < 10 detectable fonts + # are flagged by CreepJS and headless-detector. These provide broad coverage. + + # ── Nerd Fonts (dev monospace with icons) ────────────────────────────── + nerd-fonts.jetbrains-mono # monospace font — xterm and kitty terminal nerd-fonts.fira-code # popular ligature font nerd-fonts.hack # clean monospace nerd-fonts.meslo-lg # macOS Terminal default derivative @@ -143,35 +741,116 @@ in nerd-fonts.roboto-mono # Google monospace nerd-fonts.iosevka # narrow monospace nerd-fonts.victor-mono # cursive italic monospace - inter # geometric sans — fallback UI font + nerd-fonts.inconsolata # classic monospace (Google Fonts) + nerd-fonts.droid-sans-mono # Android-era monospace + nerd-fonts.overpass # Red Hat inspired monospace + nerd-fonts.go-mono # Go programming font + nerd-fonts.anonymice # Anonymous Pro — clean coding font + nerd-fonts.fantasque-sans-mono # whimsical dev font + nerd-fonts.blex-mono # IBM Plex Mono Nerd Font + nerd-fonts.commit-mono # coding font with smart kerning + nerd-fonts.geist-mono # Vercel's monospace font + nerd-fonts.monaspace # GitHub's monospace font family + + # ── Core web fonts (Arial, Times, Verdana, Georgia, etc.) ────────────── + corefonts # MS core web fonts (Arial, Times New Roman, Verdana, Georgia, etc.) + noto-fonts-cjk-sans # CJK (Chinese/Japanese/Korean) — common on real desktops + + # ── System / UI fonts (common on real desktops) ──────────────────────── + noto-fonts # comprehensive Unicode (Noto Sans, Noto Serif) + noto-fonts-color-emoji # color emoji — real browsers have these + dejavu_fonts # DejaVu Sans/Serif/Mono — Linux default + liberation_ttf # Liberation Sans/Serif/Mono — metric-compatible with Arial/Times/Courier + roboto # Google's Android/Material UI font + ubuntu-sans # Ubuntu's system font family + lato # popular Google Font — clean humanist sans + open-sans # widely used Google Font + source-sans # Adobe Source Sans — professional sans-serif + source-serif # Adobe Source Serif — matching serif + ibm-plex # IBM Plex Sans/Serif/Mono — modern corporate + inter # geometric sans — fallback UI font + montserrat # geometric sans — popular for headings + raleway # elegant thin/light sans + work-sans # grotesque sans — Google Fonts + cabin # humanist sans with rounded terminals + cantarell-fonts # GNOME's default UI font + gentium # SIL Gentium — academic serif + comic-neue # Comic Neue — legible comic-style sans + zilla-slab # Mozilla Zilla Slab — bold display slab-serif + + # ── Developer fonts from Google Fonts ───────────────────────────────── + fira-sans # Mozilla Fira Sans — pairs with Fira Code + paratype-pt-sans # ParaType PT Sans — clean Russian/Latin sans + atkinson-hyperlegible # Braille Institute — max readability sans + quicksand # geometric rounded sans — modern UI + dm-sans # DeMarco DM Sans — geometric sans + poppins # geometric sans — popular for modern UIs + rubik # Google Rubik — slightly rounded grotesque + karla # grotesque sans — minimal UI font + barlow # grotesk sans — inspired by California plates + lexend # readability-optimized sans — Google Fonts + fraunces # variable old-style serif — Google Fonts # Patchright MCP wrapper — sets per-app user-data-dir and forwards secrets # from $USER_WORKING_DIR/.env to patchright-mcp via --secrets. # Key names are read from .env; resolved values come from the container env # (injected by docker compose env_file or op run before container start). # Claude sees only key names, never values. - (pkgs.writeShellScriptBin "patchright-mcp-cell" '' - SECRETS_FILE=$(mktemp /tmp/pw-secrets-XXXXXX.env) - trap 'rm -f "$SECRETS_FILE"' EXIT - - # Read key names from .env and look up resolved values from container env. - # Values are resolved before container start (by op run or docker compose env_file). - _ENV_FILE="''${USER_WORKING_DIR:-}/.env" - if [ -f "$_ENV_FILE" ]; then - while IFS= read -r _line || [ -n "$_line" ]; do - case "$_line" in '#'*|''') continue ;; esac - _key="''${_line%%=*}" - _key="''${_key#export }" - [ -z "$_key" ] && continue - if _val=$(printenv "$_key" 2>/dev/null); then - printf '%s=%s\n' "$_key" "$_val" - fi - done < "$_ENV_FILE" >> "$SECRETS_FILE" - fi + # Bundle wrapper + config + init-script in one derivation so + # readlink -f bin/patchright-mcp-cell → ../share/patchright/ always works. + # When nix rebuilds, the whole derivation gets a new hash, and PATH + # (via the profile symlink chain) resolves to the latest version. + # No hardcoded nix store hashes leak into MCP args. + (let + wrapperScript = pkgs.writeShellScript "patchright-mcp-cell-inner" '' + export LD_LIBRARY_PATH="${runtimeLibPath}''${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" + # Mesa Lavapipe — software Vulkan ICD for WebGL via ANGLE→Vulkan→lvp + export VK_ICD_FILENAMES="${pkgs.mesa.drivers}/share/vulkan/icd.d/lvp_icd.${pkgs.stdenv.hostPlatform.uname.processor}.json" + + # Always use config and init-script from co-located share/ dir. + # Strip any stale --config/--init-script from caller args (e.g. Claude Code + # caching old nix store hashes) so the bundled versions always win. + _SELF="$(readlink -f "$0")" + _SHARE="$(dirname "$(dirname "$_SELF")")/share/patchright" + _CLEAN_ARGS=() + _skip=false + for _a in "$@"; do + if $_skip; then _skip=false; continue; fi + case "$_a" in + --config|--init-script) _skip=true; continue ;; + esac + _CLEAN_ARGS+=("$_a") + done + set -- "''${_CLEAN_ARGS[@]}" + _EXTRA_ARGS=() + [ -f "$_SHARE/config.json" ] && _EXTRA_ARGS+=(--config "$_SHARE/config.json") + [ -f "$_SHARE/stealth-init.js" ] && _EXTRA_ARGS+=(--init-script "$_SHARE/stealth-init.js") + + SECRETS_FILE=$(mktemp /tmp/pw-secrets-XXXXXX.env) + trap 'rm -f "$SECRETS_FILE"' EXIT + + _ENV_FILE="''${USER_WORKING_DIR:-}/.env" + if [ -f "$_ENV_FILE" ]; then + while IFS= read -r _line || [ -n "$_line" ]; do + [[ -z "$_line" || "$_line" == '#'* ]] && continue + _key="''${_line%%=*}" + _key="''${_key#export }" + [ -z "$_key" ] && continue + if _val=$(printenv "$_key" 2>/dev/null); then + printf '%s=%s\n' "$_key" "$_val" + fi + done < "$_ENV_FILE" >> "$SECRETS_FILE" + fi - # No exec: keep shell alive so EXIT trap fires after mcp-server-patchright terminates. - USER_DATA_DIR="''${PLAYWRIGHT_MCP_USER_DATA_DIR:-$HOME/.playwright-''${APP_NAME:-cell}}" - mcp-server-patchright --user-data-dir "$USER_DATA_DIR" --secrets "$SECRETS_FILE" "$@" + USER_DATA_DIR="''${PLAYWRIGHT_MCP_USER_DATA_DIR:-$HOME/.playwright-''${APP_NAME:-cell}}" + mcp-server-patchright --no-sandbox --user-data-dir "$USER_DATA_DIR" --secrets "$SECRETS_FILE" "''${_EXTRA_ARGS[@]}" "$@" + ''; + in pkgs.runCommandLocal "patchright-mcp-cell" {} '' + mkdir -p $out/bin $out/share/patchright + cp ${wrapperScript} $out/bin/patchright-mcp-cell + chmod +x $out/bin/patchright-mcp-cell + cp ${patchrightConfig} $out/share/patchright/config.json + cp ${stealthInitScript} $out/share/patchright/stealth-init.js '') ]; @@ -336,156 +1015,21 @@ in ".fluxbox/apps".text = '' [app] (name=.*) [Tab] {no} - [Deco] {1087} + [Deco] {NORMAL} [end] ''; + # ── Entrypoint fragment: LD_LIBRARY_PATH from full nix closure ────────── + # Sourced by entrypoint.sh BEFORE 50-gui.sh. All services inherit this export. + ".config/devcell/entrypoint.d/06-nix-ldpath.sh" = { + executable = true; + source = ../fragments/06-nix-ldpath.sh; + }; + # ── Entrypoint fragment: GUI service startup ──────────────────────────── # Sourced by entrypoint.sh from /etc/devcell/entrypoint.d/ at container start. ".config/devcell/entrypoint.d/50-gui.sh" = { - executable = true; - text = '' - #!/bin/bash - # 50-gui.sh — GUI service startup (Xvfb, fluxbox, x11vnc, xrdp) - # Sourced by entrypoint.sh if present and executable. - - [ "$DEVCELL_GUI_ENABLED" = "true" ] || return 0 - - # Ensure DBUS machine-id exists (Kitty/GTK apps need it) - [ -f /etc/machine-id ] || dbus-uuidgen > /etc/machine-id 2>/dev/null || true - - DISPLAY_NUM=99 - RESOLUTION=1920x1080x24 - - mkdir -p /tmp/.X11-unix - chmod 1777 /tmp/.X11-unix - - # Mesa llvmpipe software rendering — enables GLX for GPU terminals (Kitty etc.) - export LIBGL_ALWAYS_SOFTWARE=1 - export GALLIUM_DRIVER=llvmpipe - export LIBGL_DRIVERS_PATH=${pkgs.mesa}/lib/dri - export LD_LIBRARY_PATH=${pkgs.mesa}/lib''${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH} - - log "Starting Xvfb on display :''${DISPLAY_NUM} (+GLX, Mesa llvmpipe)..." - gosu "$USER" Xvfb :''${DISPLAY_NUM} -screen 0 ''${RESOLUTION} -dpi 96 +extension GLX +render +iglx 2>/dev/null & - export DISPLAY=:''${DISPLAY_NUM} - # Wait for X server to accept connections (socket file appears before server is ready) - for i in $(seq 1 40); do - xset -display :''${DISPLAY_NUM} q >/dev/null 2>&1 && break - sleep 0.05 - done - - # Load X resources (xterm dark theme, cursor color, fonts) - # Deferred via background process: xrdb ChangeProperty requests sent from - # the entrypoint's PID 1 context are silently dropped by Xvfb. Running - # xrdb from a detached process after exec gosu replaces PID 1 works. - if [ -f "$DEVCELL_HOME/.Xresources" ]; then - (sleep 1; xrdb -display :''${DISPLAY_NUM} -merge "$DEVCELL_HOME/.Xresources" 2>/dev/null) & - disown - fi - - if [ -f "$DEVCELL_HOME/.fluxbox/wallpaper.png" ]; then - gosu "$USER" feh --bg-fill "$DEVCELL_HOME/.fluxbox/wallpaper.png" 2>/dev/null || true - else - gosu "$USER" xsetroot -solid '#1e1e2e' 2>/dev/null || true - fi - - FLUXBOX_RC=/tmp/fluxbox-init - cp "$DEVCELL_HOME/.fluxbox/init" "$FLUXBOX_RC" - chmod u+w "$FLUXBOX_RC" - WORKSPACE_NAME=" ''${APP_NAME:-cell} " - if grep -q "session.screen0.workspaceNames" "$FLUXBOX_RC"; then - sed -i "s/^session.screen0.workspaceNames:.*/session.screen0.workspaceNames: ''${WORKSPACE_NAME}/" "$FLUXBOX_RC" - else - echo "session.screen0.workspaceNames: ''${WORKSPACE_NAME}" >> "$FLUXBOX_RC" - fi - log "Starting fluxbox (workspace: ''${WORKSPACE_NAME})..." - gosu "$USER" fluxbox -rc "$FLUXBOX_RC" &>/dev/null & - # Poll for fluxbox process instead of fixed sleep 1 - for i in $(seq 1 20); do - pgrep -u "$USER" fluxbox >/dev/null 2>&1 && break - sleep 0.05 - done - - if [ -f "$DEVCELL_HOME/.fluxbox/wallpaper.png" ]; then - gosu "$USER" feh --bg-fill "$DEVCELL_HOME/.fluxbox/wallpaper.png" 2>/dev/null || true - fi - - log "Starting x11vnc on port 5900..." - gosu "$USER" x11vnc -display :''${DISPLAY_NUM} -forever -nevershared -passwd vnc -rfbport 5900 \ - -desktop "''${APP_NAME:-cell}" -pointer_mode 2 -repeat -xrandr &>/dev/null & - - log "VNC server ready - connect to localhost:''${EXT_VNC_PORT:-5900}" - log "DISPLAY=:''${DISPLAY_NUM}" - - # ── xrdp (RDP gateway to existing VNC session) ──────────────────────── - XRDP_BIN=$(command -v xrdp 2>/dev/null) - if [ -n "$XRDP_BIN" ]; then - XRDP_CFG="/tmp/xrdp" - mkdir -p "$XRDP_CFG" - XRDP_PREFIX=$(dirname "$(dirname "$(readlink -f "$XRDP_BIN")")") - - # Copy default configs from nix store (read-only) to writable dir - cp -a "$XRDP_PREFIX/etc/xrdp/"* "$XRDP_CFG/" 2>/dev/null || true - chmod u+w "$XRDP_CFG/"* 2>/dev/null || true - - # Pre-generate RSA keys so xrdp can read them at startup - # (without this, xrdp fails with "cannot read rsakeys.ini" on first connect) - xrdp-keygen xrdp "$XRDP_CFG/rsakeys.ini" 2>/dev/null || true - - # Generate self-signed SSL cert in global config dir - # (survives container restarts via ~/.config/devcell/ bind mount at /etc/devcell/config/) - XRDP_CERT_DIR="/etc/devcell/config/xrdp" - mkdir -p "$XRDP_CERT_DIR" - if [ ! -f "$XRDP_CERT_DIR/key.pem" ]; then - openssl req -x509 -newkey rsa:2048 -nodes \ - -keyout "$XRDP_CERT_DIR/key.pem" -out "$XRDP_CERT_DIR/cert.pem" \ - -days 365 -subj "/CN=devcell" 2>/dev/null - fi - - # Patch xrdp.ini: port, SSL, autorun, logging to file only - # DEVCELL_DEBUG=true → INFO logs; otherwise WARNING only - if [ "$DEVCELL_DEBUG" = "true" ]; then - XRDP_LOG_LEVEL="INFO" - else - XRDP_LOG_LEVEL="WARNING" - fi - sed -i \ - -e "s|^port=.*|port=3389|" \ - -e "s|^certificate=.*|certificate=$XRDP_CERT_DIR/cert.pem|" \ - -e "s|^key_file=.*|key_file=$XRDP_CERT_DIR/key.pem|" \ - -e "s|^autorun=.*|autorun=vnc-any|" \ - -e "s|^max_bpp=.*|max_bpp=24|" \ - -e "s|^LogFile=.*|LogFile=/var/log/xrdp.log|" \ - -e "s|^LogLevel=.*|LogLevel=$XRDP_LOG_LEVEL|" \ - -e "s|^#*EnableSyslog=.*|EnableSyslog=false|" \ - "$XRDP_CFG/xrdp.ini" - - # Remove stock [Xorg] section (has username=ask which forces login - # prompt even with autorun). Keep only our [vnc-any] with hardcoded - # creds so xrdp auto-connects without asking. - sed -i '/^\[Xorg\]/,/^\[/{ /^\[vnc-any\]/!d; }' "$XRDP_CFG/xrdp.ini" - - # Replace [vnc-any] section — hardcoded creds skip login prompt - sed -i '/^\[vnc-any\]/,$d' "$XRDP_CFG/xrdp.ini" - { - echo '[vnc-any]' - echo 'name=VNC' - echo 'lib=libvnc.so' - echo 'ip=127.0.0.1' - echo 'port=5900' - echo "username=''${HOST_USER}" - echo 'password=vnc' - echo 'xserverbpp=24' - } >> "$XRDP_CFG/xrdp.ini" - - log "Starting xrdp on port 3389 (RDP → VNC :''${DISPLAY_NUM})..." - xrdp --nodaemon --config "$XRDP_CFG/xrdp.ini" >>/var/log/xrdp.log 2>&1 & - - log "xrdp ready - connect to localhost:''${EXT_RDP_PORT:-3389}" - else - log "xrdp not found — skipping RDP server" - fi - ''; + executable = true; + source = ../fragments/50-gui.sh; }; } // pixmaps; } diff --git a/nixhome/modules/desktop/themes/main/theme.nix b/nixhome/modules/desktop/themes/main/theme.nix index 55e9af11..bc9447f3 100644 --- a/nixhome/modules/desktop/themes/main/theme.nix +++ b/nixhome/modules/desktop/themes/main/theme.nix @@ -26,13 +26,16 @@ let }; f = rec { + # UI font — used for toolbar, window titles, menus (like Pop!_OS) + ui = "Fira Sans"; + uiSm = "${ui}-10"; + uiSmSB = "${ui}-10:semibold"; + uiMd = "${ui}-11"; + uiMdB = "${ui}-11:bold"; + uiLg = "${ui}-12"; + uiXlB = "${ui}-14:bold"; + # Mono font — used for terminal (xterm, kitty) family = "JetBrainsMono Nerd Font"; - sm = "${family}-9"; - smB = "${family}-9:bold"; - md = "${family}-10"; - mdB = "${family}-10:bold"; - lg = "${family}-11"; - xlB = "${family}-13:bold"; }; # ── Fluxbox theme generator ───────────────────────────────────────────── @@ -55,7 +58,7 @@ let "toolbar.borderWidth" = 0; "toolbar.clock" = "flat"; "toolbar.clock.color" = c.border; - "toolbar.clock.font" = f.smB; + "toolbar.clock.font" = f.uiSmSB; "toolbar.clock.textColor" = c.textBright; "toolbar.color" = c.border; "toolbar.height" = s.toolbarHeight; @@ -64,16 +67,16 @@ let "toolbar.iconbar.focused" = "flat"; "toolbar.iconbar.focused.borderWidth" = 0; "toolbar.iconbar.focused.color" = c.raised; - "toolbar.iconbar.focused.font" = f.smB; + "toolbar.iconbar.focused.font" = f.uiSmSB; "toolbar.iconbar.focused.textColor" = c.textBright; "toolbar.iconbar.unfocused" = "flat"; "toolbar.iconbar.unfocused.borderWidth" = 0; "toolbar.iconbar.unfocused.color" = c.border; - "toolbar.iconbar.unfocused.font" = f.smB; + "toolbar.iconbar.unfocused.font" = f.uiSmSB; "toolbar.iconbar.unfocused.textColor" = c.inactive; "toolbar.workspace" = "flat"; "toolbar.workspace.color" = c.highlight; - "toolbar.workspace.font" = f.smB; + "toolbar.workspace.font" = f.uiSmSB; "toolbar.workspace.justify" = "center"; "toolbar.workspace.textColor" = c.border; }; @@ -97,7 +100,7 @@ let "window.iconify.pressed.pixmap" = "pixmaps/min.xpm"; "window.iconify.unfocus.pixmap" = "pixmaps/min_unfocus.xpm"; "window.label.focus" = "parentrelative"; - "window.label.focus.font" = f.mdB; + "window.label.focus.font" = f.uiMdB; "window.label.focus.justify" = "left"; "window.label.focus.textColor" = c.text; "window.maximize.pixmap" = "pixmaps/max.xpm"; @@ -117,7 +120,7 @@ let "window.handle.unfocus" = "flat"; "window.handle.unfocus.color" = c.raised; "window.label.unfocus" = "parentrelative"; - "window.label.unfocus.font" = f.md; + "window.label.unfocus.font" = f.uiMd; "window.label.unfocus.textColor" = c.inactive; "window.title.unfocus" = "flat"; "window.title.unfocus.color" = c.surface; @@ -140,7 +143,7 @@ let "menu.frame" = "flat"; "menu.frame.color" = c.surface; "menu.frame.disableColor" = c.inactive; - "menu.frame.font" = f.lg; + "menu.frame.font" = f.uiLg; "menu.frame.justify" = "left"; "menu.frame.textColor" = c.textBright; "menu.hilite" = "flat"; @@ -149,7 +152,7 @@ let "menu.itemHeight" = 28; "menu.title" = "flat"; "menu.title.color" = c.highlight; - "menu.title.font" = f.xlB; + "menu.title.font" = f.uiXlB; "menu.title.justify" = "left"; "menu.title.textColor" = c.border; "menu.titleHeight" = 32; diff --git a/nixhome/modules/entrypoint.nix b/nixhome/modules/entrypoint.nix deleted file mode 100644 index bc7c7c42..00000000 --- a/nixhome/modules/entrypoint.nix +++ /dev/null @@ -1,22 +0,0 @@ -# entrypoint.nix — stage nix-generated entrypoint fragments to /etc/devcell/entrypoint.d/ -# -# Any module can drop a fragment into ~/.config/devcell/entrypoint.d/ via home.file: -# home.file.".config/devcell/entrypoint.d/50-gui.sh" = { executable = true; text = ''...''; }; -# -# This activation script copies them to /etc/devcell/entrypoint.d/ where the base -# entrypoint sources them at container startup. -# -# Numbering convention: -# 10-* — early setup (future: mise extraction) -# 50-* — services (GUI, xrdp) -# 90-* — late setup (future: custom user hooks) -{lib, ...}: { - home.activation.stageEntrypoints = lib.hm.dag.entryAfter ["writeBoundary"] '' - export PATH="/usr/bin:/bin:$PATH" - $DRY_RUN_CMD sudo mkdir -p /etc/devcell/entrypoint.d - if [ -d "$HOME/.config/devcell/entrypoint.d" ]; then - $DRY_RUN_CMD sudo cp -f "$HOME/.config/devcell/entrypoint.d/"*.sh /etc/devcell/entrypoint.d/ 2>/dev/null || true - $DRY_RUN_CMD sudo chmod +x /etc/devcell/entrypoint.d/*.sh 2>/dev/null || true - fi - ''; -} diff --git a/nixhome/modules/financial.nix b/nixhome/modules/financial.nix new file mode 100644 index 00000000..9dada3fd --- /dev/null +++ b/nixhome/modules/financial.nix @@ -0,0 +1,104 @@ +# financial.nix — Financial data MCP servers +{pkgs, ...}: let + py = pkgs.python312Packages; + + # httpxthrottlecache: rate-limiting + caching httpx wrapper (edgartools dep, not in nixpkgs) + httpxthrottlecache = py.buildPythonPackage { + pname = "httpxthrottlecache"; + version = "0.3.5"; + src = pkgs.fetchPypi { + pname = "httpxthrottlecache"; + version = "0.3.5"; + hash = "sha256-7aNhG5/L8eIEYZFpX7koVfc/wzOnwEDTZgqsEbmmDB8="; + }; + pyproject = true; + build-system = [py.hatchling]; + postPatch = '' + substituteInPlace pyproject.toml \ + --replace-fail 'dynamic = ["version"]' 'version = "0.3.5"' + sed -i '/uv-dynamic-versioning/d' pyproject.toml + ''; + dependencies = with py; [aiofiles filelock httpx pyrate-limiter]; + pythonRelaxDeps = ["pyrate-limiter"]; # nixpkgs has 3.9.0, needs >=4.0.0 + doCheck = false; + }; + + # yahoo-finance-mcp: stock prices, financials, news, options via yfinance (no API key) + yahooFinanceMcp = py.buildPythonApplication { + pname = "yahoo-finance-mcp"; + version = "0-unstable-2026-03-19"; + src = pkgs.fetchFromGitHub { + owner = "Alex2Yang97"; + repo = "yahoo-finance-mcp"; + rev = "81dcf33d69a8808696820e55373028741584b1ae"; + hash = "sha256-QKizB4zU9KfojK6wkyEtXfp8Pl+htlJCZmO2YHhSfnY="; + }; + pyproject = true; + build-system = [py.hatchling]; + dependencies = with py; [mcp yfinance]; + doCheck = false; + }; + + # edgartools: SEC filings (10-K, 10-Q, 8-K, 13F), XBRL financials, insider trades (no API key) + # Needs SEC_EDGAR_IDENTITY="Name email@example.com" env var at runtime. + edgartoolsMcp = py.buildPythonApplication { + pname = "edgartools"; + version = "0-unstable-2026-03-19"; + src = pkgs.fetchFromGitHub { + owner = "dgunning"; + repo = "edgartools"; + rev = "07060ec91d4d2d2a14020f3eb76a7a0b46a7eab3"; + hash = "sha256-3+Ji/zFmhzixugpTiruAN8zJKYEc14Vbw0dHyocQoe8="; + }; + pyproject = true; + build-system = [py.hatchling]; + dependencies = with py; [ + # core + httpx pandas tabulate pyarrow beautifulsoup4 lxml rich humanize + stamina orjson textdistance rank-bm25 rapidfuzz unidecode + pydantic tqdm nest-asyncio jinja2 pyrate-limiter truststore + httpxthrottlecache + # ai extras (needed for MCP server) + mcp tiktoken starlette uvicorn + ]; + doCheck = false; + }; + + # mcp-fredapi: 800K+ FRED economic time series — GDP, CPI, rates, etc. + # Requires FRED_API_KEY env var (free at https://fred.stlouisfed.org/docs/api/api_key.html) + # No [build-system] in upstream pyproject.toml — use writeShellScriptBin wrapper. + fredapiSrc = pkgs.fetchFromGitHub { + owner = "Jaldekoa"; + repo = "mcp-fredapi"; + rev = "000208215cedfc523ef98336db77c54c47ddc2c0"; + hash = "sha256-JdKfjrXKfWNPCtRB6G371hCM2UfKx3PubIbYmQXYv4Y="; + }; + fredapiMcp = pkgs.writeShellScriptBin "mcp-fredapi" '' + exec ${ + (pkgs.python312.withPackages (ps: with ps; [httpx mcp python-dotenv pydantic])) + }/bin/python ${fredapiSrc}/server.py "$@" + ''; +in { + home.packages = [ + yahooFinanceMcp # Yahoo Finance MCP (stocks, news, options — no API key) + edgartoolsMcp # SEC EDGAR MCP (filings, financials — no API key) + fredapiMcp # FRED MCP (economic data — free API key) + ]; + + devcell.managedMcp.servers."yahoo-finance" = { + command = "yahoo-finance-mcp"; + args = []; + }; + + devcell.managedMcp.servers."edgartools" = { + command = "edgartools-mcp"; + args = []; + # Set SEC_EDGAR_IDENTITY="Your Name your@email.com" in environment + }; + + devcell.managedMcp.servers."mcp-fredapi" = { + command = "mcp-fredapi"; + args = []; + # Set FRED_API_KEY in environment (free: https://fred.stlouisfed.org/docs/api/api_key.html) + }; +} diff --git a/nixhome/modules/fragments/05-shell-rc.sh b/nixhome/modules/fragments/05-shell-rc.sh new file mode 100755 index 00000000..126516c6 --- /dev/null +++ b/nixhome/modules/fragments/05-shell-rc.sh @@ -0,0 +1,37 @@ +#!/bin/bash +# 05-shell-rc.sh — shell rc files + nix profile for session user +# Sourced by entrypoint.sh if present and executable. + +# ── Nix profile symlink ────────────────────────────────────────────── +# nix.sh resolves $HOME/.nix-profile to find nix tools. Symlink so the +# session user gets nix in PATH without running home-manager. +ln -sfT "$(readlink -f /opt/devcell/.nix-profile)" "$HOME/.nix-profile" + +# ── Shell rc files — source from /opt/devcell (always current) ─────── +# /opt/devcell/.zshenv etc. are home-manager symlinks → nix store. +# Sourcing the stable path means hm-session-vars.sh (with LD_LIBRARY_PATH, +# GOPATH, etc.) always reflects the current generation — no stale hashes. +for file in .bashrc .zshrc .zshenv .profile; do + [ -f "$DEVCELL_HOME/$file" ] || continue + cat > "$HOME/$file" </dev/null)" +export MISE_NODE_DEFAULT_PACKAGES_FILE="$(readlink -f "$DEVCELL_HOME/.default-npm-packages" 2>/dev/null)" +export NIX_CONF_DIR="/opt/devcell/.config/nix" +export STARSHIP_CONFIG="/opt/devcell/.config/starship.toml" +export FONTCONFIG_PATH="/opt/devcell/.config/fontconfig" +export HISTFILE="$HOME/.zsh_history" +export PATH="$HOME/go/bin:/opt/devcell/.local/state/nix/profiles/profile/bin:$HOME/.local/share/mise/shims:/opt/python-tools/.venv/bin:/opt/npm-tools/node_modules/.bin\${PATH:+:}\${PATH}" +# LD_LIBRARY_PATH from full nix profile closure (docker exec sessions don't +# inherit PID 1's env, so each shell must source the file independently). +_NLD="/opt/devcell/.nix-ld-library-path" +if [ -f "\$_NLD" ]; then + export LD_LIBRARY_PATH="\$(cat "\$_NLD")\${LD_LIBRARY_PATH:+:}\${LD_LIBRARY_PATH}" +fi +RCEOF + chown "$HOST_USER" "$HOME/$file" +done diff --git a/nixhome/modules/fragments/06-nix-ldpath.sh b/nixhome/modules/fragments/06-nix-ldpath.sh new file mode 100755 index 00000000..fff7cbe3 --- /dev/null +++ b/nixhome/modules/fragments/06-nix-ldpath.sh @@ -0,0 +1,7 @@ +#!/bin/bash +# 06-nix-ldpath.sh — export LD_LIBRARY_PATH from full nix profile closure +# Sourced by entrypoint.sh before 50-gui.sh. All services inherit this. +_NLD="/opt/devcell/.nix-ld-library-path" +if [ -f "$_NLD" ]; then + export LD_LIBRARY_PATH="$(cat "$_NLD")${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" +fi diff --git a/nixhome/modules/fragments/10-mise.sh b/nixhome/modules/fragments/10-mise.sh new file mode 100755 index 00000000..856affce --- /dev/null +++ b/nixhome/modules/fragments/10-mise.sh @@ -0,0 +1,105 @@ +#!/bin/bash +# 10-mise.sh — mise runtime version manager setup +# Sourced by entrypoint.sh if present and executable. + +command -v mise &>/dev/null || return 0 + +# ── Copy .tool-versions to session user home ───────────────────────── +# Written to /etc/devcell/tool-versions by nix activation (no dangling symlinks). +# Always overwrite — persistent $HOME may have a dangling symlink from a +# previous home-manager generation. cp refuses to write through dangling +# symlinks, so remove first (DIMM-24). +if [ -f /etc/devcell/tool-versions ]; then + [ -L "$HOME/.tool-versions" ] && rm -f "$HOME/.tool-versions" + cp /etc/devcell/tool-versions "$HOME/.tool-versions" + chown "$HOST_USER" "$HOME/.tool-versions" +fi +# mise config and default-npm-packages are handled via env vars +# (MISE_GLOBAL_CONFIG_FILE, MISE_NODE_DEFAULT_PACKAGES_FILE) set in shell rc overrides. + +# ── Setup ~/.local/share/mise (user-persisted MISE_DATA_DIR) ───────── +# /opt/mise holds baked-in installs (ephemeral, reset on each container start). +# ~/.local/share/mise (CellHome, bind-mounted) holds user-installed versions +# that persist. Baked-in versions are symlinked per-version so mise resolves +# both through a single MISE_DATA_DIR without copying data. +setup_mise_home() { + local baked="/opt/mise" + local user_mise="$HOME/.local/share/mise" + local mise_bin="/opt/devcell/.local/state/nix/profiles/profile/bin/mise" + + mkdir -p "$user_mise/installs" "$user_mise/shims" + + # Symlink each baked-in tool version individually so user installs can coexist + # as real directories alongside the symlinks. + for tool_dir in "$baked/installs"/*/; do + [ -d "$tool_dir" ] || continue + tool_name=$(basename "$tool_dir") + mkdir -p "$user_mise/installs/$tool_name" + + # Remove dangling symlinks left by superseded image versions. + # Use * (not */) so dangling symlinks (no live target) are included. + for link in "$user_mise/installs/$tool_name"/*; do + if [ -L "$link" ] && [ ! -e "$link" ]; then rm -f "$link"; fi + done + + # Symlink current baked-in versions (skip real dirs — user installs). + for ver_dir in "$tool_dir"*/; do + [ -d "$ver_dir" ] || continue + ver_name=$(basename "$ver_dir") + dest="$user_mise/installs/$tool_name/$ver_name" + # Never overwrite a real directory (user-installed version). + [ -d "$dest" ] && [ ! -L "$dest" ] && continue + ln -sfT "$ver_dir" "$dest" + done + done + + chown -R "$HOST_USER" "$user_mise" + + # Regenerate shims for all currently visible installs. + MISE_DATA_DIR="$user_mise" HOME="$HOME" "$mise_bin" reshim 2>/dev/null || true + + # Install any versions listed in ~/.tool-versions that aren't baked. + # Skips if the file hasn't changed since the last successful install + # (checksum stored in mise data dir). First start or edits trigger a full check. + if [ -f "$HOME/.tool-versions" ]; then + local tv_sha + tv_sha=$(sha256sum "$HOME/.tool-versions" 2>/dev/null | cut -d' ' -f1) + if [ -f "$user_mise/.tv-global.sha" ] && [ "$(cat "$user_mise/.tv-global.sha" 2>/dev/null)" = "$tv_sha" ]; then + log "Global .tool-versions unchanged, skipping install" + else + log "Installing global tool versions from ~/.tool-versions..." + (cd "$HOME" && MISE_DATA_DIR="$user_mise" HOME="$HOME" USER="$HOST_USER" \ + "$mise_bin" install -y 2>&1) | while IFS= read -r line; do log "$line"; done || true + chown -R "$HOST_USER" "$user_mise" + echo "$tv_sha" > "$user_mise/.tv-global.sha" + fi + fi + + # If the workspace has a .tool-versions, install any missing versions now so + # they land in ~/.local/share/mise (CellHome) and persist — no re-download on next start. + local workspace="/${APP_NAME:-}" + if [ -n "$APP_NAME" ] && [ -f "$workspace/.tool-versions" ]; then + local ws_sha + ws_sha=$(sha256sum "$workspace/.tool-versions" 2>/dev/null | cut -d' ' -f1) + if [ -f "$user_mise/.tv-workspace.sha" ] && [ "$(cat "$user_mise/.tv-workspace.sha" 2>/dev/null)" = "$ws_sha" ]; then + log "Workspace .tool-versions unchanged, skipping install" + else + log "Installing workspace tool versions from $workspace/.tool-versions..." + MISE_DATA_DIR="$user_mise" "$mise_bin" trust "$workspace/.tool-versions" 2>/dev/null || true + (cd "$workspace" && MISE_DATA_DIR="$user_mise" HOME="$HOME" USER="$HOST_USER" \ + "$mise_bin" install -y 2>&1) | while IFS= read -r line; do log "$line"; done || true + chown -R "$HOST_USER" "$user_mise" + echo "$ws_sha" > "$user_mise/.tv-workspace.sha" + fi + fi +} +setup_mise_home + +# ── Mise env exports ───────────────────────────────────────────────── +# Ensure mise env vars are correct for exec'd processes (e.g. claude) +# that don't source shell rc files and would otherwise inherit the container ENV +# which still points at the ephemeral /opt/mise. +export MISE_DATA_DIR="${HOME}/.local/share/mise" +export MISE_GLOBAL_CONFIG_FILE="$(readlink -f "$DEVCELL_HOME/.config/mise/config.toml" 2>/dev/null)" +export MISE_NODE_DEFAULT_PACKAGES_FILE="$(readlink -f "$DEVCELL_HOME/.default-npm-packages" 2>/dev/null)" +export PATH="${HOME}/.local/share/mise/shims:${PATH}" diff --git a/nixhome/modules/fragments/20-homedir.sh b/nixhome/modules/fragments/20-homedir.sh new file mode 100755 index 00000000..a9a15c3e --- /dev/null +++ b/nixhome/modules/fragments/20-homedir.sh @@ -0,0 +1,22 @@ +#!/bin/bash +# 20-homedir.sh — homedir setup (nix config, starship, repo homedir, browser env) +# Sourced by entrypoint.sh if present and executable. + +# ── Nix-managed configs: NO COPY, use env vars ───────────────────── +# nix.conf, starship.toml, fontconfig, mise config are all nix-managed +# (symlinks → nix store in /opt/devcell/). NEVER copy them to $HOME — +# they'd become stale on the persistent bind mount after image rebuilds. +# Instead, env vars in shell rc overrides point tools at /opt/devcell/: +# NIX_CONF_DIR, STARSHIP_CONFIG, FONTCONFIG_PATH (DIMM-24). +export FONTCONFIG_PATH="$DEVCELL_HOME/.config/fontconfig" + +# ── Copy from repo's homedir/ (project-specific overrides) ────────── +if [ -d "$REPO_HOMEDIR" ]; then + log "Syncing repo homedir to ~/ (ignore existing, exclude .claude)" + rsync -a --copy-links --ignore-existing --exclude=.claude \ + --chown="$HOST_USER" "$REPO_HOMEDIR/" "$HOME/" +fi + +# ── Browser environment ───────────────────────────────────────────── +export CHROMIUM_PROFILE_PATH="${HOME}/.chrome-${APP_NAME:-cell}" +export PLAYWRIGHT_MCP_USER_DATA_DIR="${HOME}/.playwright-${APP_NAME:-cell}" diff --git a/nixhome/modules/fragments/30-claude.sh b/nixhome/modules/fragments/30-claude.sh new file mode 100755 index 00000000..bb570dab --- /dev/null +++ b/nixhome/modules/fragments/30-claude.sh @@ -0,0 +1,142 @@ +#!/bin/bash +# 30-claude.sh — Claude Code merge logic (nix-generated entrypoint fragment) +# Sourced by entrypoint.sh; has access to: $HOME, $HOST_USER, $USER, $DEVCELL_HOME, log() + +merge_claude_settings() { + local template_file="$1" target_file="$2" + [ -f "$template_file" ] || return 1 + mkdir -p "$(dirname "$target_file")" + if [ ! -f "$target_file" ]; then + log "Creating Claude settings from template" + cp "$template_file" "$target_file" + return 0 + fi + local backup_file="${target_file}.backup-$(date +%Y%m%d-%H%M%S)" + cp "$target_file" "$backup_file" + log "✓ Created backup: $(basename "$backup_file")" + ls -t "${target_file}.backup-"* 2>/dev/null | tail -n +6 | xargs rm -f 2>/dev/null || true + log "Merging Claude settings (preserving existing configuration)" + local temp_file=$(mktemp) + jq -s ' + if .[0].hooks.PermissionRequest then .[0] + else .[0] * .[1] + end + ' "$target_file" "$template_file" > "$temp_file" 2>/dev/null + if [ $? -eq 0 ] && [ -s "$temp_file" ] && jq empty "$temp_file" 2>/dev/null; then + mv "$temp_file" "$target_file" + grep -q "PermissionRequest" "$target_file" \ + && log "✓ Claude settings updated (PermissionRequest hook configured)" \ + || log "✓ Claude settings preserved (custom PermissionRequest hook detected)" + else + echo "⚠ Failed to merge Claude settings, restoring from backup" + cp "$backup_file" "$target_file" + rm -f "$temp_file" + fi +} + +merge_claude_nix() { + local nix_hooks_dir="/etc/claude-code/hooks" + local nix_settings="/etc/claude-code/nix-settings.json" + if [ -d "$nix_hooks_dir" ] && [ -n "$(ls -A "$nix_hooks_dir" 2>/dev/null)" ]; then + rsync -a --chmod=+x --chown="$HOST_USER" --delete \ + "$nix_hooks_dir/" "$HOME/.claude/hooks/" + log "✓ Claude hooks synced from nix" + fi + if [ -f "$nix_settings" ]; then + merge_claude_settings "$nix_settings" "$HOME/.claude/settings.json" + fi +} + +merge_claude_mcp() { + local target_file="$1" + local nix_file="/etc/claude-code/nix-mcp-servers.json" + + # No nix MCP servers configured — nothing to do. + [ -f "$nix_file" ] || return 0 + + # Validate nix source file before touching anything. + if ! jq empty "$nix_file" 2>/dev/null; then + echo "⚠ nix-mcp-servers.json is invalid JSON — skipping MCP merge" + return 1 + fi + + local backup_before_merge + backup_before_merge=$(jq -r '.backupBeforeMerge // true' "$nix_file") + + mkdir -p "$(dirname "$target_file")" + + # Fresh start — no existing ~/.claude.json. + if [ ! -f "$target_file" ]; then + log "Creating ~/.claude.json with nix MCP servers" + local temp_file + temp_file=$(mktemp) + jq '{mcpServers: (.mcpServers // {})}' "$nix_file" > "$temp_file" + if [ -s "$temp_file" ] && jq empty "$temp_file" 2>/dev/null; then + mv "$temp_file" "$target_file" + log "✓ ~/.claude.json created ($(jq '.mcpServers | length' "$target_file") server(s))" + else + rm -f "$temp_file" + echo "⚠ Failed to create ~/.claude.json from nix MCP servers" + return 1 + fi + return 0 + fi + + # Existing file is corrupt — back it up and recreate rather than abort. + if ! jq empty "$target_file" 2>/dev/null; then + local corrupt_bak="${target_file}.corrupt-$(date +%Y%m%d-%H%M%S)" + cp "$target_file" "$corrupt_bak" + log "⚠ ~/.claude.json was corrupt — saved to $(basename "$corrupt_bak"), recreating" + local temp_file + temp_file=$(mktemp) + jq '{mcpServers: (.mcpServers // {})}' "$nix_file" > "$temp_file" + if [ -s "$temp_file" ] && jq empty "$temp_file" 2>/dev/null; then + mv "$temp_file" "$target_file" + log "✓ ~/.claude.json recreated" + else + rm -f "$temp_file" + echo "⚠ Failed to recreate ~/.claude.json" + return 1 + fi + return 0 + fi + + # Optional pre-merge backup. + local backup_file="" + if [ "$backup_before_merge" = "true" ]; then + backup_file="${target_file}.backup-$(date +%Y%m%d-%H%M%S)" + cp "$target_file" "$backup_file" + log "✓ Created backup: $(basename "$backup_file")" + # Keep only 5 most recent backups. + ls -t "${target_file}.backup-"* 2>/dev/null | tail -n +6 | xargs rm -f 2>/dev/null || true + fi + + # Merge: nix servers are written over same-named user entries (infra wins). + # User servers with unique names are preserved unchanged. + local temp_file + temp_file=$(mktemp) + jq -s ' + .[0] as $existing | + .[1].mcpServers as $nix | + $existing | .mcpServers = (($existing.mcpServers // {}) + ($nix // {})) + ' "$target_file" "$nix_file" > "$temp_file" 2>/dev/null + if [ $? -eq 0 ] && [ -s "$temp_file" ] && jq empty "$temp_file" 2>/dev/null; then + mv "$temp_file" "$target_file" + log "✓ MCP servers merged into ~/.claude.json ($(jq '.mcpServers | length' "$target_file") total)" + else + rm -f "$temp_file" + echo "⚠ Failed to merge MCP servers — keeping original" + if [ -n "$backup_file" ] && [ -f "$backup_file" ]; then + cp "$backup_file" "$target_file" + echo "✓ Restored from backup" + fi + return 1 + fi +} + +# Run nix hooks/settings merge +merge_claude_nix + +# Merge nix MCP servers into user config +merge_claude_mcp "$HOME/.claude.json" +[ -f "$HOME/.claude.json" ] && chown "$HOST_USER" "$HOME/.claude.json" diff --git a/nixhome/modules/fragments/30-codex.sh b/nixhome/modules/fragments/30-codex.sh new file mode 100755 index 00000000..ab2327e9 --- /dev/null +++ b/nixhome/modules/fragments/30-codex.sh @@ -0,0 +1,121 @@ +#!/bin/bash +# 30-codex.sh — Codex CLI MCP server merge logic +# Sourced by entrypoint.sh; has access to $HOME, $HOST_USER, log() + +merge_codex_mcp() { + local target_file="$1" + local nix_file="/etc/codex/nix-mcp-servers.toml" + + [ -f "$nix_file" ] || return 0 + + if ! command -v python3 &>/dev/null; then + echo "⚠ python3 not available — skipping Codex MCP merge" + return 1 + fi + + if ! python3 -c "import tomllib; tomllib.load(open('$nix_file','rb'))" 2>/dev/null; then + echo "⚠ nix-mcp-servers.toml (Codex) is invalid TOML — skipping MCP merge" + return 1 + fi + + local backup_before_merge + backup_before_merge=$(python3 -c " +import tomllib, sys +with open('$nix_file', 'rb') as f: + d = tomllib.load(f) +print('true' if d.get('backupBeforeMerge', True) else 'false') +" 2>/dev/null || echo "true") + + mkdir -p "$(dirname "$target_file")" + + if [ -f "$target_file" ] && ! python3 -c "import tomllib; tomllib.load(open('$target_file','rb'))" 2>/dev/null; then + local corrupt_bak="${target_file}.corrupt-$(date +%Y%m%d-%H%M%S)" + cp "$target_file" "$corrupt_bak" + log "⚠ ~/.codex/config.toml was corrupt — saved to $(basename "$corrupt_bak"), recreating" + rm -f "$target_file" + fi + + local backup_file="" + if [ -f "$target_file" ] && [ "$backup_before_merge" = "true" ]; then + backup_file="${target_file}.backup-$(date +%Y%m%d-%H%M%S)" + cp "$target_file" "$backup_file" + log "✓ Created backup: $(basename "$backup_file")" + ls -t "${target_file}.backup-"* 2>/dev/null | tail -n +6 | xargs rm -f 2>/dev/null || true + fi + + local temp_file + temp_file=$(mktemp --suffix=.toml) + + python3 - "$nix_file" "$target_file" "$temp_file" 2>/dev/null << 'PYEOF' +import sys, tomllib, os + +nix_path, target_path, temp_path = sys.argv[1], sys.argv[2], sys.argv[3] + +def toml_val(v): + if isinstance(v, str): return f'"{v}"' + if isinstance(v, bool): return 'true' if v else 'false' + if isinstance(v, int): return str(v) + if isinstance(v, float): return repr(v) + if isinstance(v, list): return '[' + ', '.join(toml_val(x) for x in v) + ']' + raise ValueError(f"unsupported type: {type(v)}") + +def write_toml(data, out): + # Scalars first (skip internal keys and tables) + skip = {'mcp_servers', 'backupBeforeMerge'} + for k, v in data.items(): + if k not in skip and not isinstance(v, dict): + out.write(f'{k} = {toml_val(v)}\n') + # mcp_servers section + if 'mcp_servers' in data: + for srv, sdata in data['mcp_servers'].items(): + out.write(f'\n[mcp_servers.{srv}]\n') + for sk, sv in sdata.items(): + if not isinstance(sv, dict): + out.write(f'{sk} = {toml_val(sv)}\n') + for sk, sv in sdata.items(): + if isinstance(sv, dict): + out.write(f'\n[mcp_servers.{srv}.{sk}]\n') + for ek, ev in sv.items(): + out.write(f'{ek} = {toml_val(ev)}\n') + # Other tables + for k, v in data.items(): + if k not in skip and isinstance(v, dict): + out.write(f'\n[{k}]\n') + for sk, sv in v.items(): + if not isinstance(sv, dict): + out.write(f'{sk} = {toml_val(sv)}\n') + +with open(nix_path, 'rb') as f: + nix = tomllib.load(f) + +try: + with open(target_path, 'rb') as f: + existing = tomllib.load(f) +except FileNotFoundError: + existing = {} + +merged = dict(existing) +merged['mcp_servers'] = {**existing.get('mcp_servers', {}), **nix.get('mcp_servers', {})} + +with open(temp_path, 'w') as f: + write_toml(merged, f) + +print(f"merged {len(merged.get('mcp_servers', {}))} server(s)", file=sys.stderr) +PYEOF + + if [ $? -eq 0 ] && [ -s "$temp_file" ] && python3 -c "import tomllib; tomllib.load(open('$temp_file','rb'))" 2>/dev/null; then + mv "$temp_file" "$target_file" + log "✓ MCP servers merged into ~/.codex/config.toml" + else + rm -f "$temp_file" + echo "⚠ Failed to merge MCP servers into ~/.codex/config.toml — keeping original" + if [ -n "$backup_file" ] && [ -f "$backup_file" ]; then + cp "$backup_file" "$target_file" + echo "✓ Restored from backup" + fi + return 1 + fi +} + +merge_codex_mcp "$HOME/.codex/config.toml" +[ -d "$HOME/.codex" ] && chown -R "$HOST_USER" "$HOME/.codex" diff --git a/nixhome/modules/fragments/30-opencode.sh b/nixhome/modules/fragments/30-opencode.sh new file mode 100755 index 00000000..67be13d0 --- /dev/null +++ b/nixhome/modules/fragments/30-opencode.sh @@ -0,0 +1,137 @@ +#!/bin/bash +# 30-opencode.sh — merge nix-declared OpenCode providers + MCP servers +# Sourced by entrypoint.sh; has access to $HOME, $HOST_USER, log() + +merge_opencode_providers() { + local target_file="$1" + local nix_file="/etc/opencode/nix-providers.json" + + [ -f "$nix_file" ] || return 0 + + if ! jq empty "$nix_file" 2>/dev/null; then + echo "⚠ nix-providers.json (OpenCode) is invalid JSON — skipping provider merge" + return 1 + fi + + mkdir -p "$(dirname "$target_file")" + + if [ ! -f "$target_file" ]; then + # No user config yet — seed it from nix providers + local temp_file + temp_file=$(mktemp) + jq '{"$schema":"https://opencode.ai/config.json","provider": .provider}' "$nix_file" > "$temp_file" + if [ -s "$temp_file" ] && jq empty "$temp_file" 2>/dev/null; then + mv "$temp_file" "$target_file" + log "✓ ~/opencode.json created with $(jq '.provider | length' "$target_file") nix provider(s)" + else + rm -f "$temp_file" + echo "⚠ Failed to create ~/opencode.json" + fi + return 0 + fi + + if ! jq empty "$target_file" 2>/dev/null; then + echo "⚠ ~/opencode.json is invalid JSON — skipping provider merge" + return 1 + fi + + # Merge: inject nix providers only where the key is absent in user config + local temp_file + temp_file=$(mktemp) + jq -s ' + .[0] as $existing | + .[1].provider as $nix | + $existing | .provider = (($nix // {}) + ($existing.provider // {})) + ' "$target_file" "$nix_file" > "$temp_file" 2>/dev/null + if [ $? -eq 0 ] && [ -s "$temp_file" ] && jq empty "$temp_file" 2>/dev/null; then + mv "$temp_file" "$target_file" + log "✓ OpenCode providers merged into ~/opencode.json" + else + rm -f "$temp_file" + echo "⚠ Failed to merge OpenCode providers — keeping original" + fi +} + +merge_opencode_mcp() { + local target_file="$1" + local nix_file="/etc/opencode/nix-mcp-servers.json" + + [ -f "$nix_file" ] || return 0 + + if ! jq empty "$nix_file" 2>/dev/null; then + echo "⚠ nix-mcp-servers.json (OpenCode) is invalid JSON — skipping MCP merge" + return 1 + fi + + local backup_before_merge + backup_before_merge=$(jq -r '.backupBeforeMerge // true' "$nix_file") + + mkdir -p "$(dirname "$target_file")" + + if [ ! -f "$target_file" ]; then + log "Creating ~/.opencode.json with nix MCP servers" + local temp_file + temp_file=$(mktemp) + jq '{mcp: (.mcp // {})}' "$nix_file" > "$temp_file" + if [ -s "$temp_file" ] && jq empty "$temp_file" 2>/dev/null; then + mv "$temp_file" "$target_file" + log "✓ ~/.opencode.json created ($(jq '.mcp | length' "$target_file") server(s))" + else + rm -f "$temp_file" + echo "⚠ Failed to create ~/.opencode.json" + return 1 + fi + return 0 + fi + + if ! jq empty "$target_file" 2>/dev/null; then + local corrupt_bak="${target_file}.corrupt-$(date +%Y%m%d-%H%M%S)" + cp "$target_file" "$corrupt_bak" + log "⚠ ~/.opencode.json was corrupt — saved to $(basename "$corrupt_bak"), recreating" + local temp_file + temp_file=$(mktemp) + jq '{mcp: (.mcp // {})}' "$nix_file" > "$temp_file" + if [ -s "$temp_file" ] && jq empty "$temp_file" 2>/dev/null; then + mv "$temp_file" "$target_file" + else + rm -f "$temp_file" + return 1 + fi + return 0 + fi + + local backup_file="" + if [ "$backup_before_merge" = "true" ]; then + backup_file="${target_file}.backup-$(date +%Y%m%d-%H%M%S)" + cp "$target_file" "$backup_file" + log "✓ Created backup: $(basename "$backup_file")" + ls -t "${target_file}.backup-"* 2>/dev/null | tail -n +6 | xargs rm -f 2>/dev/null || true + fi + + local temp_file + temp_file=$(mktemp) + jq -s ' + .[0] as $existing | + .[1].mcp as $nix | + $existing | .mcp = (($existing.mcp // {}) + ($nix // {})) + ' "$target_file" "$nix_file" > "$temp_file" 2>/dev/null + if [ $? -eq 0 ] && [ -s "$temp_file" ] && jq empty "$temp_file" 2>/dev/null; then + mv "$temp_file" "$target_file" + log "✓ MCP servers merged into ~/.opencode.json ($(jq '.mcp | length' "$target_file") total)" + else + rm -f "$temp_file" + echo "⚠ Failed to merge MCP servers into ~/.opencode.json — keeping original" + if [ -n "$backup_file" ] && [ -f "$backup_file" ]; then + cp "$backup_file" "$target_file" + echo "✓ Restored from backup" + fi + return 1 + fi +} + +# ── Run merges ── +merge_opencode_providers "$HOME/opencode.json" +[ -f "$HOME/opencode.json" ] && chown $HOST_USER "$HOME/opencode.json" + +merge_opencode_mcp "$HOME/.opencode.json" +[ -f "$HOME/.opencode.json" ] && chown $HOST_USER "$HOME/.opencode.json" diff --git a/nixhome/modules/fragments/50-gui.sh b/nixhome/modules/fragments/50-gui.sh new file mode 100755 index 00000000..e5737d01 --- /dev/null +++ b/nixhome/modules/fragments/50-gui.sh @@ -0,0 +1,170 @@ +#!/bin/bash +# 50-gui.sh — GUI service startup (Xvfb, fluxbox, x11vnc, xrdp) +# Sourced by entrypoint.sh if present and executable. + +[ "$DEVCELL_GUI_ENABLED" = "true" ] || return 0 + +# Ensure DBUS machine-id exists (Kitty/GTK apps need it) +[ -f /etc/machine-id ] || dbus-uuidgen > /etc/machine-id 2>/dev/null || true + +DISPLAY_NUM=99 +RESOLUTION=1920x1080x24 + +mkdir -p /tmp/.X11-unix +chmod 1777 /tmp/.X11-unix + +# Mesa llvmpipe software rendering — enables GLX for GPU terminals (Kitty etc.) +export LIBGL_ALWAYS_SOFTWARE=1 +export GALLIUM_DRIVER=llvmpipe +export LIBGL_DRIVERS_PATH="/opt/devcell/.mesa-dri" +# LD_LIBRARY_PATH inherited from 06-nix-ldpath.sh (full profile closure) + +log "Starting Xvfb on display :${DISPLAY_NUM} (+GLX, Mesa llvmpipe)..." +gosu "$USER" Xvfb :${DISPLAY_NUM} -screen 0 ${RESOLUTION} -dpi 96 +extension GLX +render +iglx 2>/dev/null & +export DISPLAY=:${DISPLAY_NUM} +# Wait for X server to accept connections (socket file appears before server is ready) +for i in $(seq 1 40); do + xset -display :${DISPLAY_NUM} q >/dev/null 2>&1 && break + sleep 0.05 +done + +# Load X resources (xterm dark theme, cursor color, fonts) +# Deferred via background process: xrdb ChangeProperty requests sent from +# the entrypoint's PID 1 context are silently dropped by Xvfb. Running +# xrdb from a detached process after exec gosu replaces PID 1 works. +# PulseAudio null sink — provides audio backend for Chromium AudioContext. +# Without this, AudioContext produces silent (all-zero) frequency data, +# which is a bot detection signal (CreepJS). +# Uses -n (no default config) to avoid dbus dependency; explicitly loads +# native-protocol-unix (socket) + null-sink (virtual audio output). +PULSE_DIR="/tmp/pulse-runtime" +mkdir -p "$PULSE_DIR" +chown "$USER:$(id -gn "$USER")" "$PULSE_DIR" +log "Starting PulseAudio (null sink)..." +gosu "$USER" env XDG_RUNTIME_DIR="$PULSE_DIR" \ + pulseaudio --daemonize=yes --exit-idle-time=-1 --disable-shm=true -n \ + --load="module-null-sink sink_name=NullSink" \ + --load="module-native-protocol-unix" 2>/dev/null || true +export PULSE_SERVER="unix:$PULSE_DIR/pulse/native" +export XDG_RUNTIME_DIR="$PULSE_DIR" + +if [ -f "$DEVCELL_HOME/.Xresources" ]; then + (sleep 1; xrdb -display :${DISPLAY_NUM} -merge "$DEVCELL_HOME/.Xresources" 2>/dev/null) & + disown +fi + +if [ -f "$DEVCELL_HOME/.fluxbox/wallpaper.png" ]; then + gosu "$USER" feh --bg-fill "$DEVCELL_HOME/.fluxbox/wallpaper.png" 2>/dev/null || true +else + gosu "$USER" xsetroot -solid '#1e1e2e' 2>/dev/null || true +fi + +FLUXBOX_RC=/tmp/fluxbox-init +cp "$DEVCELL_HOME/.fluxbox/init" "$FLUXBOX_RC" +chmod u+w "$FLUXBOX_RC" +WORKSPACE_NAME=" ${APP_NAME:-cell} " +if grep -q "session.screen0.workspaceNames" "$FLUXBOX_RC"; then + sed -i "s/^session.screen0.workspaceNames:.*/session.screen0.workspaceNames: ${WORKSPACE_NAME}/" "$FLUXBOX_RC" +else + echo "session.screen0.workspaceNames: ${WORKSPACE_NAME}" >> "$FLUXBOX_RC" +fi +log "Starting fluxbox (workspace: ${WORKSPACE_NAME})..." +gosu "$USER" fluxbox -rc "$FLUXBOX_RC" &>/dev/null & +# Poll for fluxbox process instead of fixed sleep 1 +for i in $(seq 1 20); do + pgrep -u "$USER" fluxbox >/dev/null 2>&1 && break + sleep 0.05 +done + +if [ -f "$DEVCELL_HOME/.fluxbox/wallpaper.png" ]; then + gosu "$USER" feh --bg-fill "$DEVCELL_HOME/.fluxbox/wallpaper.png" 2>/dev/null || true +fi + +log "Starting x11vnc on port 5900..." +gosu "$USER" x11vnc -display :${DISPLAY_NUM} -forever -nevershared -passwd vnc -rfbport 5900 \ + -desktop "${APP_NAME:-cell}" -pointer_mode 2 -repeat -xrandr &>/dev/null & + +log "VNC server ready - connect to localhost:${EXT_VNC_PORT:-5900}" +log "DISPLAY=:${DISPLAY_NUM}" + +# ── xrdp (RDP gateway to existing VNC session) ──────────────────────── +XRDP_BIN=$(command -v xrdp 2>/dev/null) +if [ -n "$XRDP_BIN" ]; then + XRDP_CFG="/tmp/xrdp" + mkdir -p "$XRDP_CFG" + XRDP_PREFIX=$(dirname "$(dirname "$(readlink -f "$XRDP_BIN")")") + + # Copy default configs from nix store (read-only) to writable dir + cp -a "$XRDP_PREFIX/etc/xrdp/"* "$XRDP_CFG/" 2>/dev/null || true + chmod u+w "$XRDP_CFG/"* 2>/dev/null || true + + # Pre-generate RSA keys so xrdp can read them at startup + # (without this, xrdp fails with "cannot read rsakeys.ini" on first connect) + if [ "$DEVCELL_DEBUG" = "true" ]; then + xrdp-keygen xrdp "$XRDP_CFG/rsakeys.ini" || true + else + xrdp-keygen xrdp "$XRDP_CFG/rsakeys.ini" >/dev/null 2>&1 || true + fi + + # Generate self-signed SSL cert in global config dir + # (survives container restarts via ~/.config/devcell/ bind mount at /etc/devcell/config/) + XRDP_CERT_DIR="/etc/devcell/config/xrdp" + mkdir -p "$XRDP_CERT_DIR" + if [ ! -f "$XRDP_CERT_DIR/key.pem" ]; then + if [ "$DEVCELL_DEBUG" = "true" ]; then + openssl req -x509 -newkey rsa:2048 -nodes \ + -keyout "$XRDP_CERT_DIR/key.pem" -out "$XRDP_CERT_DIR/cert.pem" \ + -days 365 -subj "/CN=devcell" + else + openssl req -x509 -newkey rsa:2048 -nodes \ + -keyout "$XRDP_CERT_DIR/key.pem" -out "$XRDP_CERT_DIR/cert.pem" \ + -days 365 -subj "/CN=devcell" >/dev/null 2>&1 + fi + fi + + # Patch xrdp.ini: port, SSL, autorun, logging to file only + # DEVCELL_DEBUG=true → INFO logs; otherwise WARNING only + if [ "$DEVCELL_DEBUG" = "true" ]; then + XRDP_LOG_LEVEL="INFO" + else + XRDP_LOG_LEVEL="WARNING" + fi + sed -i \ + -e "s|^port=.*|port=3389|" \ + -e "s|^certificate=.*|certificate=$XRDP_CERT_DIR/cert.pem|" \ + -e "s|^key_file=.*|key_file=$XRDP_CERT_DIR/key.pem|" \ + -e "s|^autorun=.*|autorun=vnc-any|" \ + -e "s|^max_bpp=.*|max_bpp=24|" \ + -e "s|^allow_channels=.*|allow_channels=true|" \ + -e "s|^LogFile=.*|LogFile=/var/log/xrdp.log|" \ + -e "s|^LogLevel=.*|LogLevel=$XRDP_LOG_LEVEL|" \ + -e "s|^#*EnableSyslog=.*|EnableSyslog=false|" \ + -e "s|^#*default_dpi=.*|default_dpi=96|" \ + -e "s|^cliprdr=.*|cliprdr=true|" \ + "$XRDP_CFG/xrdp.ini" + + # Remove stock [Xorg] section (has username=ask which forces login + # prompt even with autorun). Keep only our [vnc-any] with hardcoded + # creds so xrdp auto-connects without asking. + sed -i '/^\[Xorg\]/,/^\[/{ /^\[vnc-any\]/!d; }' "$XRDP_CFG/xrdp.ini" + + # Replace [vnc-any] section — hardcoded creds skip login prompt + sed -i '/^\[vnc-any\]/,$d' "$XRDP_CFG/xrdp.ini" + { + echo '[vnc-any]' + echo 'name=VNC' + echo 'lib=libvnc.so' + echo 'ip=127.0.0.1' + echo 'port=5900' + echo "username=${HOST_USER}" + echo 'password=vnc' + echo 'xserverbpp=24' + } >> "$XRDP_CFG/xrdp.ini" + + log "Starting xrdp on port 3389 (RDP → VNC :${DISPLAY_NUM})..." + xrdp --nodaemon --config "$XRDP_CFG/xrdp.ini" >>/var/log/xrdp.log 2>&1 & + + log "xrdp ready - connect to localhost:${EXT_RDP_PORT:-3389}" +else + log "xrdp not found — skipping RDP server" +fi diff --git a/nixhome/modules/go.nix b/nixhome/modules/go.nix index 79a96dea..b3c3f9b6 100644 --- a/nixhome/modules/go.nix +++ b/nixhome/modules/go.nix @@ -18,6 +18,7 @@ home.sessionVariables = { GOPATH = "${config.home.homeDirectory}/go"; + CC = "cc"; # Go defaults to gcc which we don't have; cc → clang via nix }; home.sessionPath = ["${config.home.homeDirectory}/go/bin"]; diff --git a/nixhome/modules/infra.nix b/nixhome/modules/infra.nix index b8ffa7c2..da2f9e72 100644 --- a/nixhome/modules/infra.nix +++ b/nixhome/modules/infra.nix @@ -10,10 +10,25 @@ packer terraform-docs terraform-plugin-docs # generates/validates Terraform provider docs (use: tfplugindocs) + kubernetes-helm # Kubernetes package manager (use: helm) ]; devcell.managedMcp.servers.opentofu = { command = "opentofu-mcp-server"; args = []; }; + + # Linear — remote HTTP MCP server. + # Auth: OAuth 2.1 flow on first use (run /mcp in Claude session to authenticate). + devcell.managedMcp.servers."linear-server" = { + type = "http"; + url = "https://mcp.linear.app/mcp"; + }; + + # Notion — remote HTTP MCP server. + # Auth: OAuth 2.1 flow on first use (run /mcp in Claude session to authenticate). + devcell.managedMcp.servers.notion = { + type = "http"; + url = "https://mcp.notion.com/mcp"; + }; } diff --git a/nixhome/modules/llm/claude.nix b/nixhome/modules/llm/claude.nix new file mode 100644 index 00000000..a72622c0 --- /dev/null +++ b/nixhome/modules/llm/claude.nix @@ -0,0 +1,152 @@ +# claude.nix — Claude Code hook scripts, settings, and MCP server staging. +# Merged from managed-claude.nix + Claude parts of managed-mcp.nix. +{ + pkgs, + pkgsUnstable, + lib, + config, + ... +}: let + cfg = config.devcell.managedClaude; + mcpCfg = config.devcell.managedMcp; + + json = pkgs.formats.json {}; + + settingsFile = json.generate "claude-nix-settings.json" cfg.settings; + + hookDerivations = lib.mapAttrs ( + name: content: pkgs.writeShellScript name content + ) cfg.hookScripts; + + hasHooks = cfg.hookScripts != {}; + hasSettings = cfg.settings != {}; + + # Claude MCP config derivation (from mcp.nix servers) + toClaudeServer = _: s: let + t = s.type or "stdio"; + in + if t == "http" then { + type = "http"; + url = s.url; + } + else { + type = t; + command = s.command; + args = s.args or []; + env = s.env or {}; + }; + + claudeConfig = json.generate "claude-nix-mcp-servers.json" { + backupBeforeMerge = mcpCfg.backupBeforeMerge; + mcpServers = lib.mapAttrs toClaudeServer mcpCfg.servers; + }; + + hasServers = mcpCfg.servers != {}; +in { + options.devcell.managedClaude = { + settings = lib.mkOption { + type = lib.types.anything; + default = {}; + description = '' + Claude Code settings merged into ~/.claude/settings.json at container + start. User's existing configuration is preserved; nix settings are + merged in only where the user has no value (same semantics as MCP merge). + ''; + }; + + hookScripts = lib.mkOption { + type = lib.types.attrsOf lib.types.str; + default = {}; + description = '' + Shell scripts staged to /etc/claude-code/hooks/ at image build + time and copied to ~/.claude/hooks/ by entrypoint.sh on start. + ''; + }; + }; + + config = { + home.packages = [ + pkgsUnstable.claude-code # AI coding assistant CLI (unstable for latest features) + ]; + + # ── Default Claude Code settings ─────────────────────────────────────── + devcell.managedClaude = { + hookScripts."auto-approve-all.sh" = '' + #!/bin/bash + # Auto-approve all permission requests (unrestricted mode for background agents) + echo '{"decision":"allow","applyPermissionRule":true}' + ''; + settings = { + hooks.PermissionRequest = [ + { + matcher = "*"; + hooks = [ + { + type = "command"; + command = "bash ~/.claude/hooks/auto-approve-all.sh"; + } + ]; + } + ]; + }; + }; + + # Always generate the entrypoint fragment (self-guards at runtime) + home.file.".config/devcell/entrypoint.d/30-claude.sh" = { + executable = true; + source = ../fragments/30-claude.sh; + }; + + # Stage hooks + settings + MCP servers when configured + home.activation.setupManagedClaude = lib.mkIf (hasHooks || hasSettings || hasServers) ( + lib.hm.dag.entryAfter ["writeBoundary"] '' + export PATH="/usr/bin:/bin:$PATH" + $DRY_RUN_CMD sudo mkdir -p /etc/claude-code/hooks + ${lib.concatStringsSep "\n" (lib.mapAttrsToList (name: drv: '' + $DRY_RUN_CMD sudo cp ${drv} /etc/claude-code/hooks/${name} + $DRY_RUN_CMD sudo chmod +x /etc/claude-code/hooks/${name} + '') hookDerivations)} + ${lib.optionalString hasSettings '' + $DRY_RUN_CMD sudo cp ${settingsFile} /etc/claude-code/nix-settings.json + ''} + ${lib.optionalString hasServers '' + # Remove legacy files that had undesired exclusive-control or requirements semantics. + $DRY_RUN_CMD sudo rm -f /etc/claude-code/managed-mcp.json + $DRY_RUN_CMD sudo cp ${claudeConfig} /etc/claude-code/nix-mcp-servers.json + + # Live-merge into ~/.claude.json so mid-session home-manager switch picks up + # new MCP server paths without requiring container restart. + _nix_file="/etc/claude-code/nix-mcp-servers.json" + _merge_claude_mcp() { + local _target="$1" + if [ -f "$_target" ] && jq empty "$_target" 2>/dev/null; then + local _tmp; _tmp=$(mktemp) + jq -s ' + .[0] as $existing | + .[1].mcpServers as $nix | + $existing | .mcpServers = (($existing.mcpServers // {}) + ($nix // {})) + ' "$_target" "$_nix_file" > "$_tmp" 2>/dev/null + if [ $? -eq 0 ] && [ -s "$_tmp" ] && jq empty "$_tmp" 2>/dev/null; then + mv "$_tmp" "$_target" + echo "setupManagedClaude: merged MCP servers into $_target" + else + rm -f "$_tmp" + fi + elif [ ! -f "$_target" ]; then + jq '{mcpServers: (.mcpServers // {})}' "$_nix_file" > "$_target" 2>/dev/null + echo "setupManagedClaude: created $_target with MCP servers" + fi + } + if [ -f "$_nix_file" ] && command -v jq &>/dev/null; then + _merge_claude_mcp "$HOME/.claude.json" + # Also merge into active session user's home if different from $HOME + for _user_home in /home/*; do + [ -d "$_user_home/.claude" ] && [ "$_user_home" != "$HOME" ] && \ + _merge_claude_mcp "$_user_home/.claude.json" + done + fi + ''} + '' + ); + }; +} diff --git a/nixhome/modules/llm/codex.nix b/nixhome/modules/llm/codex.nix new file mode 100644 index 00000000..55199b8f --- /dev/null +++ b/nixhome/modules/llm/codex.nix @@ -0,0 +1,47 @@ +# codex.nix — Codex CLI MCP server staging and entrypoint merge logic. +# Extracted from managed-mcp.nix. +{ + pkgs, + lib, + config, + ... +}: let + mcpCfg = config.devcell.managedMcp; + + toml = pkgs.formats.toml {}; + + # Only stdio servers — Codex doesn't support HTTP transport. + stdioServers = lib.filterAttrs (_: s: (s.type or "stdio") == "stdio") mcpCfg.servers; + + toCodexServer = _: s: + { + command = s.command; + args = s.args or []; + } + // lib.optionalAttrs ((s.env or {}) != {}) {env = s.env;}; + + codexConfig = toml.generate "codex-nix-mcp-servers.toml" { + backupBeforeMerge = mcpCfg.backupBeforeMerge; + mcp_servers = lib.mapAttrs toCodexServer stdioServers; + }; + + hasServers = mcpCfg.servers != {}; +in { + config = { + # Always generate the Codex merge fragment (self-guards at runtime) + home.file.".config/devcell/entrypoint.d/30-codex.sh" = { + executable = true; + source = ../fragments/30-codex.sh; + }; + + # Stage Codex MCP config when servers are defined + home.activation.setupManagedCodex = lib.mkIf hasServers ( + lib.hm.dag.entryAfter ["writeBoundary"] '' + export PATH="/usr/bin:/bin:$PATH" + $DRY_RUN_CMD sudo mkdir -p /etc/codex + $DRY_RUN_CMD sudo rm -f /etc/codex/managed_config.toml + $DRY_RUN_CMD sudo cp ${codexConfig} /etc/codex/nix-mcp-servers.toml + '' + ); + }; +} diff --git a/nixhome/modules/llm/default.nix b/nixhome/modules/llm/default.nix new file mode 100644 index 00000000..55b83772 --- /dev/null +++ b/nixhome/modules/llm/default.nix @@ -0,0 +1,9 @@ +# llm/default.nix — LLM coding tools: module imports. +{...}: { + imports = [ + ./mcp.nix + ./claude.nix + ./opencode.nix + ./codex.nix + ]; +} diff --git a/nixhome/modules/llm/mcp.nix b/nixhome/modules/llm/mcp.nix new file mode 100644 index 00000000..35a0c99e --- /dev/null +++ b/nixhome/modules/llm/mcp.nix @@ -0,0 +1,17 @@ +# mcp.nix — shared MCP server option definition. +# Individual tool modules (claude.nix, opencode.nix, codex.nix) each build +# their own config derivation from config.devcell.managedMcp.servers. +{lib, ...}: { + options.devcell.managedMcp = { + servers = lib.mkOption { + type = lib.types.attrsOf lib.types.anything; + default = {}; + description = "Canonical MCP server definitions. Each entry: { command, args?, env? }."; + }; + backupBeforeMerge = lib.mkOption { + type = lib.types.bool; + default = true; + description = "Whether entrypoint.sh should back up user config files before merging nix MCP servers (Claude, OpenCode, Codex)."; + }; + }; +} diff --git a/nixhome/modules/llm/opencode.nix b/nixhome/modules/llm/opencode.nix new file mode 100644 index 00000000..0f1679a3 --- /dev/null +++ b/nixhome/modules/llm/opencode.nix @@ -0,0 +1,89 @@ +# opencode.nix — OpenCode provider configs and MCP server staging. +# Merged from managed-opencode.nix + OpenCode parts of managed-mcp.nix. +{ + pkgs, + lib, + config, + ... +}: let + cfg = config.devcell.managedOpencode; + mcpCfg = config.devcell.managedMcp; + + json = pkgs.formats.json {}; + + providersFile = json.generate "opencode-nix-providers.json" { + provider = cfg.providers; + }; + + hasProviders = cfg.providers != {}; + + # OpenCode MCP config derivation (from mcp.nix servers) + # Only stdio servers — OpenCode doesn't support HTTP transport. + stdioServers = lib.filterAttrs (_: s: (s.type or "stdio") == "stdio") mcpCfg.servers; + + toOpenCodeServer = _: s: + { + type = "local"; + command = [s.command] ++ (s.args or []); + } + // lib.optionalAttrs ((s.env or {}) != {}) {environment = s.env;}; + + openCodeConfig = json.generate "opencode-nix-mcp-servers.json" { + backupBeforeMerge = mcpCfg.backupBeforeMerge; + mcp = lib.mapAttrs toOpenCodeServer stdioServers; + }; + + hasServers = mcpCfg.servers != {}; +in { + options.devcell.managedOpencode = { + providers = lib.mkOption { + type = lib.types.attrsOf lib.types.anything; + default = {}; + description = '' + OpenCode provider configs merged into ~/opencode.json at container start. + Each key is a provider ID; the value is the provider config object. + Providers are only injected if the key is not already present in the + user's existing ~/opencode.json. + ''; + }; + }; + + config = { + home.packages = with pkgs; [ + opencode # AI coding agent for terminal + ]; + + # ── Default OpenCode provider config ───────────────────────────────── + devcell.managedOpencode.providers = { + lmstudio = { + npm = "@ai-sdk/openai-compatible"; + name = "LM Studio (local)"; + options.baseURL = "http://127.0.0.1:1234/v1"; + models = { + "google/gemma-3n-e4b".name = "Gemma 3n-e4b (local)"; + "zai-org_glm-4.7-flash".name = "GLM-4.7 (local)"; + }; + }; + }; + + # Always generate the fragment (self-guards at runtime) + home.file.".config/devcell/entrypoint.d/30-opencode.sh" = { + executable = true; + source = ../fragments/30-opencode.sh; + }; + + # Stage providers + MCP servers when configured + home.activation.setupManagedOpencode = lib.mkIf (hasProviders || hasServers) ( + lib.hm.dag.entryAfter ["writeBoundary"] '' + export PATH="/usr/bin:/bin:$PATH" + $DRY_RUN_CMD sudo mkdir -p /etc/opencode + ${lib.optionalString hasProviders '' + $DRY_RUN_CMD sudo cp ${providersFile} /etc/opencode/nix-providers.json + ''} + ${lib.optionalString hasServers '' + $DRY_RUN_CMD sudo cp ${openCodeConfig} /etc/opencode/nix-mcp-servers.json + ''} + '' + ); + }; +} diff --git a/nixhome/modules/managed-claude.nix b/nixhome/modules/managed-claude.nix deleted file mode 100644 index 861a21e1..00000000 --- a/nixhome/modules/managed-claude.nix +++ /dev/null @@ -1,73 +0,0 @@ -# managed-claude.nix — builds Claude Code hook scripts and settings declaratively, -# then stages them under /etc/claude-code/ for entrypoint.sh to merge at startup. -# -# Usage in any module: -# devcell.managedClaude.hookScripts."auto-approve-all.sh" = '' -# #!/bin/bash -# echo '{"decision":"allow","applyPermissionRule":true}' -# ''; -# devcell.managedClaude.settings = { -# hooks.PermissionRequest = [{ -# matcher = "*"; -# hooks = [{ type = "command"; command = "bash ~/.claude/hooks/auto-approve-all.sh"; }]; -# }]; -# }; -# -# entrypoint.sh then: -# 1. Copies /etc/claude-code/hooks/* → ~/.claude/hooks/ (chmod +x) -# 2. Merges /etc/claude-code/nix-settings.json → ~/.claude/settings.json -# (same merge_claude_settings logic — user's existing hooks are preserved) -{ - pkgs, - lib, - config, - ... -}: let - cfg = config.devcell.managedClaude; - - json = pkgs.formats.json {}; - - settingsFile = json.generate "claude-nix-settings.json" cfg.settings; - - hookDerivations = lib.mapAttrs ( - name: content: pkgs.writeShellScript name content - ) cfg.hookScripts; - - hasHooks = cfg.hookScripts != {}; - hasSettings = cfg.settings != {}; -in { - options.devcell.managedClaude = { - settings = lib.mkOption { - type = lib.types.anything; - default = {}; - description = '' - Claude Code settings merged into ~/.claude/settings.json at container - start. User's existing configuration is preserved; nix settings are - merged in only where the user has no value (same semantics as MCP merge). - ''; - }; - - hookScripts = lib.mkOption { - type = lib.types.attrsOf lib.types.str; - default = {}; - description = '' - Shell scripts staged to /etc/claude-code/hooks/ at image build - time and copied to ~/.claude/hooks/ by entrypoint.sh on start. - ''; - }; - }; - - config = lib.mkIf (hasHooks || hasSettings) { - home.activation.setupManagedClaude = lib.hm.dag.entryAfter ["writeBoundary"] '' - export PATH="/usr/bin:/bin:$PATH" - $DRY_RUN_CMD sudo mkdir -p /etc/claude-code/hooks - ${lib.concatStringsSep "\n" (lib.mapAttrsToList (name: drv: '' - $DRY_RUN_CMD sudo cp ${drv} /etc/claude-code/hooks/${name} - $DRY_RUN_CMD sudo chmod +x /etc/claude-code/hooks/${name} - '') hookDerivations)} - ${lib.optionalString hasSettings '' - $DRY_RUN_CMD sudo cp ${settingsFile} /etc/claude-code/nix-settings.json - ''} - ''; - }; -} diff --git a/nixhome/modules/managed-mcp.nix b/nixhome/modules/managed-mcp.nix deleted file mode 100644 index b02351e7..00000000 --- a/nixhome/modules/managed-mcp.nix +++ /dev/null @@ -1,100 +0,0 @@ -# managed-mcp.nix — aggregates MCP server configs contributed by any module -# and generates system-level config files for all supported LLM CLI tools. -# -# Usage in any module: -# devcell.managedMcp.servers.my-server = { -# command = "my-mcp-server"; # executable name/path (string) -# args = ["--flag" "value"]; # CLI args (list of strings) -# env = { KEY = "value"; }; # environment variables (attrset) -# }; -# -# The nix module system merges all contributions automatically. -# Generates (when servers != {}): -# /etc/claude-code/nix-mcp-servers.json — staging file; entrypoint.sh merges -# into ~/.claude.json at container start -# (additive — users keep their own servers) -# /etc/opencode/nix-mcp-servers.json — staging; entrypoint merges into ~/.opencode.json -# /etc/codex/nix-mcp-servers.toml — staging; entrypoint merges into ~/.codex/config.toml -{ - pkgs, - lib, - config, - ... -}: let - servers = config.devcell.managedMcp.servers; - - # ── Format converters ──────────────────────────────────────────────────────── - - # Claude Code: { type, command, args, env } - toClaudeServer = _: s: { - type = s.type or "stdio"; - command = s.command; - args = s.args or []; - env = s.env or {}; - }; - - # OpenCode: { type, command (merged array), environment } - toOpenCodeServer = _: s: - { - type = "local"; - command = [s.command] ++ (s.args or []); - } - // lib.optionalAttrs ((s.env or {}) != {}) {environment = s.env;}; - - # Codex CLI (TOML): { command, args, env-section } - # No type field — inferred from presence of command vs url. - toCodexServer = _: s: - { - command = s.command; - args = s.args or []; - } - // lib.optionalAttrs ((s.env or {}) != {}) {env = s.env;}; - - # ── Config file derivations ────────────────────────────────────────────────── - - json = pkgs.formats.json {}; - toml = pkgs.formats.toml {}; - - claudeConfig = json.generate "claude-nix-mcp-servers.json" { - backupBeforeMerge = config.devcell.managedMcp.backupBeforeMerge; - mcpServers = lib.mapAttrs toClaudeServer servers; - }; - - openCodeConfig = json.generate "opencode-nix-mcp-servers.json" { - backupBeforeMerge = config.devcell.managedMcp.backupBeforeMerge; - mcp = lib.mapAttrs toOpenCodeServer servers; - }; - - codexConfig = toml.generate "codex-nix-mcp-servers.toml" { - backupBeforeMerge = config.devcell.managedMcp.backupBeforeMerge; - mcp_servers = lib.mapAttrs toCodexServer servers; - }; -in { - options.devcell.managedMcp = { - servers = lib.mkOption { - type = lib.types.attrsOf lib.types.anything; - default = {}; - description = "Canonical MCP server definitions. Each entry: { command, args?, env? }."; - }; - backupBeforeMerge = lib.mkOption { - type = lib.types.bool; - default = true; - description = "Whether entrypoint.sh should back up user config files before merging nix MCP servers (Claude, OpenCode, Codex)."; - }; - }; - - config = lib.mkIf (servers != {}) { - home.activation.setupManagedMcp = lib.hm.dag.entryAfter ["writeBoundary"] '' - # sudo may not be in PATH when home-manager activation runs inside a Docker - # build layer (restricted Nix-managed PATH). Add system bin dirs as fallback. - export PATH="/usr/bin:/bin:$PATH" - $DRY_RUN_CMD sudo mkdir -p /etc/claude-code /etc/opencode /etc/codex - # Remove legacy files that had undesired exclusive-control or requirements semantics. - $DRY_RUN_CMD sudo rm -f /etc/claude-code/managed-mcp.json - $DRY_RUN_CMD sudo rm -f /etc/codex/managed_config.toml - $DRY_RUN_CMD sudo cp ${claudeConfig} /etc/claude-code/nix-mcp-servers.json - $DRY_RUN_CMD sudo cp ${openCodeConfig} /etc/opencode/nix-mcp-servers.json - $DRY_RUN_CMD sudo cp ${codexConfig} /etc/codex/nix-mcp-servers.toml - ''; - }; -} diff --git a/nixhome/modules/managed-opencode.nix b/nixhome/modules/managed-opencode.nix deleted file mode 100644 index cacb656c..00000000 --- a/nixhome/modules/managed-opencode.nix +++ /dev/null @@ -1,53 +0,0 @@ -# managed-opencode.nix — declares default OpenCode provider configs declaratively, -# stages them to /etc/opencode/nix-providers.json at image build time, -# and entrypoint.sh merges them into ~/opencode.json at container start -# (only injecting a provider if the key is not already present in the user's config). -# -# Usage in any module: -# devcell.managedOpencode.providers = { -# lmstudio = { -# npm = "@ai-sdk/openai-compatible"; -# name = "LM Studio (local)"; -# options.baseURL = "http://127.0.0.1:1234/v1"; -# models = { -# "google/gemma-3n-e4b".name = "Gemma 3n-e4b (local)"; -# }; -# }; -# }; -{ - pkgs, - lib, - config, - ... -}: let - cfg = config.devcell.managedOpencode; - - json = pkgs.formats.json {}; - - providersFile = json.generate "opencode-nix-providers.json" { - provider = cfg.providers; - }; - - hasProviders = cfg.providers != {}; -in { - options.devcell.managedOpencode = { - providers = lib.mkOption { - type = lib.types.attrsOf lib.types.anything; - default = {}; - description = '' - OpenCode provider configs merged into ~/opencode.json at container start. - Each key is a provider ID; the value is the provider config object. - Providers are only injected if the key is not already present in the - user's existing ~/opencode.json. - ''; - }; - }; - - config = lib.mkIf hasProviders { - home.activation.setupManagedOpencode = lib.hm.dag.entryAfter ["writeBoundary"] '' - export PATH="/usr/bin:/bin:$PATH" - $DRY_RUN_CMD sudo mkdir -p /etc/opencode - $DRY_RUN_CMD sudo cp ${providersFile} /etc/opencode/nix-providers.json - ''; - }; -} diff --git a/nixhome/modules/mise.nix b/nixhome/modules/mise.nix index e4d1bec8..68438145 100644 --- a/nixhome/modules/mise.nix +++ b/nixhome/modules/mise.nix @@ -33,12 +33,27 @@ in { ''; }; - home.file.".tool-versions" = lib.mkIf (cfg.tools != {}) { - text = toolVersionsContent + "\n"; - }; + # .tool-versions is written to /etc/devcell/ (not home.file) to avoid + # nix creating a dangling symlink at $HOME. The entrypoint fragment + # copies it to $HOME at runtime; build-time mise install reads it + # from /opt/devcell/ via the activation-generated copy. + home.activation.writeToolVersions = lib.mkIf (cfg.tools != {}) ( + lib.hm.dag.entryAfter ["writeBoundary"] '' + export PATH="/usr/bin:/bin:$PATH" + $DRY_RUN_CMD mkdir -p /etc/devcell + echo ${lib.escapeShellArg (toolVersionsContent + "\n")} | $DRY_RUN_CMD tee /etc/devcell/tool-versions > /dev/null + $DRY_RUN_CMD cp /etc/devcell/tool-versions "$HOME/.tool-versions" 2>/dev/null || true + '' + ); home.file.".default-npm-packages" = lib.mkIf (cfg.defaultNpmPackages != []) { text = lib.concatStringsSep "\n" cfg.defaultNpmPackages + "\n"; }; + + # ── Entrypoint fragment: mise setup ────────────────────────────────────── + home.file.".config/devcell/entrypoint.d/10-mise.sh" = { + executable = true; + source = ./fragments/10-mise.sh; + }; }; } diff --git a/nixhome/modules/news.nix b/nixhome/modules/news.nix new file mode 100644 index 00000000..70f26e91 --- /dev/null +++ b/nixhome/modules/news.nix @@ -0,0 +1,34 @@ +# news.nix — RSS/News tools +{pkgs, ...}: let + # inoreader-mcp: Inoreader RSS MCP — 19 tools (feeds, articles, search, tagging, analytics) + # https://github.com/justmytwospence/inoreader-mcp + inoreaderMcp = pkgs.buildNpmPackage { + pname = "inoreader-mcp"; + version = "0.1.0-unstable-2026-03-20"; + src = pkgs.fetchFromGitHub { + owner = "justmytwospence"; + repo = "inoreader-mcp"; + rev = "0aadb769b632f5d21cfdd4e9ed7cf08b287cda0e"; + hash = "sha256-yx0Ndy/qVIofaYZr3cE19SUz4PKhSUD0Ja+APGHuKA4="; + }; + npmDepsHash = "sha256-YoaWxGRBnTqytFKZp2p5NW2+hMW5Dh2XiD4D0HamoqQ="; + nodejs = pkgs.nodejs_22; + }; +in { + home.packages = [ + inoreaderMcp # Inoreader RSS MCP server (use: inoreader-mcp) + ]; + + # Inoreader — 19 tools: feeds, articles, search, tagging, batch ops, feed health analysis. + # Requires INOREADER_CLIENT_ID, INOREADER_CLIENT_SECRET env vars at runtime. + # Get credentials: https://www.inoreader.com/developers/ → create app → redirect URI: http://localhost:3333/callback + # Auth: use setup_auth tool to complete OAuth flow. Tokens stored in ~/.config/inoreader-mcp/tokens.json + devcell.managedMcp.servers."inoreader" = { + command = "inoreader-mcp"; + args = []; + env = { + INOREADER_CLIENT_ID = "\${INOREADER_CLIENT_ID}"; + INOREADER_CLIENT_SECRET = "\${INOREADER_CLIENT_SECRET}"; + }; + }; +} diff --git a/nixhome/modules/python.nix b/nixhome/modules/python.nix index 9433de78..1c68db18 100644 --- a/nixhome/modules/python.nix +++ b/nixhome/modules/python.nix @@ -1,6 +1,4 @@ # python.nix — Python runtime and uv package manager -# TODO: Switch to devcell.mise.tools.python for Python runtime once python-build -# works reliably in nix-only containers (needs build deps in standard paths). {pkgs, ...}: { # imports = [./mise.nix]; # devcell.mise.tools.python = "3.13.2"; diff --git a/nixhome/modules/scraping/default.nix b/nixhome/modules/scraping/default.nix new file mode 100644 index 00000000..38fa9b25 --- /dev/null +++ b/nixhome/modules/scraping/default.nix @@ -0,0 +1,30 @@ +# web.nix — Chromium for browser automation / Patchright +# Interactive browsing: nix chromium wrapper (--no-sandbox, per-app profile). +# Automation: Patchright's bundled Chromium (stealth — no webdriver leak). +# Do NOT set PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH — it overrides the patched binary. +{pkgs, config, ...}: { + home.packages = with pkgs; [ + hugo + + # Chromium wrapper — reads CHROMIUM_PROFILE_PATH at runtime so each + # container can have an isolated profile even when sharing CELL_HOME. + (pkgs.writeShellScriptBin "chromium" '' + exec ${pkgs.chromium}/bin/chromium \ + --user-data-dir="''${CHROMIUM_PROFILE_PATH:-$HOME/.chrome-''${APP_NAME:-default}}" \ + --no-sandbox \ + --disable-gpu \ + --disable-dev-shm-usage \ + "$@" + '') + ]; + + home.sessionVariables = { + # Patchright uses its own bundled Chromium (with webdriver stealth patches). + # Do NOT set PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH — it overrides the patched binary. + # The interactive chromium wrapper above uses pkgs.chromium for manual browsing. + # Let Patchright manage its own browser cache in the session user's home. + # Pre-installed at base image build time in /opt/devcell/.cache/ms-playwright; + # falls back to auto-download on first launch if not present. + PLAYWRIGHT_MCP_BROWSER = "chromium"; + }; +} diff --git a/nixhome/modules/shell.nix b/nixhome/modules/shell.nix index 215aba5c..9fda8c08 100644 --- a/nixhome/modules/shell.nix +++ b/nixhome/modules/shell.nix @@ -1,5 +1,5 @@ # shell.nix — zsh + starship prompt configuration -# Imported by base.nix so all profiles get a consistent shell experience. +# Imported by base.nix so all stacks get a consistent shell experience. # Works in both `cell shell` (zsh) and xterm (desktop GUI). {pkgs, ...}: { programs.zsh = { diff --git a/nixhome/modules/travel.nix b/nixhome/modules/travel.nix new file mode 100644 index 00000000..a1b6c908 --- /dev/null +++ b/nixhome/modules/travel.nix @@ -0,0 +1,64 @@ +# travel.nix — Travel and geospatial tools +{pkgs, ...}: let + py = pkgs.python312Packages; + + # mcp-google-map: Google Maps MCP — 17 tools (geocoding, routing, places, elevation, air quality, timezone, etc.) + # https://github.com/cablate/mcp-google-map + mcpGoogleMap = pkgs.buildNpmPackage { + pname = "mcp-google-map"; + version = "0.0.47"; + src = pkgs.fetchFromGitHub { + owner = "cablate"; + repo = "mcp-google-map"; + rev = "88e406aadbc01e0bb68eb42e4dda373eb92b3e07"; + hash = "sha256-oe8LjhwC9yxiMVGe9BvbDWn0BnguGZph8J2JSSRGgng="; + }; + npmDepsHash = "sha256-9ocpBEJtpPafdkKPDeM4h8FBt4hPCnDFOssvdLRvP2o="; + nodejs = pkgs.nodejs_22; + }; + + # tripit-mcp: TripIt MCP server — list_trips, get_trip with date filtering + # https://github.com/DimmKirr/tripit-mcp + tripitMcp = py.buildPythonApplication { + pname = "tripit-mcp"; + version = "0.1.0"; + src = pkgs.fetchFromGitHub { + owner = "DimmKirr"; + repo = "tripit-mcp"; + rev = "e19a88db76da3c660b9531e974f06fc4bfbb4975"; + hash = "sha256-DJQj0Xc6mFeoT7kv6vHPlojZZdOLHSF0DhRM+K2JKVY="; + }; + pyproject = true; + build-system = [py.setuptools]; + # Remove non-Python dirs that confuse setuptools flat-layout discovery + postPatch = '' + rm -rf nix docs scripts tests + ''; + dependencies = with py; [ + fastmcp + httpx + uvicorn + ]; + doCheck = false; + }; +in { + home.packages = [ + mcpGoogleMap # Google Maps MCP server (use: mcp-google-map) + tripitMcp # TripIt MCP server (use: tripit-mcp) + ]; + + # Google Maps — 17 tools: geocoding, routing, places, elevation, air quality. + # Requires GOOGLE_MAPS_API_KEY env var at runtime. + devcell.managedMcp.servers."google-maps" = { + command = "mcp-google-map"; + args = ["--stdio"]; + env.GOOGLE_MAPS_API_KEY = "\${GOOGLE_MAPS_API_KEY}"; + }; + + # TripIt — list_trips, get_trip with date filtering. + # Requires TRIPIT_USERNAME, TRIPIT_PASSWORD, TRIPIT_CLIENT_ID, TRIPIT_CLIENT_SECRET env vars at runtime. + devcell.managedMcp.servers."tripit" = { + command = "tripit-mcp"; + args = []; + }; +} diff --git a/nixhome/modules/web.nix b/nixhome/modules/web.nix deleted file mode 100644 index fec802cf..00000000 --- a/nixhome/modules/web.nix +++ /dev/null @@ -1,27 +0,0 @@ -# web.nix — Chromium for browser automation / Playwright -# Replaces apt: chromium, chromium-driver -# Sets PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH so Playwright uses nix chromium -# instead of downloading its own. -{pkgs, config, ...}: { - home.packages = with pkgs; [ - hugo - - # Chromium wrapper — reads CHROMIUM_PROFILE_PATH at runtime so each - # container can have an isolated profile even when sharing CELL_HOME. - (pkgs.writeShellScriptBin "chromium" '' - exec ${pkgs.chromium}/bin/chromium \ - --user-data-dir="''${CHROMIUM_PROFILE_PATH:-$HOME/.chrome-''${APP_NAME:-default}}" \ - --no-sandbox \ - --disable-gpu \ - --disable-dev-shm-usage \ - "$@" - '') - ]; - - home.sessionVariables = { - PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH = "${pkgs.chromium}/bin/chromium"; - PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD = "1"; - PLAYWRIGHT_BROWSERS_PATH = "0"; - PLAYWRIGHT_MCP_BROWSER = "chromium"; - }; -} diff --git a/nixhome/profiles/base.nix b/nixhome/stacks/base.nix similarity index 100% rename from nixhome/profiles/base.nix rename to nixhome/stacks/base.nix diff --git a/nixhome/profiles/electronics.nix b/nixhome/stacks/electronics.nix similarity index 100% rename from nixhome/profiles/electronics.nix rename to nixhome/stacks/electronics.nix diff --git a/nixhome/profiles/fullstack.nix b/nixhome/stacks/fullstack.nix similarity index 88% rename from nixhome/profiles/fullstack.nix rename to nixhome/stacks/fullstack.nix index 6c02c2e1..40969091 100644 --- a/nixhome/profiles/fullstack.nix +++ b/nixhome/stacks/fullstack.nix @@ -7,6 +7,6 @@ ../modules/infra.nix ../modules/node.nix ../modules/python.nix - ../modules/web.nix + ../modules/scraping ]; } diff --git a/nixhome/profiles/go.nix b/nixhome/stacks/go.nix similarity index 100% rename from nixhome/profiles/go.nix rename to nixhome/stacks/go.nix diff --git a/nixhome/profiles/node.nix b/nixhome/stacks/node.nix similarity index 74% rename from nixhome/profiles/node.nix rename to nixhome/stacks/node.nix index 49403218..f22d2c02 100644 --- a/nixhome/profiles/node.nix +++ b/nixhome/stacks/node.nix @@ -2,6 +2,6 @@ imports = [ ../modules/base.nix ../modules/node.nix - ../modules/web.nix + ../modules/scraping ]; } diff --git a/nixhome/profiles/python.nix b/nixhome/stacks/python.nix similarity index 75% rename from nixhome/profiles/python.nix rename to nixhome/stacks/python.nix index 1016e83b..36c101bf 100644 --- a/nixhome/profiles/python.nix +++ b/nixhome/stacks/python.nix @@ -2,6 +2,6 @@ imports = [ ../modules/base.nix ../modules/python.nix - ../modules/web.nix + ../modules/scraping ]; } diff --git a/nixhome/profiles/ultimate.nix b/nixhome/stacks/ultimate.nix similarity index 65% rename from nixhome/profiles/ultimate.nix rename to nixhome/stacks/ultimate.nix index f9a4a4e4..410fa241 100644 --- a/nixhome/profiles/ultimate.nix +++ b/nixhome/stacks/ultimate.nix @@ -3,7 +3,10 @@ ./fullstack.nix ../modules/desktop ../modules/electronics.nix + ../modules/financial.nix ../modules/graphics.nix + ../modules/news.nix ../modules/nixos.nix + ../modules/travel.nix ]; } diff --git a/test/desktop_test.go b/test/desktop_test.go deleted file mode 100644 index 1bf95cf1..00000000 --- a/test/desktop_test.go +++ /dev/null @@ -1,505 +0,0 @@ -package container_test - -// desktop_test.go — Tests for fluxbox desktop theme, taskbar, and menu styling. -// -// Verifies that nixhome/modules/desktop/theme.nix values are deployed -// correctly inside the container at /opt/devcell/.fluxbox/. -// -// Run against a GUI-capable image: -// DEVCELL_TEST_IMAGE=ghcr.io/dimmkirr/devcell:latest-ultimate go test -v -run TestDesktop ./... - -import ( - "context" - "strings" - "testing" - "time" - - "github.com/testcontainers/testcontainers-go" - "github.com/testcontainers/testcontainers-go/wait" -) - -const ( - devcellHome = "/opt/devcell" - themePath = devcellHome + "/.fluxbox/styles/devcell-ocean/theme.cfg" - overlayPath = devcellHome + "/.fluxbox/overlay" - initPath = devcellHome + "/.fluxbox/init" -) - -// assertContains checks that content contains the expected substring. -func assertContains(t *testing.T, label, content, expected string) { - t.Helper() - if !strings.Contains(content, expected) { - t.Errorf("%s: expected %q not found in:\n%s", label, expected, content) - } -} - -// ── Theme file existence ──────────────────────────────────────────────────── - -// TestDesktopThemeFileExists — theme.cfg must be deployed at the expected path. -func TestDesktopThemeFileExists(t *testing.T) { - c := startEnvContainer(t) - skipIfNoGUI(t, c) - - for _, path := range []string{themePath, overlayPath, initPath} { - out, code := exec(t, c, []string{"test", "-f", path}) - if code != 0 { - t.Errorf("FAIL: %s does not exist (exit %d): %s", path, code, out) - } else { - t.Logf("PASS: %s exists", path) - } - } -} - -// TestDesktopWallpaperExists — wallpaper.png must be deployed. -func TestDesktopWallpaperExists(t *testing.T) { - c := startEnvContainer(t) - skipIfNoGUI(t, c) - - path := devcellHome + "/.fluxbox/wallpaper.png" - _, code := exec(t, c, []string{"test", "-f", path}) - if code != 0 { - t.Fatalf("FAIL: wallpaper not found at %s", path) - } - // Verify it's a valid PNG (magic bytes) - out, code := exec(t, c, []string{"sh", "-c", "head -c 4 " + path + " | od -A n -t x1 | tr -d ' '"}) - if code != 0 { - t.Fatalf("FAIL: could not read wallpaper header: %s", out) - } - if !strings.Contains(out, "89504e47") { - t.Errorf("FAIL: wallpaper is not a valid PNG (magic: %s)", out) - } else { - t.Logf("PASS: wallpaper.png exists and is a valid PNG") - } -} - -// ── Toolbar styling ───────────────────────────────────────────────────────── - -// TestDesktopToolbarHeight — toolbar must be 35px. -func TestDesktopToolbarHeight(t *testing.T) { - c := startEnvContainer(t) - skipIfNoGUI(t, c) - - theme, _ := exec(t, c, []string{"cat", themePath}) - assertContains(t, "toolbar.height", theme, "toolbar.height: 35") -} - -// TestDesktopToolbarColors — toolbar uses the correct palette colors. -func TestDesktopToolbarColors(t *testing.T) { - c := startEnvContainer(t) - skipIfNoGUI(t, c) - - theme, _ := exec(t, c, []string{"cat", themePath}) - - // Toolbar background = border color (#000000) - assertContains(t, "toolbar.color", theme, "toolbar.color: #000000") - // Clock text = textBright (#ffffff) - assertContains(t, "toolbar.clock.textColor", theme, "toolbar.clock.textColor: #ffffff") - // Workspace badge = highlight (#b8e336) - assertContains(t, "toolbar.workspace.color", theme, "toolbar.workspace.color: #b8e336") - // Focused iconbar = raised (#1a1a2e) - assertContains(t, "toolbar.iconbar.focused.color", theme, "toolbar.iconbar.focused.color: #1a1a2e") - // Unfocused text = inactive (#667788) - assertContains(t, "toolbar.iconbar.unfocused.textColor", theme, "toolbar.iconbar.unfocused.textColor: #667788") -} - -// TestDesktopToolbarFonts — toolbar uses JetBrainsMono Nerd Font at correct sizes. -func TestDesktopToolbarFonts(t *testing.T) { - c := startEnvContainer(t) - skipIfNoGUI(t, c) - - theme, _ := exec(t, c, []string{"cat", themePath}) - - // Clock font = JetBrainsMono Nerd Font-9:bold - assertContains(t, "toolbar.clock.font", theme, "toolbar.clock.font: JetBrainsMono Nerd Font-9:bold") - // Workspace font = JetBrainsMono Nerd Font-9:bold - assertContains(t, "toolbar.workspace.font", theme, "toolbar.workspace.font: JetBrainsMono Nerd Font-9:bold") - // Focused iconbar font = JetBrainsMono Nerd Font-9:bold (same size as unfocused for baseline alignment) - assertContains(t, "toolbar.iconbar.focused.font", theme, "toolbar.iconbar.focused.font: JetBrainsMono Nerd Font-9:bold") -} - -// ── Menu styling ──────────────────────────────────────────────────────────── - -// TestDesktopMenuTitle — menu title uses highlight color, large bold font, left-justified. -func TestDesktopMenuTitle(t *testing.T) { - c := startEnvContainer(t) - skipIfNoGUI(t, c) - - theme, _ := exec(t, c, []string{"cat", themePath}) - - // Title background = highlight (#b8e336) - assertContains(t, "menu.title.color", theme, "menu.title.color: #b8e336") - // Title text = border (#000000) - assertContains(t, "menu.title.textColor", theme, "menu.title.textColor: #000000") - // Title font = JetBrainsMono Nerd Font-13:bold - assertContains(t, "menu.title.font", theme, "menu.title.font: JetBrainsMono Nerd Font-13:bold") - // Title justify = left - assertContains(t, "menu.title.justify", theme, "menu.title.justify: left") - // Title height = 32 - assertContains(t, "menu.titleHeight", theme, "menu.titleHeight: 32") -} - -// TestDesktopMenuBody — menu body uses surface color, white text, correct item height. -func TestDesktopMenuBody(t *testing.T) { - c := startEnvContainer(t) - skipIfNoGUI(t, c) - - theme, _ := exec(t, c, []string{"cat", themePath}) - - // Body background = surface (#0a0a18) - assertContains(t, "menu.frame.color", theme, "menu.frame.color: #0a0a18") - // Body text = textBright (#ffffff) - assertContains(t, "menu.frame.textColor", theme, "menu.frame.textColor: #ffffff") - // Body font = JetBrainsMono Nerd Font-11 - assertContains(t, "menu.frame.font", theme, "menu.frame.font: JetBrainsMono Nerd Font-11") - // Item height = 28 - assertContains(t, "menu.itemHeight", theme, "menu.itemHeight: 28") - // Highlight = highlight (#b8e336) - assertContains(t, "menu.hilite.color", theme, "menu.hilite.color: #b8e336") - // Border = 3px black (neobrutalist) - assertContains(t, "menu.borderWidth", theme, "menu.borderWidth: 3") - assertContains(t, "menu.borderColor", theme, "menu.borderColor: #000000") -} - -// ── Window styling ────────────────────────────────────────────────────────── - -// TestDesktopWindowBorder — windows must have thick 3px black border (neobrutalist signature). -func TestDesktopWindowBorder(t *testing.T) { - c := startEnvContainer(t) - skipIfNoGUI(t, c) - - theme, _ := exec(t, c, []string{"cat", themePath}) - - assertContains(t, "window.borderWidth", theme, "window.borderWidth: 3") - assertContains(t, "window.borderColor", theme, "window.borderColor: #000000") -} - -// TestDesktopWindowTitle — focused window title bar must be 30px, flat, black bg. -func TestDesktopWindowTitle(t *testing.T) { - c := startEnvContainer(t) - skipIfNoGUI(t, c) - - theme, _ := exec(t, c, []string{"cat", themePath}) - - assertContains(t, "window.title.height", theme, "window.title.height: 30") - assertContains(t, "window.title.focus", theme, "window.title.focus: flat") - assertContains(t, "window.title.focus.color", theme, "window.title.focus.color: #000000") - // Title text font = JetBrainsMono Nerd Font-10:bold - assertContains(t, "window.label.focus.font", theme, "window.label.focus.font: JetBrainsMono Nerd Font-10:bold") - // Title text color = text (#f0f0f0) - assertContains(t, "window.label.focus.textColor", theme, "window.label.focus.textColor: #f0f0f0") -} - -// TestDesktopWindowHandle — focused handle = highlight green, 10px width. -func TestDesktopWindowHandle(t *testing.T) { - c := startEnvContainer(t) - skipIfNoGUI(t, c) - - theme, _ := exec(t, c, []string{"cat", themePath}) - - assertContains(t, "window.handleWidth", theme, "window.handleWidth: 10") - assertContains(t, "window.handle.focus.color", theme, "window.handle.focus.color: #b8e336") -} - -// ── Pixmaps ───────────────────────────────────────────────────────────────── - -// TestDesktopPixmapsExist — all 6 window button pixmaps must be deployed. -func TestDesktopPixmapsExist(t *testing.T) { - c := startEnvContainer(t) - skipIfNoGUI(t, c) - - pixmapDir := devcellHome + "/.fluxbox/styles/devcell-ocean/pixmaps" - pixmaps := []string{ - "close.xpm", "close_unfocus.xpm", - "max.xpm", "max_unfocus.xpm", - "min.xpm", "min_unfocus.xpm", - } - for _, p := range pixmaps { - path := pixmapDir + "/" + p - _, code := exec(t, c, []string{"test", "-f", path}) - if code != 0 { - t.Errorf("FAIL: pixmap %s not found", path) - } else { - t.Logf("PASS: %s exists", p) - } - } -} - -// TestDesktopPixmapReferences — theme.cfg must reference the pixmap files. -func TestDesktopPixmapReferences(t *testing.T) { - c := startEnvContainer(t) - skipIfNoGUI(t, c) - - theme, _ := exec(t, c, []string{"cat", themePath}) - - assertContains(t, "window.close.pixmap", theme, "window.close.pixmap: pixmaps/close.xpm") - assertContains(t, "window.maximize.pixmap", theme, "window.maximize.pixmap: pixmaps/max.xpm") - assertContains(t, "window.iconify.pixmap", theme, "window.iconify.pixmap: pixmaps/min.xpm") -} - -// ── Init / session config ─────────────────────────────────────────────────── - -// TestDesktopInitConfig — fluxbox init must reference the correct style and menu paths. -func TestDesktopInitConfig(t *testing.T) { - c := startEnvContainer(t) - skipIfNoGUI(t, c) - - init, _ := exec(t, c, []string{"cat", initPath}) - - assertContains(t, "styleFile", init, "session.styleFile:\t"+devcellHome+"/.fluxbox/styles/devcell-ocean/theme.cfg") - assertContains(t, "menuFile", init, "session.menuFile:\t"+devcellHome+"/.fluxbox/menu") - assertContains(t, "overlay", init, "session.styleOverlay:\t"+devcellHome+"/.fluxbox/overlay") - assertContains(t, "toolbar.placement", init, "session.screen0.toolbar.placement: BottomCenter") - assertContains(t, "toolbar.widthPercent", init, "session.screen0.toolbar.widthPercent: 100") - assertContains(t, "toolbar.visible", init, "session.screen0.toolbar.visible: true") -} - -// TestDesktopOverlayMatchesTheme — overlay must contain the same content as theme.cfg. -func TestDesktopOverlayMatchesTheme(t *testing.T) { - c := startEnvContainer(t) - skipIfNoGUI(t, c) - - theme, _ := exec(t, c, []string{"cat", themePath}) - overlay, _ := exec(t, c, []string{"cat", overlayPath}) - - if theme != overlay { - t.Errorf("FAIL: overlay content differs from theme.cfg\ntheme.cfg length: %d\noverlay length: %d", len(theme), len(overlay)) - } else { - t.Logf("PASS: overlay matches theme.cfg (%d bytes)", len(theme)) - } -} - -// ── Xresources ────────────────────────────────────────────────────────────── - -// TestDesktopXresources — Xresources must set XTerm colors from the palette. -func TestDesktopXresources(t *testing.T) { - c := startEnvContainer(t) - skipIfNoGUI(t, c) - - xres, _ := exec(t, c, []string{"cat", devcellHome + "/.Xresources"}) - - // Background = surface (#0a0a18) - assertContains(t, "XTerm*background", xres, "XTerm*background: #0a0a18") - // Cursor = accent (#1abc9c) - assertContains(t, "XTerm*cursorColor", xres, "XTerm*cursorColor: #1abc9c") - // Font = JetBrainsMono Nerd Font - assertContains(t, "XTerm*faceName", xres, "XTerm*faceName: JetBrainsMono Nerd Font") - // Font size = 11 - assertContains(t, "XTerm*faceSize", xres, "XTerm*faceSize: 11") -} - -// ── Patchright stealth MCP ─────────────────────────────────────────────────── - -// TestDesktopPatchrightMcpAvailable verifies mcp-server-patchright binary is on PATH. -// The npm package is "patchright-mcp" but its bin name is "mcp-server-patchright". -func TestDesktopPatchrightMcpAvailable(t *testing.T) { - c := startContainer(t, map[string]string{"HOST_USER": hostUser, "APP_NAME": "test"}) - - out, code := exec(t, c, []string{"sh", "-c", "command -v mcp-server-patchright"}) - if code != 0 { - t.Fatalf("FAIL: mcp-server-patchright not on PATH (exit %d)", code) - } - t.Logf("PASS: %s", strings.TrimSpace(out)) -} - -// TestDesktopStealthInitScript verifies the stealth init-script exists in nix store. -func TestDesktopStealthInitScript(t *testing.T) { - c := startContainer(t, map[string]string{"HOST_USER": hostUser, "APP_NAME": "test"}) - - out, code := exec(t, c, []string{"sh", "-c", - "ls -1 /nix/store/*stealth-init.js 2>/dev/null | head -1"}) - if code != 0 || strings.TrimSpace(out) == "" { - t.Fatalf("FAIL: stealth-init.js not found in nix store") - } - // Verify key stealth patches are present - content, _ := exec(t, c, []string{"cat", strings.TrimSpace(out)}) - if !strings.Contains(content, "navigator.webdriver") { - t.Errorf("FAIL: stealth-init.js missing webdriver patch") - } - if !strings.Contains(content, "Intel Inc.") { - t.Errorf("FAIL: stealth-init.js missing WebGL spoof") - } - t.Logf("PASS: stealth-init.js found at %s", strings.TrimSpace(out)) -} - -// TestDesktopPatchrightMcpCellWrapper verifies the patchright-mcp-cell wrapper exists -// and references patchright-mcp (not playwright-mcp). -func TestDesktopPatchrightMcpCellWrapper(t *testing.T) { - c := startContainer(t, map[string]string{"HOST_USER": hostUser, "APP_NAME": "test"}) - - out, code := exec(t, c, []string{"sh", "-c", "command -v patchright-mcp-cell"}) - if code != 0 { - t.Fatalf("FAIL: patchright-mcp-cell not on PATH (exit %d)", code) - } - // Read the wrapper and verify it calls mcp-server-patchright - wrapper, _ := exec(t, c, []string{"cat", strings.TrimSpace(out)}) - if !strings.Contains(wrapper, "mcp-server-patchright") { - t.Errorf("FAIL: wrapper does not call mcp-server-patchright") - } - if strings.Contains(wrapper, "playwright-mcp ") { - t.Errorf("FAIL: wrapper still references playwright-mcp") - } - t.Logf("PASS: patchright-mcp-cell wrapper found at %s", strings.TrimSpace(out)) -} - -// ── Runtime GUI tests (require full GUI stack) ────────────────────────────── - -// startDesktopGUIContainer starts a container with DEVCELL_GUI_ENABLED=true -// and waits for the full GUI stack (Xvfb + fluxbox + x11vnc) to be running. -func startDesktopGUIContainer(t *testing.T) testcontainers.Container { - t.Helper() - ctx := context.Background() - req := testcontainers.ContainerRequest{ - Image: image(), - Env: map[string]string{ - "HOST_USER": hostUser, - "APP_NAME": "test", - "DEVCELL_GUI_ENABLED": "true", - }, - User: "0", - Cmd: []string{"tail", "-f", "/dev/null"}, - WaitingFor: wait.ForExec([]string{"sh", "-c", - "grep -qi 170C /proc/net/tcp6 /proc/net/tcp 2>/dev/null && grep -qi ' 0A ' /proc/net/tcp6 /proc/net/tcp 2>/dev/null"}). - WithStartupTimeout(60 * time.Second), - } - c, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ - ContainerRequest: req, - Started: true, - }) - if err != nil { - t.Fatalf("start desktop GUI container: %v", err) - } - t.Cleanup(func() { _ = c.Terminate(ctx) }) - return c -} - -// TestDesktopXresourcesLoaded — with GUI enabled, xrdb must have loaded the -// Xresources into the X server's resource database at startup. -// xrdb loading is deferred (background process after entrypoint exec gosu) -// so we retry for up to 5 seconds. -func TestDesktopXresourcesLoaded(t *testing.T) { - probeGUI(t) - c := startDesktopGUIContainer(t) - - // xrdb is loaded by a deferred background process (~1s after entrypoint); - // retry for up to 5 seconds to allow it to complete. - var out string - var code int - for i := 0; i < 10; i++ { - out, code = exec(t, c, []string{"sh", "-c", "DISPLAY=:99 xrdb -query"}) - if code == 0 && out != "" { - break - } - time.Sleep(500 * time.Millisecond) - } - if code != 0 { - t.Fatalf("xrdb -query failed (exit %d): %s", code, out) - } - if out == "" { - t.Fatal("FAIL: X resource database is empty — Xresources not loaded at startup") - } - - // Verify key resources from theme.nix palette - assertContains(t, "XTerm*background", out, "XTerm*background:\t#0a0a18") - assertContains(t, "XTerm*cursorColor", out, "XTerm*cursorColor:\t#1abc9c") - assertContains(t, "XTerm*faceName", out, "XTerm*faceName:\tJetBrainsMono Nerd Font") - assertContains(t, "XTerm*faceSize", out, "XTerm*faceSize:\t11") - t.Logf("PASS: Xresources loaded into X server (%d lines)", strings.Count(out, "\n")+1) -} - -// TestDesktopFluxboxThemeActive — with GUI enabled, fluxbox must be running -// with the devcell-ocean theme (not default grey). -func TestDesktopFluxboxThemeActive(t *testing.T) { - probeGUI(t) - c := startDesktopGUIContainer(t) - - // Verify fluxbox is running with our patched init - out, code := exec(t, c, []string{"sh", "-c", "cat /tmp/fluxbox-init"}) - if code != 0 { - t.Fatalf("could not read /tmp/fluxbox-init (exit %d): %s", code, out) - } - // The init must point to our custom theme - assertContains(t, "styleFile", out, - "session.styleFile:\t"+devcellHome+"/.fluxbox/styles/devcell-ocean/theme.cfg") - // Workspace name must be patched with APP_NAME - assertContains(t, "workspaceNames", out, "test") - t.Logf("PASS: fluxbox running with devcell-ocean theme, workspace='test'") -} - -// TestDesktopXftDpi96 — Xft.dpi must be set to 96 in X resources so that -// Chromium reports devicePixelRatio=1 and window.screen matches the Xvfb -// resolution (1920x1080). Without this, Xvfb reports 0mm x 0mm physical -// size, causing Chromium to calculate a non-1.0 DPR (~1.047) and report -// the screen as ~1835x1032 instead of 1920x1080. -func TestDesktopXftDpi96(t *testing.T) { - probeGUI(t) - c := startDesktopGUIContainer(t) - - // Wait for xrdb to load (deferred background process, ~1s) - var out string - var code int - for i := 0; i < 10; i++ { - out, code = exec(t, c, []string{"sh", "-c", "DISPLAY=:99 xrdb -query 2>&1"}) - if code == 0 && strings.Contains(out, "Xft.dpi") { - break - } - time.Sleep(500 * time.Millisecond) - } - if !strings.Contains(out, "Xft.dpi") { - t.Fatalf("FAIL: Xft.dpi not found in X resource database:\n%s", out) - } - assertContains(t, "Xft.dpi", out, "Xft.dpi:\t96") - t.Logf("PASS: Xft.dpi set to 96 in X resources") -} - -// TestDesktopXvfbDpi96 — Xvfb must be started with -dpi 96 for consistent -// rendering across all X11 clients, not just those reading Xft.dpi. -func TestDesktopXvfbDpi96(t *testing.T) { - probeGUI(t) - c := startDesktopGUIContainer(t) - - out, code := exec(t, c, []string{"sh", "-c", - "cat /proc/$(pgrep Xvfb)/cmdline 2>/dev/null | tr '\\0' ' '"}) - if code != 0 { - t.Fatalf("FAIL: could not read Xvfb cmdline (exit %d): %s", code, out) - } - if !strings.Contains(out, "-dpi 96") && !strings.Contains(out, "-dpi96") { - t.Errorf("FAIL: Xvfb not started with -dpi 96:\n%s", out) - } else { - t.Logf("PASS: Xvfb started with -dpi 96") - } -} - -// TestDesktopScreenshotNotDefaultGrey — capture a screenshot and verify -// the taskbar is not the default fluxbox grey (#bebebd) but our black (#000000). -// Uses ImageMagick's `import` to capture the X root window. -func TestDesktopScreenshotNotDefaultGrey(t *testing.T) { - probeGUI(t) - c := startDesktopGUIContainer(t) - - // Capture a screenshot - _, code := exec(t, c, []string{"sh", "-c", "DISPLAY=:99 import -window root /tmp/desktop.png"}) - if code != 0 { - t.Skip("import (ImageMagick) not available — skipping screenshot test") - } - - // Sample the taskbar area (bottom 40px strip, center pixel) - // Use ImageMagick to get the color of a pixel at (960, 1060) — middle of the toolbar - // on a 1920x1080 screen with 40px toolbar at bottom. - out, code := exec(t, c, []string{"sh", "-c", - "DISPLAY=:99 convert /tmp/desktop.png -crop 1x1+960+1060 -format '%[hex:u.p{0,0}]' info:"}) - if code != 0 { - t.Skipf("convert not available (exit %d): %s", code, out) - } - - // The toolbar pixel should be dark (our theme: #000000) not default grey (#bebebd) - out = strings.TrimSpace(strings.ToUpper(out)) - t.Logf("Toolbar pixel color at (960,1060): #%s", out) - - // Default fluxbox grey is around BEBEBD; our theme is 000000. - // Allow for some variation but it should definitely not be light grey. - if strings.HasPrefix(out, "BE") || strings.HasPrefix(out, "C0") || strings.HasPrefix(out, "D0") { - t.Errorf("FAIL: toolbar appears to use default grey color (#%s) — theme not applied", out) - } else { - t.Logf("PASS: toolbar is not default grey (#%s)", out) - } -} diff --git a/test/dotenv_parse_test.go b/test/dotenv_parse_test.go deleted file mode 100644 index 0ed6e827..00000000 --- a/test/dotenv_parse_test.go +++ /dev/null @@ -1,144 +0,0 @@ -package container_test - -// dotenv_parse_test.go — pure unit tests for the .env key-extraction logic used -// by the playwright-mcp-cell wrapper (desktop/default.nix). -// -// These tests run without any container image — they replicate the shell logic in Go -// so it can be validated quickly in CI before building the image. -// -// The logic under test: -// while IFS= read -r _line; do -// case "$_line" in '#'*|'') continue ;; esac -// _key="${_line%%=*}" -// _key="${_key#export }" -// [ -z "$_key" ] && continue -// if _val=$(printenv "$_key"); then printf '%s=%s\n' "$_key" "$_val"; fi -// done < .env - -import ( - "strings" - "testing" -) - -// extractDotEnvKeys replicates the shell's key-extraction logic from the wrapper. -// It returns the list of key names found in the .env content, in order, skipping -// comments and blank lines. -func extractDotEnvKeys(content string) []string { - var keys []string - for _, line := range strings.Split(content, "\n") { - // skip comments and blank lines - if line == "" || strings.HasPrefix(line, "#") { - continue - } - // _key="${_line%%=*}" — take everything before the first '=' - key := line - if i := strings.IndexByte(line, '='); i >= 0 { - key = line[:i] - } - // _key="${_key#export }" - key = strings.TrimPrefix(key, "export ") - if key == "" { - continue - } - keys = append(keys, key) - } - return keys -} - -func TestDotEnvKeyExtraction(t *testing.T) { - tests := []struct { - name string - content string - wantKeys []string - }{ - { - name: "simple KEY=VALUE pairs", - content: "TEST_PASSWORD=hello123\nGITHUB_TOKEN=ghtoken456\n", - wantKeys: []string{"TEST_PASSWORD", "GITHUB_TOKEN"}, - }, - { - name: "export prefix stripped", - content: "export SECRET_KEY=value\nexport OTHER=x\n", - wantKeys: []string{"SECRET_KEY", "OTHER"}, - }, - { - name: "comments and blank lines skipped", - content: "# this is a comment\n\nMY_KEY=value\n\n# another comment\nSECOND=val\n", - wantKeys: []string{"MY_KEY", "SECOND"}, - }, - { - name: "empty value (KEY=) still yields key", - content: "TEST_USERNAME=\nTEST_PASSWORD=\n", - wantKeys: []string{"TEST_USERNAME", "TEST_PASSWORD"}, - }, - { - name: "key with no equals sign", - content: "BARE_KEY\n", - wantKeys: []string{"BARE_KEY"}, - }, - { - name: "value contains equals sign", - content: "DB_URL=postgres://host:5432/db?sslmode=disable\n", - wantKeys: []string{"DB_URL"}, - }, - { - name: "empty file", - content: "", - wantKeys: nil, - }, - { - name: "only comments and blanks", - content: "# comment\n\n# another\n", - wantKeys: nil, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - got := extractDotEnvKeys(tc.content) - if len(got) != len(tc.wantKeys) { - t.Errorf("got keys %v, want %v", got, tc.wantKeys) - return - } - for i, k := range tc.wantKeys { - if got[i] != k { - t.Errorf("key[%d]: got %q, want %q", i, got[i], k) - } - } - }) - } -} - -// TestDotEnvOnlyDotEnvKeysForwarded verifies that only keys present in .env -// are forwarded — simulating the wrapper's env-lookup step. -func TestDotEnvOnlyDotEnvKeysForwarded(t *testing.T) { - dotEnv := "TEST_PASSWORD=placeholder\nGITHUB_TOKEN=placeholder\n" - containerEnv := map[string]string{ - "TEST_PASSWORD": "hello123", - "GITHUB_TOKEN": "ghtoken456", - "APP_NAME": "test", // in container env but NOT in .env - "HOST_USER": "testuser", // in container env but NOT in .env - } - - keys := extractDotEnvKeys(dotEnv) - secrets := map[string]string{} - for _, k := range keys { - if v, ok := containerEnv[k]; ok { - secrets[k] = v - } - } - - if secrets["TEST_PASSWORD"] != "hello123" { - t.Errorf("TEST_PASSWORD: got %q, want hello123", secrets["TEST_PASSWORD"]) - } - if secrets["GITHUB_TOKEN"] != "ghtoken456" { - t.Errorf("GITHUB_TOKEN: got %q, want ghtoken456", secrets["GITHUB_TOKEN"]) - } - if _, ok := secrets["APP_NAME"]; ok { - t.Errorf("APP_NAME must not be forwarded (not in .env), got %q", secrets["APP_NAME"]) - } - if _, ok := secrets["HOST_USER"]; ok { - t.Errorf("HOST_USER must not be forwarded (not in .env), got %q", secrets["HOST_USER"]) - } - t.Logf("PASS: only .env keys forwarded: %v", secrets) -} diff --git a/test/entrypoint_test.go b/test/entrypoint_test.go deleted file mode 100644 index 0a1a9c3e..00000000 --- a/test/entrypoint_test.go +++ /dev/null @@ -1,228 +0,0 @@ -package container_test - -import ( - "context" - "fmt" - "os" - osexec "os/exec" - "path/filepath" - "regexp" - "strings" - "testing" - "time" - - "github.com/DimmKirr/devcell/internal/scaffold" - "github.com/testcontainers/testcontainers-go" - "github.com/testcontainers/testcontainers-go/wait" -) - -// buildTestUserImage builds a user image from a scaffolded config directory. -// Returns the tag. Removes the image on cleanup. -func buildTestUserImage(t *testing.T, configDir string) string { - t.Helper() - tag := fmt.Sprintf("devcell-test-user:%s-%s", shortSHA(), time.Now().Format("20060102T150405")) - t.Logf("Building user image: %s (from %s)", tag, configDir) - - cmd := osexec.Command("docker", "build", "-t", tag, configDir) - cmd.Dir = filepath.Join("..") - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - if err := cmd.Run(); err != nil { - t.Fatalf("build user image: %v", err) - } - t.Cleanup(func() { osexec.Command("docker", "rmi", tag).Run() }) - return tag -} - -// TestEntrypointFragments is an e2e test that verifies the full -// scaffold → build → run flow for nix-generated entrypoint fragments. -// -// The test: -// 1. Builds (or reuses) a base image with the entrypoint.d sourcing loop -// 2. Scaffolds a config dir using that base image -// 3. Builds a user image (home-manager switch stages 50-gui.sh) -// 4. Starts a container with DEVCELL_GUI_ENABLED=true -// 5. Verifies 50-gui.sh is staged and GUI services are running -// -// Set DEVCELL_TEST_BASE_IMAGE to skip the base image build (CI pre-builds it). -// Skipped in -short mode. -func TestEntrypointFragments(t *testing.T) { - if testing.Short() { - t.Skip("skipping in short mode") - } - - // 1. Resolve base image. - baseImg := baseImage() - - // 2. Scaffold config dir with this base image. - configDir := t.TempDir() - t.Setenv("DEVCELL_BASE_IMAGE", baseImg) - if err := scaffold.Scaffold(configDir, "", "", false); err != nil { - t.Fatalf("scaffold: %v", err) - } - - // Verify Dockerfile FROM line. - dockerfile, err := os.ReadFile(filepath.Join(configDir, "Dockerfile")) - if err != nil { - t.Fatalf("read Dockerfile: %v", err) - } - if !strings.HasPrefix(string(dockerfile), "FROM "+baseImg) { - t.Fatalf("Dockerfile FROM doesn't match base image: got %.80s", string(dockerfile)) - } - t.Logf("Scaffold OK: Dockerfile FROM %s", baseImg) - - // 3. Build user image. - userImage := buildTestUserImage(t, configDir) - - // 4. Start container with GUI enabled, wait for xrdp to listen on 3389. - ctx := context.Background() - req := testcontainers.ContainerRequest{ - Image: userImage, - ExposedPorts: []string{"3389/tcp", "5900/tcp"}, - Env: map[string]string{ - "HOST_USER": hostUser, - "APP_NAME": "test", - "DEVCELL_GUI_ENABLED": "true", - }, - User: "0", - Cmd: []string{"tail", "-f", "/dev/null"}, - WaitingFor: wait.ForExec([]string{"sh", "-c", - "grep -qi 0D3D /proc/net/tcp6 /proc/net/tcp 2>/dev/null && grep -qi ' 0A ' /proc/net/tcp6 /proc/net/tcp 2>/dev/null"}). - WithStartupTimeout(120 * time.Second), - } - c, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ - ContainerRequest: req, - Started: true, - }) - if err != nil { - t.Fatalf("start container: %v", err) - } - t.Cleanup(func() { _ = c.Terminate(ctx) }) - - // 5. Verify entrypoint fragments and GUI services. - t.Run("fragment_staged", func(t *testing.T) { - out, code := exec(t, c, []string{"ls", "-la", "/etc/devcell/entrypoint.d/50-gui.sh"}) - if code != 0 { - t.Fatalf("FAIL: 50-gui.sh not found in /etc/devcell/entrypoint.d/ (exit %d)", code) - } - if !strings.Contains(out, "x") { - t.Errorf("FAIL: 50-gui.sh should be executable: %s", out) - } - t.Logf("PASS: %s", out) - }) - - t.Run("xvfb_running", func(t *testing.T) { - _, code := exec(t, c, []string{"pgrep", "Xvfb"}) - if code != 0 { - t.Fatalf("FAIL: Xvfb process not found (exit %d)", code) - } - t.Logf("PASS: Xvfb is running") - }) - - t.Run("xrdp_running", func(t *testing.T) { - _, code := exec(t, c, []string{"pgrep", "xrdp"}) - if code != 0 { - t.Fatalf("FAIL: xrdp process not found (exit %d)", code) - } - t.Logf("PASS: xrdp is running") - }) - - t.Run("xrdp_listening", func(t *testing.T) { - out, code := exec(t, c, []string{"sh", "-c", - "grep -i 0D3D /proc/net/tcp6 /proc/net/tcp 2>/dev/null | grep ' 0A '"}) - if code != 0 || !strings.Contains(strings.ToUpper(out), "0D3D") { - t.Fatalf("FAIL: port 3389 (0x0D3D) not in LISTEN state:\n%s", out) - } - t.Logf("PASS: xrdp listening on :3389\n%s", out) - }) -} - -// TestEntrypointDebugTimestamps verifies that DEVCELL_DEBUG=true produces -// timestamped log lines in the format [X.XXXs]. -func TestEntrypointDebugTimestamps(t *testing.T) { - if testing.Short() { - t.Skip("skipping in short mode") - } - img := baseImage() - - out, err := osexec.Command("docker", "run", "--rm", - "--user", "0", - "-e", "HOST_USER=testuser", - "-e", "APP_NAME=tstest", - "-e", "DEVCELL_DEBUG=true", - img, - "echo", "ready", - ).CombinedOutput() - if err != nil { - t.Fatalf("docker run: %v\noutput: %s", err, out) - } - - output := string(out) - t.Logf("Debug output:\n%s", output) - - // Every log line should have a timestamp like [0.123s] or [1.456s] - tsPattern := regexp.MustCompile(`\[\d+\.\d{3}s\]`) - if !tsPattern.MatchString(output) { - t.Fatalf("FAIL: no timestamped log lines found (expected [X.XXXs] format)") - } - - // Verify multiple log lines have timestamps (not just one) - matches := tsPattern.FindAllString(output, -1) - t.Logf("PASS: found %d timestamped log lines", len(matches)) - if len(matches) < 2 { - t.Errorf("expected at least 2 timestamped lines, got %d", len(matches)) - } - - // Verify no log lines WITHOUT timestamps (lines that look like log output but lack [X.XXXs]) - for _, line := range strings.Split(output, "\n") { - line = strings.TrimSpace(line) - if line == "" || line == "ready" { - continue - } - // Lines starting with ✓ or containing known log markers should have timestamps - if (strings.Contains(line, "✓") || strings.Contains(line, "Installing") || - strings.Contains(line, "Starting") || strings.Contains(line, "Merging")) && - !tsPattern.MatchString(line) { - t.Errorf("FAIL: log line missing timestamp: %s", line) - } - } -} - -// TestEntrypointSilentWithoutDebug verifies that without DEVCELL_DEBUG, the -// entrypoint produces no log output (spinner is now in the Go CLI, not the entrypoint). -func TestEntrypointSilentWithoutDebug(t *testing.T) { - if testing.Short() { - t.Skip("skipping in short mode") - } - img := baseImage() - - out, err := osexec.Command("docker", "run", "--rm", - "--user", "0", - "-e", "HOST_USER=testuser", - "-e", "APP_NAME=myapp42", - img, - "echo", "ready", - ).CombinedOutput() - if err != nil { - t.Fatalf("docker run: %v\noutput: %s", err, out) - } - - output := string(out) - t.Logf("Non-debug output:\n%s", output) - - // No debug timestamps should appear - tsPattern := regexp.MustCompile(`\[\d+\.\d{3}s\]`) - if tsPattern.MatchString(output) { - t.Errorf("FAIL: debug timestamps found in non-debug mode") - } else { - t.Logf("PASS: no debug timestamps in non-debug mode") - } - - // No verbose log lines should appear - for _, marker := range []string{"Installing global tool", "Starting Xvfb", "Starting fluxbox", "Merging Claude"} { - if strings.Contains(output, marker) { - t.Errorf("FAIL: debug log line leaked in non-debug mode: %s", marker) - } - } - t.Logf("PASS: no debug log lines leaked") -} diff --git a/test/environment_test.go b/test/environment_test.go deleted file mode 100644 index 7b405f81..00000000 --- a/test/environment_test.go +++ /dev/null @@ -1,221 +0,0 @@ -package container_test - -// environment_test.go — TDD tests for the /opt/devcell refactor. -// -// RED (current image): TestNixAvailable fails — nix not in session user PATH. -// GREEN (after refactor): all tests pass. -// -// Run against current image to capture baseline: -// go test -v -run TestEnv ./... -// Run against refactored image: -// DEVCELL_TEST_IMAGE=ghcr.io/dimmkirr/devcell:opt-devcell go test -v -run TestEnv ./... - -import ( - "fmt" - "strconv" - "strings" - "testing" - "time" - - "github.com/testcontainers/testcontainers-go" -) - -const hostUser = "testuser" - -// ── helpers ─────────────────────────────────────────────────────────────────── - -func startEnvContainer(t *testing.T) testcontainers.Container { - t.Helper() - return startContainer(t, map[string]string{ - "HOST_USER": hostUser, - "APP_NAME": "test", - }) -} - -// asUser runs cmd as the session user inside a login shell so ~/.bashrc is sourced. -func asUser(t *testing.T, c testcontainers.Container, cmd string) (string, int) { - t.Helper() - return exec(t, c, []string{"gosu", hostUser, "bash", "-lc", cmd}) -} - -// ── 1. Nix toolchain ───────────────────────────────────────────────────────── - -// TestEnvNixAvailable — nix CLI must be on PATH for the session user. -// -// GREEN: /opt/devcell/.nix-profile/bin is on PATH unconditionally via .bashrc sed substitution. -func TestEnvNixAvailable(t *testing.T) { - c := startEnvContainer(t) - out, code := exec(t, c, []string{"gosu", hostUser, "bash", "-lc", "nix --version"}) - if code != 0 { - t.Fatalf("FAIL: nix not available for session user %q (exit %d)\n%s", hostUser, code, out) - } - t.Logf("PASS: %s", out) -} - -// TestEnvNixProfilePath — /opt/devcell must exist and contain a .nix-profile. -// -// GREEN: homeDirectory = /opt/devcell hardcoded in flake.nix. -func TestEnvNixProfilePath(t *testing.T) { - c := startEnvContainer(t) - - for _, path := range []string{ - "/opt/devcell", - "/opt/devcell/.nix-profile", - "/opt/devcell/.nix-profile/bin", - } { - _, code := exec(t, c, []string{"test", "-e", path}) - if code != 0 { - t.Errorf("FAIL: %s does not exist", path) - } else { - t.Logf("PASS: %s exists", path) - } - } -} - -// TestEnvCompatSymlink — /nix/var/nix/profiles/per-user/devcell/profile must resolve. -// -// GREEN: compat symlink hardcoded to devcell in Dockerfile. -func TestEnvCompatSymlink(t *testing.T) { - c := startEnvContainer(t) - out, code := exec(t, c, []string{"readlink", "-f", "/nix/var/nix/profiles/per-user/devcell/profile"}) - if code != 0 || !strings.Contains(out, "/nix/store/") { - t.Fatalf("FAIL: /nix/var/nix/profiles/per-user/devcell/profile doesn't resolve to /nix/store (exit %d): %q", code, out) - } - t.Logf("PASS: compat symlink → %s", out) -} - -// ── 2. Session identity ─────────────────────────────────────────────────────── - -// TestEnvSessionIdentity — $HOME and $USER must match HOST_USER (session user, not nix owner devcell). -func TestEnvSessionIdentity(t *testing.T) { - c := startEnvContainer(t) - - expectedHome := "/home/" + hostUser - - cases := []struct { - name string - cmd string - contains string - }{ - {"whoami", "whoami", hostUser}, - {"HOME", "echo $HOME", expectedHome}, - {"USER", "echo $USER", hostUser}, - } - - for _, tc := range cases { - out, code := exec(t, c, []string{"gosu", hostUser, "bash", "-lc", tc.cmd}) - if code != 0 || !strings.Contains(out, tc.contains) { - t.Errorf("FAIL %s: want %q, got %q (exit %d)", tc.name, tc.contains, out, code) - } else { - t.Logf("PASS %s: %q", tc.name, out) - } - } -} - -// ── 3. Write paths ─────────────────────────────────────────────────────────── - -// TestEnvWritePaths — GOPATH and home must be writable by the session user. -// GOPATH must NOT point into /opt/devcell (read-only for session user). -// -// GREEN: entrypoint substitutes /opt/devcell → /home/$HOST_USER in .bashrc via sed. -func TestEnvWritePaths(t *testing.T) { - c := startEnvContainer(t) - - // GOPATH must not be inside /opt/devcell - gopath, code := exec(t, c, []string{"gosu", hostUser, "bash", "-lc", "go env GOPATH"}) - if code != 0 { - t.Fatalf("FAIL: could not get GOPATH (exit %d): %s", code, gopath) - } - if strings.HasPrefix(gopath, "/opt/devcell") { - t.Errorf("FAIL: GOPATH=%q points into /opt/devcell — session user can't write there", gopath) - } else { - t.Logf("PASS: GOPATH=%q (not in /opt/devcell)", gopath) - } - - // GOPATH must be writable - probe := gopath + "/.write-test-" + strconv.FormatInt(time.Now().UnixNano(), 36) - _, code = exec(t, c, []string{"gosu", hostUser, "bash", "-lc", - fmt.Sprintf("mkdir -p %q && rmdir %q", probe, probe)}) - if code != 0 { - t.Errorf("FAIL: GOPATH=%q is not writable by session user", gopath) - } else { - t.Logf("PASS: GOPATH=%q is writable", gopath) - } - - // $HOME must be writable - _, code = exec(t, c, []string{"gosu", hostUser, "bash", "-lc", - "touch ~/.write-test && rm ~/.write-test"}) - if code != 0 { - t.Errorf("FAIL: $HOME is not writable by session user") - } else { - t.Logf("PASS: $HOME is writable") - } - - // /opt/devcell must NOT be writable by session user (it's the nix env, owned by devcell) - _, code = exec(t, c, []string{"gosu", hostUser, "bash", "-lc", - "touch /opt/devcell/.write-test 2>/dev/null"}) - if code == 0 { - t.Errorf("FAIL: session user can write to /opt/devcell — should be read-only") - // cleanup - exec(t, c, []string{"rm", "-f", "/opt/devcell/.write-test"}) //nolint - } else { - t.Logf("PASS: /opt/devcell is read-only for session user") - } -} - -// ── 4. Base image permissions ──────────────────────────────────────────────── - -// TestEnvBasePermissions — /opt/devcell directories must be owned by devcell (uid 1000). -// Regression: COPY/mkdir in Dockerfile can create intermediate dirs as root, -// breaking home-manager switch (flake.lock write) and nix config setup. -func TestEnvBasePermissions(t *testing.T) { - c := startEnvContainer(t) - - dirs := []string{ - "/opt/devcell", - "/opt/devcell/.config", - "/opt/devcell/.config/nix", - "/opt/devcell/.config/devcell", - "/opt/devcell/.nix-profile", - "/opt/mise", - "/opt/npm-tools", - "/opt/python-tools", - } - - for _, dir := range dirs { - out, code := exec(t, c, []string{"stat", "-c", "%u:%g", dir}) - if code != 0 { - t.Errorf("FAIL: %s does not exist", dir) - continue - } - if out != "1000:1000" { - t.Errorf("FAIL: %s owned by %s, want 1000:1000", dir, out) - } else { - t.Logf("PASS: %s owned by %s", dir, out) - } - } -} - -// ── 5. Startup time ────────────────────────────────────────────────────────── - -// TestEnvStartupTime — container must reach ready state within budget. -// -// Before refactor: ~15-25s (home-manager switch + asdf plugin updates). -// After refactor target: <5s (no home-manager switch at startup). -func TestEnvStartupTime(t *testing.T) { - const budgetSeconds = 10 // generous — tighten after refactor confirmed - - start := time.Now() - _ = startContainer(t, map[string]string{ - "HOST_USER": hostUser, - "APP_NAME": "test", - }) - elapsed := time.Since(start) - - t.Logf("Startup time: %.1fs (budget: %ds)", elapsed.Seconds(), budgetSeconds) - if elapsed > time.Duration(budgetSeconds)*time.Second { - t.Errorf("FAIL: startup took %.1fs, over %ds budget", elapsed.Seconds(), budgetSeconds) - } else { - t.Logf("PASS: within budget") - } -} diff --git a/test/git_test.go b/test/git_test.go deleted file mode 100644 index 260a51ed..00000000 --- a/test/git_test.go +++ /dev/null @@ -1,120 +0,0 @@ -package container_test - -// git_test.go — e2e tests for git identity configuration inside the container. -// -// Verifies that GIT_AUTHOR_NAME / GIT_AUTHOR_EMAIL env vars produce correct -// commit metadata, and that ~/.config/git/config is read when env vars are absent. -// -// Run: -// go test -v -run TestGit -timeout 120s ./... - -import ( - "strings" - "testing" - - "github.com/testcontainers/testcontainers-go" -) - -// TestGitCommitUsesEnvIdentity starts a container with explicit git env vars, -// creates a commit, and verifies git log shows the configured identity. -func TestGitCommitUsesEnvIdentity(t *testing.T) { - if testing.Short() { - t.Skip("skipping in short mode") - } - - c := startContainer(t, map[string]string{ - "HOST_USER": hostUser, - "APP_NAME": "test", - "GIT_AUTHOR_NAME": "Test Author", - "GIT_AUTHOR_EMAIL": "test@devcell.io", - "GIT_COMMITTER_NAME": "Test Committer", - "GIT_COMMITTER_EMAIL": "committer@devcell.io", - }) - - // Init repo, make a commit - out, code := asUser(t, c, ` - cd /tmp && - mkdir gittest && cd gittest && - git init && - touch file.txt && - git add file.txt && - git commit -m "test commit" && - git log -1 --format='%an|%ae|%cn|%ce' - `) - if code != 0 { - t.Fatalf("FAIL: git commit failed (exit %d):\n%s", code, out) - } - - // Parse last line of output (git log format) - lines := strings.Split(strings.TrimSpace(out), "\n") - logLine := lines[len(lines)-1] - parts := strings.Split(logLine, "|") - if len(parts) != 4 { - t.Fatalf("FAIL: unexpected git log format: %q", logLine) - } - - if parts[0] != "Test Author" { - t.Errorf("author name: want %q, got %q", "Test Author", parts[0]) - } - if parts[1] != "test@devcell.io" { - t.Errorf("author email: want %q, got %q", "test@devcell.io", parts[1]) - } - if parts[2] != "Test Committer" { - t.Errorf("committer name: want %q, got %q", "Test Committer", parts[2]) - } - if parts[3] != "committer@devcell.io" { - t.Errorf("committer email: want %q, got %q", "committer@devcell.io", parts[3]) - } - t.Logf("PASS: git identity = %s", logLine) -} - -// TestGitCommitUsesGitConfig verifies that when no GIT_* env vars are set, -// git reads identity from ~/.config/git/config inside the container. -func TestGitCommitUsesGitConfig(t *testing.T) { - if testing.Short() { - t.Skip("skipping in short mode") - } - - c := startContainer(t, map[string]string{ - "HOST_USER": hostUser, - "APP_NAME": "test", - }) - - // Write a git config file, init repo, commit, check identity - out, code := asUser(t, c, ` - mkdir -p ~/.config/git && - cat > ~/.config/git/config <<'GITCFG' -[user] - name = Config User - email = config@devcell.io -GITCFG - cd /tmp && - mkdir gitcfgtest && cd gitcfgtest && - git init && - touch file.txt && - git add file.txt && - git commit -m "config commit" && - git log -1 --format='%an|%ae' - `) - if code != 0 { - t.Fatalf("FAIL: git commit with config failed (exit %d):\n%s", code, out) - } - - lines := strings.Split(strings.TrimSpace(out), "\n") - logLine := lines[len(lines)-1] - parts := strings.Split(logLine, "|") - if len(parts) != 2 { - t.Fatalf("FAIL: unexpected git log format: %q", logLine) - } - - if parts[0] != "Config User" { - t.Errorf("author name: want %q, got %q", "Config User", parts[0]) - } - if parts[1] != "config@devcell.io" { - t.Errorf("author email: want %q, got %q", "config@devcell.io", parts[1]) - } - t.Logf("PASS: git identity from config = %s", logLine) -} - -// suppress unused import warning -var _ testcontainers.Container diff --git a/test/gui_test.go b/test/gui_test.go new file mode 100644 index 00000000..854e0a92 --- /dev/null +++ b/test/gui_test.go @@ -0,0 +1,917 @@ +// gui_test.go — desktop theme, RDP server, VNC server tests (all require GUI support) + +package container_test + +import ( + "context" + "fmt" + goimage "image" + "image/png" + "log" + "os" + osexec "os/exec" + "path/filepath" + "strconv" + "strings" + "sync" + "testing" + "time" + + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/wait" +) + +// getDevcellHome returns the devcell nix environment directory. +// Uses DEVCELL_HOME env var if set, defaults to /opt/devcell. +func getDevcellHome() string { + if h := os.Getenv("DEVCELL_HOME"); h != "" { + return h + } + return "/opt/devcell" +} + +// isInDevcell returns true when running inside a devcell container. +func isInDevcell() bool { + _, err := os.Stat(getDevcellHome()) + return err == nil +} + +// skipIfNotInDevcell skips the test when not inside a devcell container. +func skipIfNotInDevcell(t *testing.T) { + t.Helper() + if !isInDevcell() { + t.Skipf("not in devcell container (DEVCELL_HOME=%s not found)", getDevcellHome()) + } +} + +var testLogEnabled = os.Getenv("DEVCELL_TEST_LOG") != "" + +// testLog writes to stderr when DEVCELL_TEST_LOG is set. +func testLog(format string, args ...any) { + if testLogEnabled { + log.Printf("[test] "+format, args...) + } +} + +var ( + guiOnce sync.Once + guiErr error +) + +// ensureGUI makes sure the GUI stack (Xvfb, fluxbox, x11vnc, xrdp) is running. +// Starts it via 50-gui.sh if not already running. Skips if not in devcell. +func ensureGUI(t *testing.T) { + t.Helper() + skipIfNotInDevcell(t) + guiOnce.Do(func() { + if osexec.Command("pgrep", "fluxbox").Run() != nil { + testLog("starting GUI stack...") + guiErr = startGUIStack() + } else { + testLog("GUI stack already running") + } + }) + if guiErr != nil { + t.Fatalf("ensure GUI: %v", guiErr) + } +} + +// assertContains checks that content contains the expected substring. +func assertContains(t *testing.T, label, content, expected string) { + t.Helper() + if !strings.Contains(content, expected) { + t.Errorf("%s: expected %q not found in:\n%s", label, expected, content) + } +} + +// ── Pixel assertion helpers ────────────────────────────────────────────────── + +// pixelHex returns the hex color "#rrggbb" at (x, y) in img. +func pixelHex(img goimage.Image, x, y int) string { + r, g, b, _ := img.At(x, y).RGBA() + return fmt.Sprintf("#%02x%02x%02x", r>>8, g>>8, b>>8) +} + +// assertPixel checks that the pixel at (x, y) matches the expected hex color. +func assertPixel(t *testing.T, img goimage.Image, x, y int, expected, label string) { + t.Helper() + got := pixelHex(img, x, y) + if got != expected { + t.Errorf("%s: pixel(%d,%d) = %s, want %s", label, x, y, got, expected) + } +} + +// assertPixelTolerance checks pixel with ±tolerance per channel (for rendering differences). +func assertPixelTolerance(t *testing.T, img goimage.Image, x, y int, expected string, tolerance uint8, label string) { + t.Helper() + r, g, b, _ := img.At(x, y).RGBA() + er, eg, eb := parseHex(expected) + if absDiff(uint8(r>>8), er) > tolerance || + absDiff(uint8(g>>8), eg) > tolerance || + absDiff(uint8(b>>8), eb) > tolerance { + t.Errorf("%s: pixel(%d,%d) = #%02x%02x%02x, want %s (±%d)", + label, x, y, r>>8, g>>8, b>>8, expected, tolerance) + } +} + +func parseHex(hex string) (uint8, uint8, uint8) { + var r, g, b uint8 + fmt.Sscanf(hex, "#%02x%02x%02x", &r, &g, &b) + return r, g, b +} + +func absDiff(a, b uint8) uint8 { + if a > b { + return a - b + } + return b - a +} + +// ── In-place desktop screenshot capture ───────────────────────────────────── + +var ( + desktopScreenshot goimage.Image + desktopScreenshotOnce sync.Once + desktopScreenshotErr error +) + +const testWallpaper = "testdata/wallpaper-4corners.png" +const screenshotPath = "/tmp/devcell-desktop-test.png" + +// setupDesktopScreenshot returns a screenshot of the desktop with an xterm +// window and fluxbox menu open. The capture runs once (via sync.Once) and is +// shared across all pixel-assertion tests. +func setupDesktopScreenshot(t *testing.T) goimage.Image { + t.Helper() + desktopScreenshotOnce.Do(func() { + desktopScreenshot, desktopScreenshotErr = captureDesktop() + }) + if desktopScreenshotErr != nil { + t.Fatalf("desktop screenshot: %v", desktopScreenshotErr) + } + return desktopScreenshot +} + +// captureDesktop sets up the GUI environment, opens windows, and takes a +// screenshot of the full desktop. Intended to run inside a devcell container +// with Xvfb on :99. +func captureDesktop() (goimage.Image, error) { + // Gate: must be running inside a devcell container. + if !isInDevcell() { + return nil, fmt.Errorf("not in devcell container (DEVCELL_HOME=%s not found)", getDevcellHome()) + } + + // Start GUI stack if fluxbox is not already running. + if err := osexec.Command("pgrep", "fluxbox").Run(); err != nil { + if err := startGUIStack(); err != nil { + return nil, fmt.Errorf("start GUI stack: %w", err) + } + } + + // Resolve absolute path to test wallpaper. + wpAbs, err := filepath.Abs(testWallpaper) + if err != nil { + return nil, fmt.Errorf("wallpaper abs path: %w", err) + } + + // Set test wallpaper. + cmd := osexec.Command("feh", "--bg-fill", wpAbs) + cmd.Env = append(os.Environ(), "DISPLAY=:99") + if out, err := cmd.CombinedOutput(); err != nil { + return nil, fmt.Errorf("feh --bg-fill: %w\n%s", err, out) + } + time.Sleep(500 * time.Millisecond) + + // Kill any stale xterm windows from previous runs. + osexec.Command("pkill", "-f", "xterm").Run() + time.Sleep(300 * time.Millisecond) + + // Open xterm window. + xtermCmd := osexec.Command("xterm", "-geometry", "80x24+100+100") + xtermCmd.Env = append(os.Environ(), "DISPLAY=:99") + if err := xtermCmd.Start(); err != nil { + return nil, fmt.Errorf("xterm start: %w", err) + } + time.Sleep(1 * time.Second) + + // Dismiss any stale menu/popup first. + dismissCmd := osexec.Command("xdotool", "key", "Escape") + dismissCmd.Env = append(os.Environ(), "DISPLAY=:99") + dismissCmd.CombinedOutput() + time.Sleep(300 * time.Millisecond) + + // Right-click on desktop to open fluxbox root menu. + // Click on empty area away from windows. + menuCmd := osexec.Command("xdotool", "mousemove", "960", "540", "click", "3") + menuCmd.Env = append(os.Environ(), "DISPLAY=:99") + if out, err := menuCmd.CombinedOutput(); err != nil { + return nil, fmt.Errorf("xdotool right-click: %w\n%s", err, out) + } + time.Sleep(2 * time.Second) + + // Take screenshot with ImageMagick import. + importCmd := osexec.Command("import", "-window", "root", screenshotPath) + importCmd.Env = append(os.Environ(), "DISPLAY=:99") + if out, err := importCmd.CombinedOutput(); err != nil { + return nil, fmt.Errorf("import screenshot: %w\n%s", err, out) + } + + // Decode PNG. + f, err := os.Open(screenshotPath) + if err != nil { + return nil, fmt.Errorf("open screenshot: %w", err) + } + defer f.Close() + + img, err := png.Decode(f) + if err != nil { + return nil, fmt.Errorf("decode screenshot: %w", err) + } + return img, nil +} + +// startGUIStack sources the 50-gui.sh entrypoint fragment to bring up +// Xvfb, fluxbox, x11vnc, and xrdp. +func startGUIStack() error { + home := getDevcellHome() + script := fmt.Sprintf(`#!/bin/bash +set -e +export DEVCELL_GUI_ENABLED=true +export HOST_USER=$(whoami) +export APP_NAME=test +export DEVCELL_HOME=%s +export USER=$(whoami) +log() { echo "[gui-setup] $*"; } +source /etc/devcell/entrypoint.d/50-gui.sh +`, home) + cmd := osexec.Command("bash", "-c", script) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("50-gui.sh: %w", err) + } + + // Wait up to 30s for xrdp to be listening on port 3389 (0x0D3D). + deadline := time.Now().Add(30 * time.Second) + for time.Now().Before(deadline) { + check := osexec.Command("bash", "-c", + "grep -qi 0D3D /proc/net/tcp* 2>/dev/null") + if check.Run() == nil { + return nil + } + time.Sleep(500 * time.Millisecond) + } + return fmt.Errorf("xrdp did not start within 30s") +} + +// saveScreenshotOnFailure copies the desktop screenshot to the test run +// results directory when a test fails, for post-mortem inspection. +func saveScreenshotOnFailure(t *testing.T) { + t.Helper() + t.Cleanup(func() { + if t.Failed() { + dst := filepath.Join(testRunDir(), "desktop-screenshot.png") + data, err := os.ReadFile(screenshotPath) + if err == nil { + os.WriteFile(dst, data, 0644) + t.Logf("Screenshot saved to %s", dst) + } + } + }) +} + +// skipIfNoGUI skips VNC tests when the image lacks GUI binaries (e.g. nix-only image). +func skipIfNoGUI(t *testing.T, c testcontainers.Container) { + t.Helper() + _, code := exec(t, c, []string{"sh", "-c", "command -v x11vnc"}) + if code != 0 { + t.Skip("skipping: image lacks x11vnc (nix-only image without GUI support)") + } +} + +// probeGUI starts a lightweight container to check for GUI support, then skips if absent. +func probeGUI(t *testing.T) { + t.Helper() + c := startEnvContainer(t) + skipIfNoGUI(t, c) +} + +func skipIfNoXrdp(t *testing.T, c testcontainers.Container) { + t.Helper() + _, code := exec(t, c, []string{"sh", "-c", "command -v xrdp"}) + if code != 0 { + t.Skip("skipping: image lacks xrdp") + } +} + +// skipIfNoXfreerdp skips the test if xfreerdp is not on PATH inside the container. +func skipIfNoXfreerdp(t *testing.T, c testcontainers.Container) { + t.Helper() + _, code := exec(t, c, []string{"sh", "-c", "command -v xfreerdp"}) + if code != 0 { + t.Skip("skipping: xfreerdp not on PATH") + } +} + +// startDesktopGUIContainer starts a container with DEVCELL_GUI_ENABLED=true +// and waits for the full GUI stack (Xvfb + fluxbox + x11vnc) to be running. +func startDesktopGUIContainer(t *testing.T) testcontainers.Container { + t.Helper() + ctx := context.Background() + req := testcontainers.ContainerRequest{ + Image: image(), + Env: map[string]string{ + "HOST_USER": hostUser, + "APP_NAME": "test", + "DEVCELL_GUI_ENABLED": "true", + }, + User: "0", + Cmd: []string{"tail", "-f", "/dev/null"}, + WaitingFor: wait.ForExec([]string{"sh", "-c", + "grep -qi 170C /proc/net/tcp6 /proc/net/tcp 2>/dev/null && grep -qi ' 0A ' /proc/net/tcp6 /proc/net/tcp 2>/dev/null"}). + WithStartupTimeout(60 * time.Second), + } + c, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: req, + Started: true, + }) + if err != nil { + t.Fatalf("start desktop GUI container: %v", err) + } + t.Cleanup(func() { _ = c.Terminate(ctx) }) + return c +} + +// startRdpContainer starts a container with GUI enabled and publishes port 3389. +// Waits until xrdp is listening on 3389 (0x0D3D in /proc/net/tcp). +func startRdpContainer(t *testing.T) testcontainers.Container { + t.Helper() + ctx := context.Background() + req := testcontainers.ContainerRequest{ + Image: image(), + ExposedPorts: []string{"3389/tcp", "5900/tcp"}, + Env: map[string]string{ + "HOST_USER": hostUser, + "APP_NAME": "test", + "DEVCELL_GUI_ENABLED": "true", + }, + User: "0", + Cmd: []string{"tail", "-f", "/dev/null"}, + // Port 3389 = 0x0D3D; LISTEN = 0A + WaitingFor: wait.ForExec([]string{"sh", "-c", + "grep -qi 0D3D /proc/net/tcp6 /proc/net/tcp 2>/dev/null && grep -qi ' 0A ' /proc/net/tcp6 /proc/net/tcp 2>/dev/null"}). + WithStartupTimeout(90 * time.Second), + } + c, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: req, + Started: true, + }) + if err != nil { + t.Fatalf("start RDP container: %v", err) + } + t.Cleanup(func() { _ = c.Terminate(ctx) }) + return c +} + +// startVncContainer starts a container with DEVCELL_GUI_ENABLED=true and +// publishes port 5900/tcp to a random host port. +func startVncContainer(t *testing.T) testcontainers.Container { + t.Helper() + ctx := context.Background() + req := testcontainers.ContainerRequest{ + Image: image(), + ExposedPorts: []string{"5900/tcp"}, + Env: map[string]string{ + "HOST_USER": hostUser, + "APP_NAME": "test", + "DEVCELL_GUI_ENABLED": "true", + }, + User: "0", + Cmd: []string{"tail", "-f", "/dev/null"}, + WaitingFor: wait.ForExec([]string{"sh", "-c", + "grep -qi 170C /proc/net/tcp6 /proc/net/tcp 2>/dev/null && grep -qi ' 0A ' /proc/net/tcp6 /proc/net/tcp 2>/dev/null"}). + WithStartupTimeout(60 * time.Second), + } + c, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: req, + Started: true, + }) + if err != nil { + t.Fatalf("start VNC container: %v", err) + } + t.Cleanup(func() { _ = c.Terminate(ctx) }) + return c +} + +// --- Desktop --- + +// TestDesktop_Wallpaper verifies the test wallpaper rendered at full resolution. +func TestDesktop_Wallpaper(t *testing.T) { + skipIfNotInDevcell(t) + img := setupDesktopScreenshot(t) + saveScreenshotOnFailure(t) + bounds := img.Bounds() + if bounds.Dx() != 1920 || bounds.Dy() != 1080 { + t.Fatalf("screenshot resolution: %dx%d, want 1920x1080", bounds.Dx(), bounds.Dy()) + } + // Green markers at corners (wallpaper-4corners.png has ~20x20 green squares) + assertPixelTolerance(t, img, 5, 5, "#00ff00", 10, "top-left corner") + assertPixelTolerance(t, img, 1914, 5, "#00ff00", 10, "top-right corner") + // Desktop body = black (sample above toolbar, away from window) + assertPixelTolerance(t, img, 1500, 400, "#000000", 10, "desktop body") +} + +// TestDesktop_Toolbar verifies toolbar colors: black bg, green workspace badge. +func TestDesktop_Toolbar(t *testing.T) { + skipIfNotInDevcell(t) + img := setupDesktopScreenshot(t) + saveScreenshotOnFailure(t) + // Toolbar is 35px at bottom. Center of toolbar = 1080 - 17 = 1063 + toolbarY := img.Bounds().Dy() - 17 + assertPixelTolerance(t, img, 960, toolbarY, "#000000", 10, "toolbar bg center") + // Workspace badge near left side (starts at ~x=40, sample at y=1070) + assertPixelTolerance(t, img, 50, img.Bounds().Dy()-10, "#b8e336", 15, "toolbar workspace badge") +} + +// TestDesktop_WindowChrome verifies xterm window has black title bar and green handle. +func TestDesktop_WindowChrome(t *testing.T) { + skipIfNotInDevcell(t) + img := setupDesktopScreenshot(t) + saveScreenshotOnFailure(t) + // xterm at +100+100. Title bar starts at y~85 (after 3px border), 30px high. + assertPixelTolerance(t, img, 300, 90, "#000000", 10, "window title bar bg") +} + +// TestDesktop_Menu verifies the right-click menu renders with theme colors. +func TestDesktop_Menu(t *testing.T) { + skipIfNotInDevcell(t) + img := setupDesktopScreenshot(t) + saveScreenshotOnFailure(t) + // Menu triggered at (960, 540). Title bar is green, body is dark surface. + // Title area at y=532 (above click point), body at y=576 (below border). + assertPixelTolerance(t, img, 960, 532, "#b8e336", 20, "menu title bg") + assertPixelTolerance(t, img, 960, 576, "#0a0a18", 15, "menu body bg") +} + +// TestDesktop_PatchrightMcpCellWrapper verifies the patchright-mcp-cell wrapper exists +// and references patchright-mcp (not playwright-mcp). +func TestDesktop_PatchrightMcpCellWrapper(t *testing.T) { + skipIfNotInDevcell(t) + + out, err := osexec.Command("sh", "-c", "command -v patchright-mcp-cell").Output() + if err != nil { + t.Fatalf("patchright-mcp-cell not on PATH: %v", err) + } + path := strings.TrimSpace(string(out)) + testLog("patchright-mcp-cell at %s", path) + + wrapper, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read wrapper %s: %v", path, err) + } + if !strings.Contains(string(wrapper), "mcp-server-patchright") { + t.Errorf("wrapper does not call mcp-server-patchright") + } + if strings.Contains(string(wrapper), "playwright-mcp ") { + t.Errorf("wrapper still references playwright-mcp") + } + t.Logf("PASS: patchright-mcp-cell wrapper found at %s", path) +} + +// TestDesktop_XresourcesLoaded verifies xrdb loaded the Xresources into the +// X server's resource database. +func TestDesktop_XresourcesLoaded(t *testing.T) { + ensureGUI(t) + + var out string + for i := 0; i < 10; i++ { + b, err := osexec.Command("sh", "-c", "DISPLAY=:99 xrdb -query").CombinedOutput() + if err == nil && len(b) > 0 { + out = string(b) + break + } + time.Sleep(500 * time.Millisecond) + } + if out == "" { + t.Fatal("X resource database is empty -- Xresources not loaded") + } + testLog("xrdb output:\n%s", out) + + assertContains(t, "XTerm*background", out, "XTerm*background:\t#0a0a18") + assertContains(t, "XTerm*cursorColor", out, "XTerm*cursorColor:\t#1abc9c") + assertContains(t, "XTerm*faceName", out, "XTerm*faceName:\tJetBrainsMono Nerd Font") + assertContains(t, "XTerm*faceSize", out, "XTerm*faceSize:\t11") + t.Logf("PASS: Xresources loaded (%d lines)", strings.Count(out, "\n")+1) +} + +// TestDesktop_FluxboxThemeActive verifies fluxbox is running with the +// devcell-ocean theme (not default grey). +func TestDesktop_FluxboxThemeActive(t *testing.T) { + ensureGUI(t) + + data, err := os.ReadFile("/tmp/fluxbox-init") + if err != nil { + t.Fatalf("read /tmp/fluxbox-init: %v", err) + } + out := string(data) + testLog("fluxbox init:\n%s", out) + + assertContains(t, "styleFile", out, + "session.styleFile:\t"+getDevcellHome()+"/.fluxbox/styles/devcell-ocean/theme.cfg") + if !strings.Contains(out, "session.screen0.workspaceNames:") { + t.Error("workspaceNames not set in fluxbox init") + } + t.Logf("PASS: fluxbox running with devcell-ocean theme") +} + +// TestDesktop_XftDpi96 verifies Xft.dpi is set to 96 in X resources. +func TestDesktop_XftDpi96(t *testing.T) { + ensureGUI(t) + + var out string + for i := 0; i < 10; i++ { + b, err := osexec.Command("sh", "-c", "DISPLAY=:99 xrdb -query 2>&1").CombinedOutput() + if err == nil && strings.Contains(string(b), "Xft.dpi") { + out = string(b) + break + } + time.Sleep(500 * time.Millisecond) + } + if !strings.Contains(out, "Xft.dpi") { + t.Fatalf("Xft.dpi not found in X resource database:\n%s", out) + } + assertContains(t, "Xft.dpi", out, "Xft.dpi:\t96") + t.Logf("PASS: Xft.dpi set to 96") +} + +// TestDesktop_XvfbDpi96 verifies Xvfb was started with -dpi 96. +func TestDesktop_XvfbDpi96(t *testing.T) { + ensureGUI(t) + + b, err := osexec.Command("sh", "-c", + "cat /proc/$(pgrep Xvfb)/cmdline 2>/dev/null | tr '\\0' ' '").CombinedOutput() + if err != nil { + t.Fatalf("could not read Xvfb cmdline: %v", err) + } + out := string(b) + testLog("Xvfb cmdline: %s", out) + if !strings.Contains(out, "-dpi 96") && !strings.Contains(out, "-dpi96") { + t.Errorf("Xvfb not started with -dpi 96:\n%s", out) + } else { + t.Logf("PASS: Xvfb started with -dpi 96") + } +} + +// --- RDP --- + +// TestRdp_ListensOn3389 -- with DEVCELL_GUI_ENABLED=true, xrdp must bind port 3389. +func TestRdp_ListensOn3389(t *testing.T) { + probeGUI(t) + c := startRdpContainer(t) + _, code := exec(t, c, []string{"pgrep", "xrdp"}) + if code != 0 { + t.Fatalf("FAIL: xrdp process not found (exit %d)", code) + } + out, code := exec(t, c, []string{"sh", "-c", + "grep -i 0D3D /proc/net/tcp6 /proc/net/tcp 2>/dev/null | grep ' 0A '"}) + if code != 0 || !strings.Contains(strings.ToUpper(out), "0D3D") { + t.Errorf("FAIL: port 3389 (0x0D3D) not in LISTEN state:\n%s", out) + } else { + t.Logf("PASS: xrdp listening on :3389\n%s", out) + } +} + +// TestRdp_PortPublishedToHost -- the published 3389/tcp must map to a non-privileged host port. +func TestRdp_PortPublishedToHost(t *testing.T) { + probeGUI(t) + c := startRdpContainer(t) + ctx := context.Background() + mapped, err := c.MappedPort(ctx, "3389/tcp") + if err != nil { + t.Fatalf("FAIL: no mapped port for 3389/tcp: %v", err) + } + port := mapped.Int() + if port < 1024 || port > 65535 { + t.Errorf("FAIL: mapped port %d outside unprivileged range", port) + } else { + t.Logf("PASS: 3389/tcp -> host port %d", port) + } +} + +// TestRdp_DockerPortByName -- `docker port 3389` must return the exact host port. +func TestRdp_DockerPortByName(t *testing.T) { + probeGUI(t) + c := startRdpContainer(t) + ctx := context.Background() + + name, err := c.Name(ctx) + if err != nil { + t.Fatalf("FAIL: could not get container name: %v", err) + } + name = strings.TrimPrefix(name, "/") + + out, err := osexec.Command("docker", "port", name, "3389").Output() + if err != nil { + t.Fatalf("FAIL: 'docker port %s 3389' failed: %v", name, err) + } + firstLine := strings.SplitN(strings.TrimSpace(string(out)), "\n", 2)[0] + lastColon := strings.LastIndex(firstLine, ":") + if lastColon < 0 { + t.Fatalf("FAIL: unexpected docker port output: %q", firstLine) + } + hostPort := firstLine[lastColon+1:] + + mapped, err := c.MappedPort(ctx, "3389/tcp") + if err != nil { + t.Fatalf("FAIL: could not get MappedPort: %v", err) + } + want := mapped.Port() + if hostPort != want { + t.Errorf("FAIL: docker port -> %q, want %q", hostPort, want) + } else { + t.Logf("PASS: docker port %s 3389 -> %s", name, hostPort) + } +} + +// TestRdp_ConnectWithCreds -- xfreerdp +auth-only with correct creds must succeed. +func TestRdp_ConnectWithCreds(t *testing.T) { + probeGUI(t) + c := startRdpContainer(t) + skipIfNoXfreerdp(t, c) + out, _ := exec(t, c, []string{"sh", "-c", + "DISPLAY=:99 xfreerdp +auth-only /v:127.0.0.1:3389 /u:" + hostUser + " /p:rdp /sec:rdp /cert:ignore 2>&1"}) + // xfreerdp logs "Authentication only, exit status N" where 0 = success. + if strings.Contains(out, "exit status 0") { + t.Logf("PASS: RDP auth-only connection succeeded") + } else if strings.Contains(out, "Authentication only") { + t.Errorf("FAIL: xfreerdp auth returned non-zero status:\n%s", out) + } else { + t.Errorf("FAIL: unexpected xfreerdp output (no auth status line):\n%s", out) + } +} + +// TestRdp_NoLoginPrompt -- xrdp auto-connects to VNC without showing a login screen. +func TestRdp_NoLoginPrompt(t *testing.T) { + probeGUI(t) + c := startRdpContainer(t) + skipIfNoXfreerdp(t, c) + // Clear xrdp log, connect, then check log for VNC connection. + exec(t, c, []string{"sh", "-c", "truncate -s0 /var/log/xrdp.log"}) + // Connect with correct creds -- triggers xrdp to proxy to VNC. + exec(t, c, []string{"sh", "-c", + "xfreerdp /v:127.0.0.1:3389 /u:" + hostUser + " /p:rdp /sec:rdp /cert:ignore /timeout:5000 2>&1 &" + + " sleep 3 && kill %1 2>/dev/null; true"}) + // Check xrdp log: should contain VNC connection, not "login_wnd" + out, _ := exec(t, c, []string{"sh", "-c", "cat /var/log/xrdp.log 2>/dev/null"}) + if strings.Contains(out, "login_wnd") { + t.Errorf("FAIL: xrdp showed login window (login_wnd found in log):\n%s", out) + } + if strings.Contains(out, "VNC started connecting") || + strings.Contains(out, "lib_mod_connect") || + strings.Contains(out, "libvnc") { + t.Logf("PASS: xrdp auto-connected to VNC (no login prompt)\n%s", out) + } else { + t.Logf("WARN: could not confirm VNC auto-connect from log (may need DEBUG level):\n%s", out) + } +} + +// TestRdp_KickExistingConnection -- x11vnc runs without -shared, so a new +// VNC connection (from a second RDP session) should disconnect the first. +func TestRdp_KickExistingConnection(t *testing.T) { + probeGUI(t) + c := startRdpContainer(t) + skipIfNoXfreerdp(t, c) + // Start first connection in background. + exec(t, c, []string{"sh", "-c", + "xfreerdp /v:127.0.0.1:3389 /u:" + hostUser + " /p:rdp /sec:rdp /cert:ignore 2>/dev/null &"}) + time.Sleep(3 * time.Second) + + // Start second connection -- should kick the first. + exec(t, c, []string{"sh", "-c", + "xfreerdp /v:127.0.0.1:3389 /u:" + hostUser + " /p:rdp /sec:rdp /cert:ignore 2>/dev/null &"}) + time.Sleep(3 * time.Second) + + // After second connect, only one ESTABLISHED VNC connection should remain. + out, _ := exec(t, c, []string{"sh", "-c", + "grep '170C' /proc/net/tcp6 /proc/net/tcp 2>/dev/null | grep -c ' 01 '"}) + count, _ := strconv.Atoi(strings.TrimSpace(out)) + if count <= 1 { + t.Logf("PASS: only %d ESTABLISHED VNC connection(s) -- old connection was kicked", count) + } else { + t.Errorf("FAIL: expected 1 ESTABLISHED VNC connection after kick, got %d", count) + } + + // Clean up background xfreerdp processes. + exec(t, c, []string{"sh", "-c", "pkill -f 'xfreerdp.*127.0.0.1:3389' 2>/dev/null; true"}) +} + +// TestRdp_LogsToFile -- xrdp logs must go to /var/log, not stdout. +func TestRdp_LogsToFile(t *testing.T) { + probeGUI(t) + c := startRdpContainer(t) + out, code := exec(t, c, []string{"sh", "-c", + "test -f /var/log/xrdp.log && echo OK"}) + if code != 0 || !strings.Contains(out, "OK") { + t.Fatalf("FAIL: xrdp log files not found in /var/log/") + } + // xrdp.ini must have LogFile pointing to /var/log and syslog disabled + out, _ = exec(t, c, []string{"sh", "-c", + "grep -E '^LogFile=|^EnableSyslog=' /tmp/xrdp/xrdp.ini 2>/dev/null"}) + if !strings.Contains(out, "LogFile=/var/log/xrdp.log") { + t.Errorf("FAIL: xrdp.ini LogFile not set to /var/log/xrdp.log:\n%s", out) + } + if !strings.Contains(out, "EnableSyslog=false") { + t.Errorf("FAIL: xrdp.ini EnableSyslog should be false:\n%s", out) + } else { + t.Logf("PASS: xrdp logs to /var/log/, syslog disabled\n%s", out) + } +} + +// TestRdp_NoXorgSection -- [Xorg] section must be removed from xrdp.ini. +func TestRdp_NoXorgSection(t *testing.T) { + probeGUI(t) + c := startRdpContainer(t) + out, _ := exec(t, c, []string{"sh", "-c", + "grep -c '\\[Xorg\\]' /tmp/xrdp/xrdp.ini 2>/dev/null || echo 0"}) + count, _ := strconv.Atoi(strings.TrimSpace(out)) + if count > 0 { + t.Errorf("FAIL: [Xorg] section should be removed from xrdp.ini (found %d)", count) + } else { + t.Logf("PASS: no [Xorg] section in xrdp.ini") + } +} + +// TestRdp_UserExists -- session user must exist for xrdp VNC proxy. +func TestRdp_UserExists(t *testing.T) { + probeGUI(t) + c := startRdpContainer(t) + out, code := exec(t, c, []string{"sh", "-c", + "id " + hostUser + " && getent passwd " + hostUser + " | cut -d: -f7"}) + if code != 0 { + t.Fatalf("FAIL: user %s does not exist (exit %d)", hostUser, code) + } + if strings.Contains(out, "/bin/zsh") || strings.Contains(out, "/bin/bash") { + t.Logf("PASS: user %s exists with valid shell:\n%s", hostUser, out) + } else { + t.Errorf("FAIL: user %s has unexpected shell:\n%s", hostUser, out) + } +} + +// TestRdp_ClipboardSync -- clipboard text set on the server X display (:99) +// must propagate to the xfreerdp client display (:98) via the RDP cliprdr channel. +func TestRdp_ClipboardSync(t *testing.T) { + probeGUI(t) + c := startRdpContainer(t) + skipIfNoXfreerdp(t, c) + + // Verify xclip is available. + _, code := exec(t, c, []string{"sh", "-c", "command -v xclip"}) + if code != 0 { + t.Skip("skipping: xclip not on PATH") + } + + testText := "devcell-clip-" + time.Now().Format("150405") + + // Start a second Xvfb (:98) for the xfreerdp client side. + exec(t, c, []string{"sh", "-c", "Xvfb :98 -screen 0 1024x768x24 &"}) + time.Sleep(1 * time.Second) + t.Cleanup(func() { + exec(t, c, []string{"sh", "-c", "pkill -f 'Xvfb :98' 2>/dev/null; true"}) + }) + + // Connect xfreerdp with clipboard enabled (client on :98, server on :99 via RDP). + exec(t, c, []string{"sh", "-c", + "DISPLAY=:98 xfreerdp /v:127.0.0.1:3389 /u:" + hostUser + + " /p:rdp /sec:rdp /cert:ignore +clipboard 2>/dev/null &"}) + t.Cleanup(func() { + exec(t, c, []string{"sh", "-c", "pkill -f 'xfreerdp.*127.0.0.1:3389' 2>/dev/null; true"}) + }) + time.Sleep(3 * time.Second) + + // Set clipboard text on the server display (:99). + exec(t, c, []string{"sh", "-c", + "echo -n '" + testText + "' | DISPLAY=:99 xclip -selection clipboard"}) + time.Sleep(2 * time.Second) + + // Read clipboard from the client display (:98). + out, code := exec(t, c, []string{"sh", "-c", + "DISPLAY=:98 xclip -selection clipboard -o 2>&1"}) + if code != 0 { + t.Fatalf("FAIL: xclip read on :98 failed (exit %d): %s", code, out) + } + if strings.Contains(strings.TrimSpace(out), testText) { + t.Logf("PASS: clipboard synced via RDP: server(:99) → client(:98) = %q", testText) + } else { + t.Errorf("FAIL: clipboard not synced; expected %q on client(:98), got %q", testText, out) + } +} + +// --- VNC --- + +// TestVnc_ListensOn5900 -- with DEVCELL_GUI_ENABLED=true, x11vnc must bind port 5900. +func TestVnc_ListensOn5900(t *testing.T) { + probeGUI(t) + c := startVncContainer(t) // already waited for port to be reachable + // Verify x11vnc process is alive + _, code := exec(t, c, []string{"pgrep", "x11vnc"}) + if code != 0 { + t.Fatalf("FAIL: x11vnc process not found (exit %d)", code) + } + // Verify the socket is in LISTEN state via /proc/net + out, code := exec(t, c, []string{"sh", "-c", + "grep -i 170C /proc/net/tcp6 /proc/net/tcp 2>/dev/null | grep ' 0A '"}) + if code != 0 || !strings.Contains(strings.ToUpper(out), "170C") { + t.Errorf("FAIL: port 5900 (0x170C) not in LISTEN state in /proc/net:\n%s", out) + } else { + t.Logf("PASS: x11vnc listening on :5900\n%s", out) + } +} + +// TestVnc_PortPublishedToHost -- the published 5900/tcp must map to a non-privileged host port. +func TestVnc_PortPublishedToHost(t *testing.T) { + probeGUI(t) + c := startVncContainer(t) + ctx := context.Background() + mapped, err := c.MappedPort(ctx, "5900/tcp") + if err != nil { + t.Fatalf("FAIL: no mapped port for 5900/tcp: %v", err) + } + port := mapped.Int() + if port < 1024 || port > 65535 { + t.Errorf("FAIL: mapped port %d is outside unprivileged range [1024,65535]", port) + } else { + t.Logf("PASS: 5900/tcp -> host port %d", port) + } +} + +// TestVnc_DynamicResolution -- xrandr resolution change must be picked up by x11vnc. +func TestVnc_DynamicResolution(t *testing.T) { + probeGUI(t) + c := startVncContainer(t) + + // Default resolution should be 1920x1080 + out, code := exec(t, c, []string{"sh", "-c", "DISPLAY=:99 xrandr 2>&1"}) + if code != 0 { + t.Fatalf("xrandr failed (exit %d): %s", code, out) + } + if !strings.Contains(out, "1920x1080") { + t.Fatalf("expected default 1920x1080 in xrandr output: %s", out) + } + t.Logf("PASS: default resolution is 1920x1080") + + // Add a new mode and switch to it + _, code = exec(t, c, []string{"sh", "-c", + "DISPLAY=:99 xrandr --newmode 2560x1440 0 2560 2560 2560 2560 1440 1440 1440 1440 2>/dev/null; " + + "DISPLAY=:99 xrandr --addmode screen 2560x1440 2>/dev/null; " + + "DISPLAY=:99 xrandr -s 2560x1440 2>/dev/null"}) + if code != 0 { + t.Skipf("xrandr mode change not supported (Xvfb RANDR is limited to initial resolution; would need Xvnc for dynamic resize) (exit %d)", code) + } + + // Verify new resolution + out, code = exec(t, c, []string{"sh", "-c", "DISPLAY=:99 xrandr 2>&1"}) + if code != 0 { + t.Fatalf("xrandr check failed (exit %d): %s", code, out) + } + if !strings.Contains(out, "2560x1440") || !strings.Contains(out, "*") { + t.Errorf("expected 2560x1440 to be active resolution: %s", out) + } else { + t.Logf("PASS: resolution changed to 2560x1440") + } +} + +// TestVnc_DockerPortByName -- `docker port 5900` must return the exact host port. +func TestVnc_DockerPortByName(t *testing.T) { + probeGUI(t) + c := startVncContainer(t) + ctx := context.Background() + + name, err := c.Name(ctx) + if err != nil { + t.Fatalf("FAIL: could not get container name: %v", err) + } + name = strings.TrimPrefix(name, "/") + + out, err := osexec.Command("docker", "port", name, "5900").Output() + if err != nil { + t.Fatalf("FAIL: 'docker port %s 5900' failed: %v\nEnsure the Docker socket is accessible from the test runner.", name, err) + } + // Output format per line: "0.0.0.0:" or "[::]:" -- port is after last colon. + firstLine := strings.SplitN(strings.TrimSpace(string(out)), "\n", 2)[0] + lastColon := strings.LastIndex(firstLine, ":") + if lastColon < 0 { + t.Fatalf("FAIL: unexpected 'docker port' output: %q", firstLine) + } + hostPort := firstLine[lastColon+1:] + + mapped, err := c.MappedPort(ctx, "5900/tcp") + if err != nil { + t.Fatalf("FAIL: could not get testcontainers MappedPort: %v", err) + } + want := mapped.Port() + if hostPort != want { + t.Errorf("FAIL: 'docker port %s 5900' -> %q, want %q", name, hostPort, want) + } else { + t.Logf("PASS: 'docker port %s 5900' -> %s (matches MappedPort)", name, hostPort) + } +} diff --git a/test/helpers_test.go b/test/helpers_test.go index 52ae3ffa..3843b260 100644 --- a/test/helpers_test.go +++ b/test/helpers_test.go @@ -33,6 +33,14 @@ var ( electronicsOnce sync.Once electronicsTag string electronicsErr error + + testdataOnce sync.Once + testdataTag string + testdataErr error + + // runDir is the per-run results directory: test/results/-/ + runDir string + runDirOnce sync.Once ) // TestMain cleans up locally-built test images after all tests complete. @@ -47,14 +55,20 @@ func TestMain(m *testing.M) { if electronicsTag != "" { osexec.Command("docker", "rmi", electronicsTag).Run() } + if testdataTag != "" { + osexec.Command("docker", "rmi", testdataTag).Run() + } os.Exit(code) } // shortSHA returns the abbreviated commit hash of HEAD. +// Falls back to a timestamp if git is unavailable (e.g. broken system gitconfig). func shortSHA() string { - out, err := osexec.Command("git", "rev-parse", "--short", "HEAD").Output() + cmd := osexec.Command("git", "rev-parse", "--short", "HEAD") + cmd.Env = append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1") + out, err := cmd.Output() if err != nil { - panic(fmt.Sprintf("git rev-parse: %v", err)) + return fmt.Sprintf("dev%s", time.Now().Format("150405")) } return strings.TrimSpace(string(out)) } @@ -77,12 +91,24 @@ func buildLocalImage(target, tagPrefix string) (string, error) { return tag, nil } -// image returns the ultimate image tag for tests. -// Uses DEVCELL_TEST_IMAGE if set (CI); otherwise builds local-ultimate once with a unique tag. +// image returns the test image tag. +// Priority: +// 1. DEVCELL_TEST_IMAGE env var (CI or explicit override) +// 2. Build from testdata Dockerfile (home-manager switch on top of ultimate-local, +// uses current nixhome/ so local changes are tested) +// 3. Fallback: build local-ultimate from scratch (slow, no nixhome iteration) func image() string { if img := os.Getenv("DEVCELL_TEST_IMAGE"); img != "" { return img } + // Build from testdata Dockerfile if ultimate-local base exists. + // This re-applies home-manager switch with current nixhome/ (~53s), + // so config changes are tested on every run. + const ultimateLocal = "ghcr.io/dimmkirr/devcell:ultimate-local" + if imageExists(ultimateLocal) { + return testdataImage() + } + // Fallback: build from scratch (slow) ultimateOnce.Do(func() { ultimateTag, ultimateErr = buildLocalImage("local-ultimate", "devcell-test") }) @@ -92,6 +118,11 @@ func image() string { return ultimateTag } +// imageExists checks if a Docker image exists locally. +func imageExists(tag string) bool { + return osexec.Command("docker", "image", "inspect", tag).Run() == nil +} + // baseImage returns the base image tag for entrypoint tests. // Uses DEVCELL_TEST_BASE_IMAGE if set (CI); otherwise builds local-base once with a unique tag. func baseImage() string { @@ -110,7 +141,7 @@ func baseImage() string { // ── Electronics image (base + home-manager switch devcell-electronics) ──────── // // Builds a user-level image following the scaffold Dockerfile pattern: -// 1. FROM base image (nix + home-manager, no profile) +// 1. FROM base image (nix + home-manager, no stack) // 2. Copy local nixhome/ flake // 3. home-manager switch --flake .#devcell-electronics (smallest profile with desktop module) // 4. npm install patchright-mcp (provides mcp-server-patchright binary) @@ -128,7 +159,7 @@ RUN ARCH=$(uname -m) && \ --flake "/opt/devcell/.config/devcell#devcell-electronics${ARCH_SUFFIX}" \ --impure && \ ln -sfT "$(readlink -f /opt/devcell/.nix-profile)" \ - /nix/var/nix/profiles/per-user/devcell/profile + /opt/devcell/.local/state/nix/profiles/profile COPY --chown=devcell:usergroup package.json /opt/npm-tools/ RUN cd /opt/npm-tools && npm install @@ -136,7 +167,7 @@ ENV PATH="/opt/npm-tools/node_modules/.bin:${PATH}" ` const elecFlakeNix = `{ - description = "DevCell electronics test profile"; + description = "DevCell electronics test stack"; inputs.devcell.url = "path:./nixhome"; outputs = { self, devcell, ... }: { homeConfigurations = devcell.homeConfigurations; @@ -220,7 +251,7 @@ func copyDirRecursive(src, dst string) error { // electronicsImage returns the electronics image tag. // Uses DEVCELL_TEST_ELECTRONICS_IMAGE if set (CI); otherwise builds once from -// base + local nixhome with devcell-electronics profile. +// base + local nixhome with devcell-electronics stack. func electronicsImage() string { if img := os.Getenv("DEVCELL_TEST_ELECTRONICS_IMAGE"); img != "" { return img @@ -266,6 +297,80 @@ func startElectronicsEnvContainer(t *testing.T) testcontainers.Container { }) } +// ── Testdata image (ultimate-local + home-manager switch with local nix cache) ─ +// +// Builds from the testdata Dockerfile which: +// 1. FROM ultimate-local (pre-built base with full nix store) +// 2. Copies testdata config + current nixhome/ into the image +// 3. home-manager switch using local /nix store as substituter (fast, no network) +// 4. Installs mise runtimes, npm tools, python tools +// +// This is the correct image for iterating on nixhome changes — it re-applies +// home-manager on top of the cached nix store, so config changes are tested. + +const testdataDir = "testdata/devcell-config-simple/devcell" + +// testRunDir returns the per-run results directory, creating it on first call. +// Layout: test/results/-/ +func testRunDir() string { + runDirOnce.Do(func() { + ts := time.Now().Format("20060102-150405") + runDir = filepath.Join("results", ts+"-"+shortSHA()) + if err := os.MkdirAll(runDir, 0755); err != nil { + panic(fmt.Sprintf("create run dir: %v", err)) + } + log.Printf("Test run dir: %s", runDir) + }) + return runDir +} + +// buildTestdataImage builds from the testdata Dockerfile with current nixhome. +// The build context is persisted in testRunDir()/build-context/ for inspection. +func buildTestdataImage() (string, error) { + dir := filepath.Join(testRunDir(), "build-context") + if err := os.MkdirAll(dir, 0755); err != nil { + return "", fmt.Errorf("mkdir build-context: %w", err) + } + + // Copy testdata build context. + if err := copyDirRecursive(testdataDir, dir); err != nil { + return "", fmt.Errorf("copy testdata: %w", err) + } + + // Replace testdata nixhome with current repo nixhome for iteration. + nixhomeDst := filepath.Join(dir, "nixhome") + os.RemoveAll(nixhomeDst) + if err := copyDirRecursive(filepath.Join("..", "nixhome"), nixhomeDst); err != nil { + return "", fmt.Errorf("copy nixhome: %w", err) + } + + tag := fmt.Sprintf("devcell-test-testdata:%s-%s", shortSHA(), time.Now().Format("20060102T150405")) + log.Printf("Building testdata image: %s", tag) + cmd := osexec.Command("docker", "build", "--progress=plain", "-t", tag, dir) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return "", fmt.Errorf("build testdata: %w", err) + } + return tag, nil +} + +// testdataImage returns the testdata image tag. +// Uses DEVCELL_TEST_TESTDATA_IMAGE if set; otherwise builds once from +// ultimate-local + current nixhome via home-manager switch. +func testdataImage() string { + if img := os.Getenv("DEVCELL_TEST_TESTDATA_IMAGE"); img != "" { + return img + } + testdataOnce.Do(func() { + testdataTag, testdataErr = buildTestdataImage() + }) + if testdataErr != nil { + panic(fmt.Sprintf("testdataImage: %v", testdataErr)) + } + return testdataTag +} + func startContainer(t *testing.T, env map[string]string) testcontainers.Container { t.Helper() ctx := context.Background() diff --git a/test/image_test.go b/test/image_test.go new file mode 100644 index 00000000..c85fbbdf --- /dev/null +++ b/test/image_test.go @@ -0,0 +1,589 @@ +// image_test.go — base image validation, entrypoint, dotenv parsing tests + +package container_test + +import ( + "bytes" + "context" + "fmt" + "os" + osexec "os/exec" + "path/filepath" + "regexp" + "strings" + "testing" + "time" + + "github.com/DimmKirr/devcell/internal/scaffold" + "github.com/creack/pty" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/wait" +) + +// --- Entrypoint --- + +// buildTestUserImage builds a user image from a scaffolded config directory. +// Returns the tag. Removes the image on cleanup. +func buildTestUserImage(t *testing.T, configDir string) string { + t.Helper() + tag := fmt.Sprintf("devcell-test-user:%s-%s", shortSHA(), time.Now().Format("20060102T150405")) + t.Logf("Building user image: %s (from %s)", tag, configDir) + + cmd := osexec.Command("docker", "build", "-t", tag, configDir) + cmd.Dir = filepath.Join("..") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + t.Fatalf("build user image: %v", err) + } + t.Cleanup(func() { osexec.Command("docker", "rmi", tag).Run() }) + return tag +} + +// TestEntrypoint_Fragments is an e2e test that verifies the full +// scaffold -> build -> run flow for nix-generated entrypoint fragments. +func TestEntrypoint_Fragments(t *testing.T) { + if testing.Short() { + t.Skip("skipping in short mode") + } + + // 1. Resolve base image. + baseImg := baseImage() + + // 2. Scaffold config dir with this base image. + configDir := t.TempDir() + t.Setenv("DEVCELL_BASE_IMAGE", baseImg) + if err := scaffold.Scaffold(configDir, "", "", false); err != nil { + t.Fatalf("scaffold: %v", err) + } + + // Verify Dockerfile FROM line. + dockerfile, err := os.ReadFile(filepath.Join(configDir, "Dockerfile")) + if err != nil { + t.Fatalf("read Dockerfile: %v", err) + } + if !strings.HasPrefix(string(dockerfile), "FROM "+baseImg) { + t.Fatalf("Dockerfile FROM doesn't match base image: got %.80s", string(dockerfile)) + } + t.Logf("Scaffold OK: Dockerfile FROM %s", baseImg) + + // 3. Build user image. + userImage := buildTestUserImage(t, configDir) + + // 4. Start container with GUI enabled, wait for xrdp to listen on 3389. + ctx := context.Background() + req := testcontainers.ContainerRequest{ + Image: userImage, + ExposedPorts: []string{"3389/tcp", "5900/tcp"}, + Env: map[string]string{ + "HOST_USER": hostUser, + "APP_NAME": "test", + "DEVCELL_GUI_ENABLED": "true", + }, + User: "0", + Cmd: []string{"tail", "-f", "/dev/null"}, + WaitingFor: wait.ForExec([]string{"sh", "-c", + "grep -qi 0D3D /proc/net/tcp6 /proc/net/tcp 2>/dev/null && grep -qi ' 0A ' /proc/net/tcp6 /proc/net/tcp 2>/dev/null"}). + WithStartupTimeout(120 * time.Second), + } + c, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: req, + Started: true, + }) + if err != nil { + t.Fatalf("start container: %v", err) + } + t.Cleanup(func() { _ = c.Terminate(ctx) }) + + // 5. Verify entrypoint fragments and GUI services. + t.Run("fragment_staged", func(t *testing.T) { + out, code := exec(t, c, []string{"ls", "-la", "/etc/devcell/entrypoint.d/50-gui.sh"}) + if code != 0 { + t.Fatalf("FAIL: 50-gui.sh not found in /etc/devcell/entrypoint.d/ (exit %d)", code) + } + if !strings.Contains(out, "x") { + t.Errorf("FAIL: 50-gui.sh should be executable: %s", out) + } + t.Logf("PASS: %s", out) + }) + + t.Run("xvfb_running", func(t *testing.T) { + _, code := exec(t, c, []string{"pgrep", "Xvfb"}) + if code != 0 { + t.Fatalf("FAIL: Xvfb process not found (exit %d)", code) + } + t.Logf("PASS: Xvfb is running") + }) + + t.Run("xrdp_running", func(t *testing.T) { + _, code := exec(t, c, []string{"pgrep", "xrdp"}) + if code != 0 { + t.Fatalf("FAIL: xrdp process not found (exit %d)", code) + } + t.Logf("PASS: xrdp is running") + }) + + t.Run("xrdp_listening", func(t *testing.T) { + out, code := exec(t, c, []string{"sh", "-c", + "grep -i 0D3D /proc/net/tcp6 /proc/net/tcp 2>/dev/null | grep ' 0A '"}) + if code != 0 || !strings.Contains(strings.ToUpper(out), "0D3D") { + t.Fatalf("FAIL: port 3389 (0x0D3D) not in LISTEN state:\n%s", out) + } + t.Logf("PASS: xrdp listening on :3389\n%s", out) + }) +} + +// TestEntrypoint_DebugTimestamps verifies that DEVCELL_DEBUG=true produces +// timestamped log lines in the format [X.XXXs]. +func TestEntrypoint_DebugTimestamps(t *testing.T) { + if testing.Short() { + t.Skip("skipping in short mode") + } + img := baseImage() + + out, err := osexec.Command("docker", "run", "--rm", + "--user", "0", + "-e", "HOST_USER=testuser", + "-e", "APP_NAME=tstest", + "-e", "DEVCELL_DEBUG=true", + img, + "echo", "ready", + ).CombinedOutput() + if err != nil { + t.Fatalf("docker run: %v\noutput: %s", err, out) + } + + output := string(out) + t.Logf("Debug output:\n%s", output) + + // Every log line should have a timestamp like [0.123s] or [1.456s] + tsPattern := regexp.MustCompile(`\[\d+\.\d{3}s\]`) + if !tsPattern.MatchString(output) { + t.Fatalf("FAIL: no timestamped log lines found (expected [X.XXXs] format)") + } + + // Verify multiple log lines have timestamps (not just one) + matches := tsPattern.FindAllString(output, -1) + t.Logf("PASS: found %d timestamped log lines", len(matches)) + if len(matches) < 2 { + t.Errorf("expected at least 2 timestamped lines, got %d", len(matches)) + } + + // Verify no log lines WITHOUT timestamps + for _, line := range strings.Split(output, "\n") { + line = strings.TrimSpace(line) + if line == "" || line == "ready" { + continue + } + if (strings.Contains(line, "\u2713") || strings.Contains(line, "Installing") || + strings.Contains(line, "Starting") || strings.Contains(line, "Merging")) && + !tsPattern.MatchString(line) { + t.Errorf("FAIL: log line missing timestamp: %s", line) + } + } +} + +// TestEntrypoint_SilentWithoutDebug verifies that without DEVCELL_DEBUG, the +// entrypoint produces no log output. +func TestEntrypoint_SilentWithoutDebug(t *testing.T) { + if testing.Short() { + t.Skip("skipping in short mode") + } + img := baseImage() + + out, err := osexec.Command("docker", "run", "--rm", + "--user", "0", + "-e", "HOST_USER=testuser", + "-e", "APP_NAME=myapp42", + img, + "echo", "ready", + ).CombinedOutput() + if err != nil { + t.Fatalf("docker run: %v\noutput: %s", err, out) + } + + output := string(out) + t.Logf("Non-debug output:\n%s", output) + + // No debug timestamps should appear + tsPattern := regexp.MustCompile(`\[\d+\.\d{3}s\]`) + if tsPattern.MatchString(output) { + t.Errorf("FAIL: debug timestamps found in non-debug mode") + } else { + t.Logf("PASS: no debug timestamps in non-debug mode") + } + + // No verbose log lines should appear + for _, marker := range []string{"Installing global tool", "Starting Xvfb", "Starting fluxbox", "Merging Claude"} { + if strings.Contains(output, marker) { + t.Errorf("FAIL: debug log line leaked in non-debug mode: %s", marker) + } + } + t.Logf("PASS: no debug log lines leaked") +} + +// --- Base Image --- + +// TestBaseImage_Scaffold validates base image capabilities via direct docker run. +func TestBaseImage_Scaffold(t *testing.T) { + if testing.Short() { + t.Skip("skipping in short mode") + } + img := image() + + t.Run("bash_echo", func(t *testing.T) { + out, err := osexec.Command("docker", "run", "--rm", + "--entrypoint", "bash", + img, + "-c", "echo 123", + ).CombinedOutput() + if err != nil { + t.Fatalf("docker run bash echo: %v\noutput: %s", err, out) + } + if !strings.Contains(string(out), "123") { + t.Errorf("expected output to contain '123', got: %s", out) + } + t.Logf("PASS: %s", strings.TrimSpace(string(out))) + }) + + t.Run("nix_version", func(t *testing.T) { + out, err := osexec.Command("docker", "run", "--rm", + "--entrypoint", "bash", + img, + "-lc", "nix --version", + ).CombinedOutput() + if err != nil { + t.Fatalf("docker run nix --version: %v\noutput: %s", err, out) + } + if !strings.Contains(strings.ToLower(string(out)), "nix") { + t.Errorf("expected output to contain 'nix', got: %s", out) + } + t.Logf("PASS: %s", strings.TrimSpace(string(out))) + }) + + t.Run("home_manager", func(t *testing.T) { + out, err := osexec.Command("docker", "run", "--rm", + "--entrypoint", "bash", + img, + "-lc", "home-manager --version", + ).CombinedOutput() + if err != nil { + t.Fatalf("docker run home-manager --version: %v\noutput: %s", err, out) + } + if !strings.Contains(string(out), ".") { + t.Errorf("expected home-manager version with '.', got: %s", out) + } + t.Logf("PASS: home-manager %s", strings.TrimSpace(string(out))) + }) + + t.Run("nix_profile_activated", func(t *testing.T) { + out, err := osexec.Command("docker", "run", "--rm", + "--entrypoint", "bash", + img, + "-lc", "readlink -f /opt/devcell/.nix-profile", + ).CombinedOutput() + if err != nil { + t.Fatalf("readlink nix-profile: %v\noutput: %s", err, out) + } + if !strings.Contains(string(out), "/nix/store/") { + t.Errorf("expected nix-profile to point into /nix/store/, got: %s", out) + } + t.Logf("PASS: nix-profile -> %s", strings.TrimSpace(string(out))) + }) +} + +// TestCell_Shell validates the cell shell command end-to-end via PTY. +func TestCell_Shell(t *testing.T) { + if testing.Short() { + t.Skip("skipping in short mode") + } + + // Build cell binary. + cellBin := filepath.Join(t.TempDir(), "cell") + build := osexec.Command("go", "build", "-o", cellBin, "./cmd") + build.Dir = filepath.Join("..") + build.Stdout = os.Stdout + build.Stderr = os.Stderr + if err := build.Run(); err != nil { + t.Fatalf("go build cell: %v", err) + } + + // Scaffold config directory (cell shell needs devcell.toml). + configDir, err := os.MkdirTemp("", "celltest-config-*") + if err != nil { + t.Fatalf("mkdtemp config: %v", err) + } + t.Cleanup(func() { + osexec.Command("docker", "run", "--rm", + "-v", configDir+":"+configDir, + "alpine", "rm", "-rf", configDir, + ).Run() + os.RemoveAll(configDir) + }) + devcellConfigDir := filepath.Join(configDir, "devcell") + if err := scaffold.Scaffold(devcellConfigDir, "", "", false); err != nil { + t.Fatalf("scaffold: %v", err) + } + + projectDir := t.TempDir() + userImage := image() // pre-built image from DEVCELL_TEST_IMAGE + + // cellShellHome creates a manually-managed HOME directory with the + // subdirectories that BuildArgv bind-mounts into the container. + cellShellHome := func(t *testing.T) string { + t.Helper() + home, err := os.MkdirTemp("", "celltest-home-*") + if err != nil { + t.Fatalf("mkdtemp: %v", err) + } + // Create directories that BuildArgv mounts from $HOME. + for _, sub := range []string{".claude/commands", ".claude/agents", ".claude/skills"} { + if err := os.MkdirAll(filepath.Join(home, sub), 0o755); err != nil { + t.Fatalf("mkdir %s: %v", sub, err) + } + } + t.Cleanup(func() { + osexec.Command("docker", "run", "--rm", + "-v", home+":"+home, + "alpine", "rm", "-rf", home, + ).Run() + os.RemoveAll(home) + }) + return home + } + + t.Run("bash_echo", func(t *testing.T) { + home := cellShellHome(t) + cmd := osexec.Command(cellBin, "shell", "--", "bash", "-c", "echo 123") + cmd.Dir = projectDir + cmd.Env = append(os.Environ(), + "XDG_CONFIG_HOME="+configDir, + "HOME="+home, + "DEVCELL_USER_IMAGE="+userImage, + ) + + out := runPTY(t, cmd) + if !strings.Contains(out, "123") { + t.Errorf("expected cell shell output to contain '123', got: %s", out) + } + }) + + t.Run("nix_version", func(t *testing.T) { + home := cellShellHome(t) + cmd := osexec.Command(cellBin, "shell", "--", "bash", "-lc", "nix --version") + cmd.Dir = projectDir + cmd.Env = append(os.Environ(), + "XDG_CONFIG_HOME="+configDir, + "HOME="+home, + "DEVCELL_USER_IMAGE="+userImage, + ) + + out := strings.ToLower(runPTY(t, cmd)) + if !strings.Contains(out, "nix") { + t.Errorf("expected cell shell output to contain 'nix', got: %s", out) + } + }) + + t.Run("spinner_visible", func(t *testing.T) { + home := cellShellHome(t) + cmd := osexec.Command(cellBin, "shell", "--", "echo", "done") + cmd.Dir = projectDir + cmd.Env = append(os.Environ(), + "XDG_CONFIG_HOME="+configDir, + "HOME="+home, + "DEVCELL_USER_IMAGE="+userImage, + ) + + out := runPTY(t, cmd) + t.Logf("PTY output (raw): %q", out) + + // Check for the "Opening Cell" status line. + if !strings.Contains(out, "Opening Cell") { + t.Fatalf("'Opening Cell' text not found in PTY output") + } + t.Logf("PASS: 'Opening Cell' rendered in PTY output") + + if strings.Contains(out, "mounts denied") { + t.Logf("SKIP: Docker mount denied (TMPDIR not in Docker shared paths) -- spinner verified") + } else if !strings.Contains(out, "done") { + t.Errorf("expected command output 'done' in PTY output") + } + }) +} + +// runPTY starts cmd in a PTY, collects output, and returns it. +func runPTY(t *testing.T, cmd *osexec.Cmd) string { + t.Helper() + ptmx, err := pty.Start(cmd) + if err != nil { + t.Fatalf("pty.Start: %v", err) + } + + var buf bytes.Buffer + done := make(chan struct{}) + go func() { + buf.ReadFrom(ptmx) + close(done) + }() + + if err := cmd.Wait(); err != nil { + t.Logf("cmd.Wait: %v (output so far: %s)", err, buf.String()) + } + ptmx.Close() + + select { + case <-done: + case <-time.After(5 * time.Second): + t.Log("warning: PTY read didn't finish within 5s after process exit") + } + return buf.String() +} + +// --- Dotenv --- + +// extractDotEnvKeys replicates the shell's key-extraction logic from the wrapper. +func extractDotEnvKeys(content string) []string { + var keys []string + for _, line := range strings.Split(content, "\n") { + // skip comments and blank lines + if line == "" || strings.HasPrefix(line, "#") { + continue + } + // _key="${_line%%=*}" -- take everything before the first '=' + key := line + if i := strings.IndexByte(line, '='); i >= 0 { + key = line[:i] + } + // _key="${_key#export }" + key = strings.TrimPrefix(key, "export ") + if key == "" { + continue + } + keys = append(keys, key) + } + return keys +} + +func TestDotEnv_KeyExtraction(t *testing.T) { + tests := []struct { + name string + content string + wantKeys []string + }{ + { + name: "simple KEY=VALUE pairs", + content: "TEST_PASSWORD=hello123\nGITHUB_TOKEN=ghtoken456\n", + wantKeys: []string{"TEST_PASSWORD", "GITHUB_TOKEN"}, + }, + { + name: "export prefix stripped", + content: "export SECRET_KEY=value\nexport OTHER=x\n", + wantKeys: []string{"SECRET_KEY", "OTHER"}, + }, + { + name: "comments and blank lines skipped", + content: "# this is a comment\n\nMY_KEY=value\n\n# another comment\nSECOND=val\n", + wantKeys: []string{"MY_KEY", "SECOND"}, + }, + { + name: "empty value (KEY=) still yields key", + content: "TEST_USERNAME=\nTEST_PASSWORD=\n", + wantKeys: []string{"TEST_USERNAME", "TEST_PASSWORD"}, + }, + { + name: "key with no equals sign", + content: "BARE_KEY\n", + wantKeys: []string{"BARE_KEY"}, + }, + { + name: "value contains equals sign", + content: "DB_URL=postgres://host:5432/db?sslmode=disable\n", + wantKeys: []string{"DB_URL"}, + }, + { + name: "empty file", + content: "", + wantKeys: nil, + }, + { + name: "only comments and blanks", + content: "# comment\n\n# another\n", + wantKeys: nil, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := extractDotEnvKeys(tc.content) + if len(got) != len(tc.wantKeys) { + t.Errorf("got keys %v, want %v", got, tc.wantKeys) + return + } + for i, k := range tc.wantKeys { + if got[i] != k { + t.Errorf("key[%d]: got %q, want %q", i, got[i], k) + } + } + }) + } +} + +// TestDotEnv_OnlyDotEnvKeysForwarded verifies that only keys present in .env +// are forwarded. +func TestDotEnv_OnlyDotEnvKeysForwarded(t *testing.T) { + dotEnv := "TEST_PASSWORD=placeholder\nGITHUB_TOKEN=placeholder\n" + containerEnv := map[string]string{ + "TEST_PASSWORD": "hello123", + "GITHUB_TOKEN": "ghtoken456", + "APP_NAME": "test", // in container env but NOT in .env + "HOST_USER": "testuser", // in container env but NOT in .env + } + + keys := extractDotEnvKeys(dotEnv) + secrets := map[string]string{} + for _, k := range keys { + if v, ok := containerEnv[k]; ok { + secrets[k] = v + } + } + + if secrets["TEST_PASSWORD"] != "hello123" { + t.Errorf("TEST_PASSWORD: got %q, want hello123", secrets["TEST_PASSWORD"]) + } + if secrets["GITHUB_TOKEN"] != "ghtoken456" { + t.Errorf("GITHUB_TOKEN: got %q, want ghtoken456", secrets["GITHUB_TOKEN"]) + } + if _, ok := secrets["APP_NAME"]; ok { + t.Errorf("APP_NAME must not be forwarded (not in .env), got %q", secrets["APP_NAME"]) + } + if _, ok := secrets["HOST_USER"]; ok { + t.Errorf("HOST_USER must not be forwarded (not in .env), got %q", secrets["HOST_USER"]) + } + t.Logf("PASS: only .env keys forwarded: %v", secrets) +} + +// --- Cell CLI --- + +// TestCell_Binary — cell CLI binary must be bundled in the image at /opt/devcell/.local/bin/cell. +func TestCell_Binary(t *testing.T) { + c := startEnvContainer(t) + + // cell binary should be on PATH + out, code := asUser(t, c, "which cell") + if code != 0 { + t.Fatalf("cell not found on PATH: exit %d", code) + } + if !strings.Contains(out, "/cell") { + t.Errorf("unexpected which output: %s", out) + } + + // cell --version should print version string + out, code = asUser(t, c, "cell --version") + if code != 0 { + t.Fatalf("cell --version failed: exit %d, output: %s", code, out) + } + if !strings.Contains(out, "cell version") { + t.Errorf("unexpected version output: %s", out) + } + t.Logf("cell --version: %s", out) +} diff --git a/test/js/bot-detect-test.js b/test/js/bot-detect-test.js new file mode 100644 index 00000000..92799fd9 --- /dev/null +++ b/test/js/bot-detect-test.js @@ -0,0 +1,243 @@ +#!/usr/bin/env node +// bot-detect-test.js — standalone Patchright test runner for bot detection sites. +// Usage: node test/bot-detect-test.js [--init-script path] [--only creepjs|sannysoft|incolumitas] +// +// Uses the same Chrome flags and init-script as patchright-mcp-cell wrapper. +// Outputs structured results for fast iteration without MCP reconnection. + +const { chromium } = require('/opt/npm-tools/node_modules/patchright'); +const fs = require('fs'); +const path = require('path'); + +// Resolve init-script: CLI arg > bundled in nix profile > local dev copy +function findInitScript() { + const argIdx = process.argv.indexOf('--init-script'); + if (argIdx !== -1 && process.argv[argIdx + 1]) return process.argv[argIdx + 1]; + + // Try nix profile bundled + try { + const bin = fs.realpathSync('/opt/devcell/.local/state/nix/profiles/profile/bin/patchright-mcp-cell'); + const share = path.join(path.dirname(path.dirname(bin)), 'share', 'patchright'); + const f = path.join(share, 'stealth-init.js'); + if (fs.existsSync(f)) return f; + } catch {} + + // Try local dev copy + const local = path.join(__dirname, '..', 'stealth-init.dev.js'); + if (fs.existsSync(local)) return local; + + console.error('No init-script found. Pass --init-script '); + process.exit(1); +} + +// Chrome launch args — same as patchright-mcp-cell config +const CHROME_ARGS = [ + '--no-sandbox', + '--use-gl=angle', + '--use-angle=vulkan', + '--ignore-gpu-blocklist', + '--window-size=1920,1040', + '--force-device-scale-factor=1', + '--disable-features=AudioServiceSandbox', + '--autoplay-policy=no-user-gesture-required', + '--disable-blink-features=AutomationControlled', +]; +// Append extra chrome flags from CLI: --chrome-args="--flag1 --flag2" +const extraIdx = process.argv.indexOf('--chrome-args'); +if (extraIdx !== -1 && process.argv[extraIdx + 1]) { + CHROME_ARGS.push(...process.argv[extraIdx + 1].split(/\s+/).filter(Boolean)); +} + +const TESTS = { + creepjs: { + url: 'https://abrahamjuliot.github.io/creepjs/', + waitSec: 22, + extract: async (page) => { + const r = await page.evaluate(() => { + const r = {}; + const text = document.body.innerText; + + // Headless scores + const likeMatch = text.match(/(\d+)% like headless/); + const headlessMatch = text.match(/(\d+)% headless:/); + const stealthMatch = text.match(/(\d+)% stealth:/); + r.likeHeadless = likeMatch ? parseInt(likeMatch[1]) : null; + r.headless = headlessMatch ? parseInt(headlessMatch[1]) : null; + r.stealth = stealthMatch ? parseInt(stealthMatch[1]) : null; + r.undetection = r.likeHeadless != null ? (100 - r.likeHeadless) + '%' : 'unknown'; + + // Screen + const screenMatch = text.match(/screen query:\s*(\d+)\s*x\s*(\d+)/); + r.screenQuery = screenMatch ? `${screenMatch[1]}x${screenMatch[2]}` : 'unknown'; + + // Viewport line + const vpSection = document.querySelector('[class*="screen"]') || document.body; + const vpText = text.match(/viewport:[\s\S]*?(\d+).*?(\d+).*?(\d+).*?(\d+).*?(\d+).*?(\d+).*?(browser|mobile).*?(portrait|landscape)/); + + // @media vs matchMedia + const mediaMatch = text.match(/@media:\s*([a-f0-9]+)/); + const matchMediaMatch = text.match(/matchMedia:\s*([a-f0-9]+)/); + r.mediaHash = mediaMatch ? mediaMatch[1] : 'unknown'; + r.matchMediaHash = matchMediaMatch ? matchMediaMatch[1] : 'unknown'; + r.mediaConsistent = r.mediaHash === r.matchMediaHash; + + // WebGL GPU + const gpuLines = [...document.querySelectorAll('*')].filter(el => + el.textContent.includes('ANGLE') || el.textContent.includes('Intel') + ); + // Get the actual displayed GPU text from the WebGL section + const webglSection = text.match(/WebGL[\s\S]*?gpu:[\s\S]*?((?:Intel|Google|ANGLE)[\s\S]*?)(?=\n\s*\n|\nimages)/); + r.webglGpu = webglSection ? webglSection[1].trim().substring(0, 120) : 'unknown'; + + // Worker + const workerArch = text.match(/Linux\s+(arm_64|x86_64)\s*$/m); + r.workerArch = workerArch ? workerArch[1] : 'unknown'; + + // Worker GPU + const workerGpuMatch = text.match(/Worker[\s\S]*?gpu:[\s\S]*?((?:Intel|Google|ANGLE|unsupported)[\s\S]*?)(?=\n\s*userAgent)/); + r.workerGpu = workerGpuMatch ? workerGpuMatch[1].trim().substring(0, 120) : 'unknown'; + + // Capture the Headless section which contains individual signal details + const headlessIdx = text.indexOf('Headless'); + r.headlessSection = headlessIdx > -1 ? text.substring(headlessIdx, headlessIdx + 800).trim() : 'not found'; + + // Capture the WebGL section + const webglIdx = text.indexOf('WebGL'); + r.webglSection = webglIdx > -1 ? text.substring(webglIdx, webglIdx + 600).trim() : 'not found'; + + // Capture the Lies section (detects prototype tampering) + const liesIdx = text.indexOf('Lies'); + r.liesSection = liesIdx > -1 ? text.substring(liesIdx, liesIdx + 400).trim() : 'not found'; + + return r; + }); + + // Direct WebGL check — run in main world (isolatedContext: false) + // Patchright runs page.evaluate() in an isolated world by default, + // which bypasses init-script prototype patches. isolatedContext: false + // verifies that init-script patches work in the page's own world. + try { + const direct = await page.evaluate(() => { + const c = document.createElement('canvas'); + const gl = c.getContext('webgl'); + if (!gl) return { error: 'no webgl' }; + const ext = gl.getExtension('WEBGL_debug_renderer_info'); + return { + vendor: ext ? gl.getParameter(ext.UNMASKED_VENDOR_WEBGL) : 'no ext', + renderer: ext ? gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) : 'no ext', + maxTexSize: gl.getParameter(gl.MAX_TEXTURE_SIZE), + maxSamples: gl.getParameter(36183), + }; + }, undefined, false); + r.directGlVendor = direct.vendor; + r.directGlRenderer = direct.renderer; + r.directMaxTexSize = direct.maxTexSize; + r.directMaxSamples = direct.maxSamples; + } catch(e) { r.directGlError = e.message; } + + return r; + } + }, + sannysoft: { + url: 'https://bot.sannysoft.com/', + waitSec: 8, + extract: async (page) => { + return page.evaluate(() => { + const rows = [...document.querySelectorAll('table tr')]; + const results = {}; + for (const row of rows) { + const cells = row.querySelectorAll('td'); + if (cells.length >= 2) { + const key = cells[0].textContent.trim(); + const val = cells[cells.length - 1].textContent.trim(); + const cls = cells[cells.length - 1].className || ''; + results[key] = { value: val, pass: cls.includes('passed') || !cls.includes('failed') }; + } + } + // Count passes/fails + const entries = Object.values(results); + const passed = entries.filter(e => e.pass).length; + const failed = entries.filter(e => !e.pass).length; + return { passed, failed, total: entries.length, details: results }; + }); + } + }, + incolumitas: { + url: 'https://bot.incolumitas.com/', + waitSec: 15, + extract: async (page) => { + return page.evaluate(() => { + const text = document.body.innerText; + const botScore = text.match(/Bot Score[:\s]*([0-9.]+)/i); + const humanScore = text.match(/Human Score[:\s]*([0-9.]+)/i); + // Try to get the detection results table + const results = {}; + const items = document.querySelectorAll('.test-result, [class*="result"]'); + items.forEach(el => { + const name = el.querySelector('.test-name, .name')?.textContent?.trim(); + const val = el.querySelector('.test-value, .value')?.textContent?.trim(); + if (name) results[name] = val; + }); + return { + botScore: botScore ? parseFloat(botScore[1]) : null, + humanScore: humanScore ? parseFloat(humanScore[1]) : null, + text: text.substring(0, 2000), + details: results + }; + }); + } + } +}; + +async function main() { + const initScript = findInitScript(); + console.log(`Init-script: ${initScript}`); + + // Which tests to run + const onlyIdx = process.argv.indexOf('--only'); + const onlyTests = onlyIdx !== -1 ? process.argv[onlyIdx + 1].split(',') : Object.keys(TESTS); + + console.log(`Tests: ${onlyTests.join(', ')}`); + console.log(`Chrome args: ${CHROME_ARGS.length} flags`); + console.log('---'); + + const browser = await chromium.launch({ + headless: false, // Patchright stealth mode (not Chromium headless) + args: CHROME_ARGS, + }); + + const context = await browser.newContext({ + viewport: null, // Use window-size from args + }); + + // Inject init-script + const script = fs.readFileSync(initScript, 'utf8'); + await context.addInitScript(script); + + for (const testName of onlyTests) { + const test = TESTS[testName]; + if (!test) { console.log(`Unknown test: ${testName}`); continue; } + + console.log(`\n=== ${testName.toUpperCase()} ===`); + console.log(`URL: ${test.url}`); + + const page = await context.newPage(); + try { + await page.goto(test.url, { timeout: 30000, waitUntil: 'domcontentloaded' }); + console.log(`Waiting ${test.waitSec}s for results...`); + await page.waitForTimeout(test.waitSec * 1000); + + const results = await test.extract(page); + console.log(JSON.stringify(results, null, 2)); + } catch (err) { + console.log(`ERROR: ${err.message}`); + } finally { + await page.close(); + } + } + + await browser.close(); + console.log('\n--- done ---'); +} + +main().catch(err => { console.error(err); process.exit(1); }); diff --git a/test/js/headless-signals-diag.js b/test/js/headless-signals-diag.js new file mode 100644 index 00000000..bbc5315c --- /dev/null +++ b/test/js/headless-signals-diag.js @@ -0,0 +1,173 @@ +#!/usr/bin/env node +// headless-signals-diag.js — Checks each CreepJS "like headless" signal individually. +// Usage: node test/headless-signals-diag.js [--init-script path] + +const { chromium } = require('/opt/npm-tools/node_modules/patchright'); +const fs = require('fs'); +const path = require('path'); + +function findInitScript() { + const argIdx = process.argv.indexOf('--init-script'); + if (argIdx !== -1 && process.argv[argIdx + 1]) return process.argv[argIdx + 1]; + const local = path.join(__dirname, '..', 'stealth-init.dev.js'); + if (fs.existsSync(local)) return local; + console.error('No init-script found'); process.exit(1); +} + +const CHROME_ARGS = [ + '--no-sandbox', + '--use-gl=angle', + '--use-angle=swiftshader', + '--enable-unsafe-swiftshader', + '--ozone-platform=headless', + '--ozone-override-screen-size=1920,1080', + '--window-size=1920,1040', + '--force-device-scale-factor=1', + '--disable-features=AudioServiceSandbox', + '--autoplay-policy=no-user-gesture-required', + '--disable-blink-features=AutomationControlled', + '--force-dark-mode', + '--blink-settings=preferredColorScheme=1', +]; + +async function main() { + const initScript = findInitScript(); + console.log('Init-script:', initScript); + console.log('Chrome args:', CHROME_ARGS.length, 'flags'); + + const browser = await chromium.launch({ headless: false, args: CHROME_ARGS }); + const context = await browser.newContext({ viewport: null }); + const script = fs.readFileSync(initScript, 'utf8'); + await context.addInitScript(script); + + const page = await context.newPage(); + await page.goto('data:text/html,', { waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(500); + + // Use exposeFunction FIRST, before any addScriptTag + let diagResult = null; + await page.exposeFunction('__reportDiag', (data) => { + diagResult = JSON.parse(data); + }); + + // Single addScriptTag with complete diagnostic + await page.addScriptTag({ content: ` + (async () => { + const IS_BLINK = ('chrome' in window && 'CSS' in window && CSS.supports('accent-color: initial')); + const signals = {}; + + signals.noChrome = IS_BLINK && !('chrome' in window); + + try { + if (IS_BLINK && 'permissions' in navigator) { + const res = await navigator.permissions.query({ name: 'notifications' }); + signals.hasPermissionsBug = ( + res.state === 'prompt' && + 'Notification' in window && + Notification.permission === 'denied' + ); + signals._permState = res.state; + signals._notifPerm = 'Notification' in window ? Notification.permission : 'no Notification'; + } else { + signals.hasPermissionsBug = false; + } + } catch(e) { signals.hasPermissionsBug = 'error: ' + e.message; } + + signals.noPlugins = IS_BLINK && navigator.plugins.length === 0; + signals._pluginsLen = navigator.plugins.length; + signals.noMimeTypes = IS_BLINK && navigator.mimeTypes.length === 0; + signals._mimeTypesLen = navigator.mimeTypes.length; + + signals.notificationIsDenied = (IS_BLINK && 'Notification' in window && Notification.permission === 'denied'); + signals._notifPermission = typeof Notification !== 'undefined' ? Notification.permission : 'no Notification'; + + try { + const div = document.createElement('div'); + document.body.appendChild(div); + div.setAttribute('style', 'background-color: ActiveText'); + const cs = getComputedStyle(div); + const activeText = cs.backgroundColor; + document.body.removeChild(div); + signals.hasKnownBgColor = activeText === 'rgb(255, 0, 0)'; + signals._activeTextColor = activeText; + } catch(e) { signals.hasKnownBgColor = 'error: ' + e.message; } + + signals.prefersLightColor = matchMedia('(prefers-color-scheme: light)').matches; + signals._prefersDark = matchMedia('(prefers-color-scheme: dark)').matches; + + if ('userAgentData' in navigator) { + signals.uaDataIsBlank = navigator.userAgentData?.platform === ''; + signals._uaDataPlatform = navigator.userAgentData?.platform; + } else { + signals.uaDataIsBlank = false; + } + + signals.pdfIsDisabled = 'pdfViewerEnabled' in navigator && navigator.pdfViewerEnabled === false; + signals._pdfViewerEnabled = navigator.pdfViewerEnabled; + + signals.noTaskbar = screen.height === screen.availHeight && screen.width === screen.availWidth; + signals._screen = screen.width + 'x' + screen.height; + signals._avail = screen.availWidth + 'x' + screen.availHeight; + + signals.hasVvpScreenRes = ( + (innerWidth === screen.width && outerHeight === screen.height) || ( + 'visualViewport' in window && + (visualViewport.width === screen.width && visualViewport.height === screen.height) + ) + ); + signals._dims = { + innerW: innerWidth, outerH: outerHeight, + vvW: window.visualViewport?.width, vvH: window.visualViewport?.height, + scrW: screen.width, scrH: screen.height + }; + + signals.hasSwiftShader = 'WORKER_CHECK'; + signals.noWebShare = IS_BLINK && CSS.supports('accent-color: initial') && ( + !('share' in navigator) || !('canShare' in navigator) + ); + signals.noContentIndex = !('ContentIndex' in window); + signals.noContactsManager = !('ContactsManager' in window); + signals.noDownlinkMax = navigator.connection?.downlinkMax === undefined; + signals._downlinkMax = navigator.connection?.downlinkMax; + + const triggering = Object.entries(signals) + .filter(([k, v]) => !k.startsWith('_') && v === true) + .map(([k]) => k); + signals._summary = { total: 16, triggering: triggering.length, signals: triggering }; + + window.__reportDiag(JSON.stringify(signals)); + })(); + `}); + + await page.waitForTimeout(3000); + + if (!diagResult) { + console.error('Failed to get diagnostic results'); + await browser.close(); + process.exit(1); + } + + console.log('\\n=== HEADLESS SIGNAL DIAGNOSTIC ===\\n'); + const signalNames = [ + 'noChrome', 'hasPermissionsBug', 'noPlugins', 'noMimeTypes', + 'notificationIsDenied', 'hasKnownBgColor', 'prefersLightColor', + 'uaDataIsBlank', 'pdfIsDisabled', 'noTaskbar', 'hasVvpScreenRes', + 'hasSwiftShader', 'noWebShare', 'noContentIndex', 'noContactsManager', + 'noDownlinkMax' + ]; + for (const name of signalNames) { + const val = diagResult[name]; + const icon = val === true ? 'FAIL' : val === false ? ' ok ' : '????'; + console.log(`[${icon}] ${name}: ${JSON.stringify(val)}`); + } + console.log('\\n=== SUMMARY ==='); + console.log(JSON.stringify(diagResult._summary, null, 2)); + console.log('\\n=== DEBUG ==='); + for (const [k, v] of Object.entries(diagResult)) { + if (k.startsWith('_') && k !== '_summary') console.log(` ${k}: ${JSON.stringify(v)}`); + } + + await browser.close(); +} + +main().catch(err => { console.error(err); process.exit(1); }); diff --git a/test/kicad_mcp_test.go b/test/kicad_mcp_test.go index dca40665..b2165bf6 100644 --- a/test/kicad_mcp_test.go +++ b/test/kicad_mcp_test.go @@ -3,7 +3,7 @@ package container_test // kicad_mcp_test.go — tests for the kicad-mcp MCP server in the electronics profile. // Run against the electronics image: // -// DEVCELL_TEST_IMAGE=ghcr.io/dimmkirr/devcell:latest-electronics go test -v -run TestKicadMcp ./... +// DEVCELL_TEST_IMAGE=ghcr.io/dimmkirr/devcell:latest-electronics go test -v -run TestKicad_Mcp ./... import ( "encoding/json" @@ -11,7 +11,7 @@ import ( "testing" ) -func TestKicadMcp(t *testing.T) { +func TestKicad_Mcp(t *testing.T) { c := startContainer(t, map[string]string{"HOST_USER": hostUser}) // ── 1. Binary on PATH ───────────────────────────────────────────────────── diff --git a/test/mcp_test.go b/test/mcp_test.go index f790aa7f..6f0856b0 100644 --- a/test/mcp_test.go +++ b/test/mcp_test.go @@ -9,7 +9,7 @@ import ( "testing" ) -// e2eFormServer is a minimal Node.js HTTP server used by TestMcpPlaywrightE2EFormSecrets. +// e2eFormServer is a minimal Node.js HTTP server used by TestMcp_PlaywrightE2EFormSecrets. // It serves an HTML form at GET / and writes submitted values to /tmp/form-output.txt. // The listening port is written to /tmp/server-port.txt once the server is ready. const e2eFormServer = `const http = require('http'); @@ -54,7 +54,7 @@ server.listen(0, '127.0.0.1', () => { const e2eMcpClient = `#!/usr/bin/env python3 import subprocess, json, os, sys, re, time -CHROMIUM = '/nix/var/nix/profiles/per-user/devcell/profile/bin/chromium' +CHROMIUM = '/opt/devcell/.local/state/nix/profiles/profile/bin/chromium' USER_DATA = '/tmp/pw-e2e-test' with open('/tmp/server-port.txt') as f: @@ -147,10 +147,10 @@ finally: // ── Playwright MCP ──────────────────────────────────────────────────────────── -// TestMcpPlaywrightSecretsFromDotEnv verifies patchright-mcp-cell reads key names from +// TestMcp_PlaywrightSecretsFromDotEnv verifies patchright-mcp-cell reads key names from // $USER_WORKING_DIR/.env and resolves values from the container environment. // Only keys present in .env are forwarded — other container env vars are not exposed. -func TestMcpPlaywrightSecretsFromDotEnv(t *testing.T) { +func TestMcp_PlaywrightSecretsFromDotEnv(t *testing.T) { c := startContainer(t, map[string]string{ "APP_NAME": "test", "HOST_USER": hostUser, @@ -206,152 +206,14 @@ func TestMcpPlaywrightSecretsFromDotEnv(t *testing.T) { t.Logf("PASS: secrets file:\n%s", out) } -// TestMcpPlaywrightStagingFileHasEntry — nix-mcp-servers.json must contain a playwright entry -// with patchright-mcp-cell as the command. -func TestMcpPlaywrightStagingFileHasEntry(t *testing.T) { - c := startContainer(t, map[string]string{"HOST_USER": hostUser, "APP_NAME": "test"}) - - raw, code := exec(t, c, []string{"cat", "/etc/claude-code/nix-mcp-servers.json"}) - if code != 0 { - t.Fatalf("FAIL: could not read nix-mcp-servers.json (exit %d)", code) - } - - var cfg struct { - McpServers map[string]struct { - Command string `json:"command"` - } `json:"mcpServers"` - } - if err := json.Unmarshal([]byte(raw), &cfg); err != nil { - t.Fatalf("FAIL: invalid JSON: %v\n%s", err, raw) - } - - entry, ok := cfg.McpServers["playwright"] - if !ok { - keys := make([]string, 0, len(cfg.McpServers)) - for k := range cfg.McpServers { - keys = append(keys, k) - } - t.Errorf("FAIL: playwright missing from nix-mcp-servers.json; present keys: [%s]", - strings.Join(keys, ", ")) - return - } - if entry.Command != "patchright-mcp-cell" { - t.Errorf("FAIL: expected command %q, got %q", "patchright-mcp-cell", entry.Command) - } else { - t.Logf("PASS: playwright entry present, command=%s", entry.Command) - } -} - -// TestMcpPlaywrightTempFileCleanup — secrets temp file must be deleted after wrapper exits. -// Verifies the trap 'rm -f' EXIT in patchright-mcp-cell fires correctly so no temp file leaks. -func TestMcpPlaywrightTempFileCleanup(t *testing.T) { - c := startContainer(t, map[string]string{ - "HOST_USER": hostUser, - "APP_NAME": "test", - "TEST_PASSWORD": "hello123", - "USER_WORKING_DIR": "/tmp/cleanup-wd", - }) - - _, code := exec(t, c, []string{"sh", "-c", - "mkdir -p /tmp/cleanup-wd && printf 'TEST_PASSWORD=any\n' > /tmp/cleanup-wd/.env"}) - if code != 0 { - t.Fatal("FAIL: could not create test .env file") - } - - out, code := exec(t, c, []string{"sh", "-c", ` - SECRETS_FILE=$(mktemp /tmp/pw-secrets-XXXXXX.env) - trap 'rm -f "$SECRETS_FILE"; echo "cleaned:$SECRETS_FILE"' EXIT - _ENV_FILE="${USER_WORKING_DIR:-}/.env" - if [ -f "$_ENV_FILE" ]; then - while IFS= read -r _line || [ -n "$_line" ]; do - case "$_line" in '#'*|'') continue ;; esac - _key="${_line%%=*}" - _key="${_key#export }" - [ -z "$_key" ] && continue - if _val=$(printenv "$_key" 2>/dev/null); then - printf '%s=%s\n' "$_key" "$_val" - fi - done < "$_ENV_FILE" >> "$SECRETS_FILE" - fi - echo "created:$SECRETS_FILE" - test -f "$SECRETS_FILE" && echo "exists_during" || echo "missing_during" - true - `}) - if code != 0 { - t.Fatalf("FAIL: wrapper logic failed (exit %d): %s", code, out) - } - if !strings.Contains(out, "exists_during") { - t.Errorf("FAIL: secrets temp file was not created") - } - if !strings.Contains(out, "cleaned:") { - t.Errorf("FAIL: EXIT trap did not fire") - } - - var cleaned string - for _, line := range strings.Split(out, "\n") { - if strings.HasPrefix(line, "cleaned:") { - cleaned = strings.TrimPrefix(strings.TrimSpace(line), "cleaned:") - } - } - if cleaned == "" { - t.Fatal("FAIL: could not extract temp file path from output") - } - _, code = exec(t, c, []string{"test", "-f", cleaned}) - if code == 0 { - t.Errorf("FAIL: temp file %q still exists after EXIT", cleaned) - } else { - t.Logf("PASS: temp file %q cleaned up", cleaned) - } -} - -// TestMcpPlaywrightProtocol — patchright-mcp-cell must respond to MCP initialize + tools/list -// over stdio. The browser is lazily initialised so this works without a display server. -// -// @modelcontextprotocol/sdk v1.x uses newline-delimited JSON (one JSON object per line), -// NOT Content-Length/LSP framing. -func TestMcpPlaywrightProtocol(t *testing.T) { - c := startContainer(t, map[string]string{"HOST_USER": hostUser, "APP_NAME": "test"}) - - _, code := exec(t, c, []string{"sh", "-c", "command -v patchright-mcp-cell"}) - if code != 0 { - t.Fatal("FAIL: patchright-mcp-cell not on PATH — is this the ultimate image?") - } - - out, code := exec(t, c, []string{"bash", "-c", ` - INIT='{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"0"}}}' - LIST='{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' - CHROMIUM=/nix/var/nix/profiles/per-user/devcell/profile/bin/chromium - TMPDIR_PW=$(mktemp -d /tmp/pw-proto-XXXXXX) - trap 'rm -rf "$TMPDIR_PW"' EXIT - { printf "%s\n%s\n" "$INIT" "$LIST"; sleep 5; } \ - | PLAYWRIGHT_MCP_USER_DATA_DIR="$TMPDIR_PW" timeout 15 patchright-mcp-cell \ - --headless --browser chromium \ - --executable-path "$CHROMIUM" \ - 2>/dev/null - `}) - // exit 124 = timeout (server did not auto-exit on EOF) — responses already written to stdout - if code != 0 && code != 124 { - t.Fatalf("FAIL: patchright-mcp-cell exited %d:\n%s", code, out) - } - if !strings.Contains(out, `"result"`) { - t.Errorf("FAIL: no JSON-RPC result in output (exit %d):\n%s", code, out) - return - } - if !strings.Contains(out, "browser_navigate") { - t.Errorf("FAIL: tools/list response missing browser_navigate (exit %d):\n%s", code, out) - } else { - t.Logf("PASS: playwright-mcp responded with MCP protocol (exit %d)", code) - } -} - -// TestMcpPlaywrightE2EFormSecrets — full end-to-end secrets flow: +// TestMcp_PlaywrightE2EFormSecrets — full end-to-end secrets flow: // 1. Container starts with TEST_USERNAME and TEST_PASSWORD env vars; .env lists those keys. // 2. Assert playwright is registered in ~/.claude.json (merged by entrypoint). // 3. Start a local Node.js HTTP server that serves a login form. // 4. Drive patchright-mcp-cell via MCP stdio: // initialize → browser_navigate → browser_snapshot → browser_fill_form (secret names) → browser_click. // 5. Assert /tmp/form-output.txt contains the real secret values (not the names). -func TestMcpPlaywrightE2EFormSecrets(t *testing.T) { +func TestMcp_PlaywrightE2EFormSecrets(t *testing.T) { const ( testUsername = "alice" testPassword = "s3cr3t123" @@ -453,7 +315,7 @@ func TestMcpPlaywrightE2EFormSecrets(t *testing.T) { const e2eStealthDetector = `#!/usr/bin/env python3 import subprocess, json, os, sys -CHROMIUM = '/nix/var/nix/profiles/per-user/devcell/profile/bin/chromium' +CHROMIUM = '/opt/devcell/.local/state/nix/profiles/profile/bin/chromium' USER_DATA = '/tmp/pw-stealth-test' # Read init-script path from nix-mcp-servers.json (production args) @@ -535,7 +397,7 @@ finally: proc.wait() ` -// TestMcpPatchrightUndetected — verifies patchright + stealth init-script make the browser +// TestMcp_PatchrightUndetected — verifies patchright + stealth init-script make the browser // undetectable as automated. Launches patchright-mcp-cell with --init-script (same args // Claude Code uses in production via nix-mcp-servers.json), navigates to a page, and asserts: // - navigator.webdriver is undefined (not true) @@ -543,7 +405,7 @@ finally: // - navigator.plugins.length >= 3 (mock) // - navigator.languages includes "en-US" // - WebGL vendor = "Intel Inc." (if available) -func TestMcpPatchrightUndetected(t *testing.T) { +func TestMcp_PatchrightUndetected(t *testing.T) { c := startContainer(t, map[string]string{ "HOST_USER": hostUser, "APP_NAME": "test", @@ -596,14 +458,14 @@ func TestMcpPatchrightUndetected(t *testing.T) { } var result struct { - Webdriver interface{} `json:"webdriver"` - HasChrome bool `json:"hasChrome"` - HasChromeRT bool `json:"hasChromeRuntime"` - PluginsCount int `json:"pluginsCount"` - Languages string `json:"languages"` - WebGLVendor string `json:"webglVendor"` - WebGLRenderer string `json:"webglRenderer"` - WebGLError string `json:"webglError"` + Webdriver interface{} `json:"webdriver"` + HasChrome bool `json:"hasChrome"` + HasChromeRT bool `json:"hasChromeRuntime"` + PluginsCount int `json:"pluginsCount"` + Languages string `json:"languages"` + WebGLVendor string `json:"webglVendor"` + WebGLRenderer string `json:"webglRenderer"` + WebGLError string `json:"webglError"` } if err := json.Unmarshal([]byte(stealthJSON), &result); err != nil { t.Fatalf("FAIL: parse stealth JSON: %v\nraw: %s", err, stealthJSON) @@ -657,88 +519,626 @@ func TestMcpPatchrightUndetected(t *testing.T) { } } -// ── NixOS MCP ───────────────────────────────────────────────────────────────── +// ── Detection Suite ────────────────────────────────────────────────────────── +// +// Self-hosted bot detection suite derived from community tools: +// - BotD (github.com/fingerprintjs/BotD) — automation framework detection +// - fpscanner (github.com/antoinevastel/fpscanner) — headless/selenium artifact checks +// - CreepJS (github.com/AbrahamJuliot/creepjs) — fingerprint consistency checks +// - headless-detector (andriyshevchenko) — modern headless detection vectors +// +// Architecture: Go test → Python MCP client → patchright-mcp-cell → self-hosted HTML page. +// The HTML page runs all detection checks client-side, stores results in window.__DETECTION. +// The Python client reads the results via browser_evaluate and outputs DETECTION:{json}. +// Go subtests assert each detection category independently. + +// e2eDetectionPage is a self-hosted bot detection page. It runs all checks client-side +// and stores structured results in window.__DETECTION. Title changes to "DONE" when complete. +const e2eDetectionPage = ` +Detection Suite +
running...
+ +` + +// e2eDetectionClient drives patchright-mcp-cell via MCP stdio, navigates to the +// self-hosted detection page, waits for completion, and reads window.__DETECTION. +// Outputs "DETECTION:{json}" for the Go test to parse. +const e2eDetectionClient = `#!/usr/bin/env python3 +import subprocess, json, os, sys, threading, http.server, time, re + +# ── Serve detection page ────────────────────────────────────────────────────── +DETECTION_HTML = open('/tmp/detection-page.html').read() + +class Handler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + self.send_response(200) + self.send_header('Content-Type', 'text/html') + self.end_headers() + self.wfile.write(DETECTION_HTML.encode()) + def log_message(self, *a): pass + +srv = http.server.HTTPServer(('127.0.0.1', 0), Handler) +port = srv.server_address[1] +threading.Thread(target=srv.serve_forever, daemon=True).start() +print(f'detection-server: port={port}', flush=True) + +# ── Read init-script from nix-mcp-servers.json ──────────────────────────────── +with open('/etc/claude-code/nix-mcp-servers.json') as f: + cfg = json.load(f) +pw_args = cfg.get('mcpServers', {}).get('playwright', {}).get('args', []) +init_script = None +for i, a in enumerate(pw_args): + if a == '--init-script' and i + 1 < len(pw_args): + init_script = pw_args[i + 1] + break +if not init_script: + print('ERROR: --init-script not found in nix-mcp-servers.json', file=sys.stderr) + sys.exit(2) +print(f'init-script: {init_script}', flush=True) + +# ── Start patchright-mcp-cell via MCP stdio ─────────────────────────────────── +env = dict(os.environ) +env['PLAYWRIGHT_MCP_USER_DATA_DIR'] = '/tmp/pw-detect-suite' + +config_path = None +for i, a in enumerate(pw_args): + if a == '--config' and i + 1 < len(pw_args): + config_path = pw_args[i + 1] + break + +cmd = ['patchright-mcp-cell', '--browser', 'chromium', + '--init-script', init_script] +if config_path: + cmd.extend(['--config', config_path]) + print(f'config: {config_path}', flush=True) + +proc = subprocess.Popen( + cmd, + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, env=env) + +msg_id = 0 +def send(method, params=None): + global msg_id + msg_id += 1 + msg = {'jsonrpc':'2.0','id':msg_id,'method':method} + if params: msg['params'] = params + proc.stdin.write((json.dumps(msg) + '\n').encode()) + proc.stdin.flush() + return msg_id + +def recv(): + line = proc.stdout.readline() + if not line: raise RuntimeError('EOF from patchright-mcp stdout') + return json.loads(line) + +try: + # Initialize + send('initialize', { + 'protocolVersion':'2024-11-05','capabilities':{}, + 'clientInfo':{'name':'detection-suite','version':'0'}}) + r = recv() + print('mcp-init: ' + r.get('result',{}).get('serverInfo',{}).get('name','?'), flush=True) + + # Navigate to detection page + send('tools/call', {'name':'browser_navigate', + 'arguments':{'url': f'http://127.0.0.1:{port}'}}) + r = recv() + if 'error' in r: + print(f'ERROR: navigate failed: {r["error"]}', file=sys.stderr) + sys.exit(3) + print('navigate: ok', flush=True) + + # Wait for detection to complete (title changes to DONE) + for attempt in range(30): + time.sleep(0.5) + send('tools/call', {'name':'browser_evaluate', + 'arguments':{'function':'() => document.title'}}) + r = recv() + text = r.get('result',{}).get('content',[{}])[0].get('text','') + if 'DONE' in text: + print('detection: complete', flush=True) + break + else: + print('ERROR: detection page did not complete in 15s', file=sys.stderr) + sys.exit(4) + + # Read detection results + send('tools/call', {'name':'browser_evaluate', + 'arguments':{'function':'() => JSON.stringify(window.__DETECTION)'}}) + r = recv() + text = r.get('result',{}).get('content',[{}])[0].get('text','') + # browser_evaluate wraps result in markdown; extract JSON from quoted line + m = re.search(r'^"(.*)"$', text, re.MULTILINE) + if m: + text = json.loads('"' + m.group(1) + '"') + print('DETECTION:' + text, flush=True) + +finally: + proc.terminate() + proc.wait() + srv.shutdown() +` + +// TestMcp_PatchrightDetectionSuite — comprehensive bot detection suite using a self-hosted +// detection page derived from BotD, fpscanner, CreepJS, and headless-detector. +// Each detection category is a subtest for independent pass/fail tracking. +// +// RED tests (expected to fail until Mesa WebGL + fonts are configured): +// - Fingerprint/WebGLAvailable — no WebGL context in headless without software GL flags +// - Fingerprint/WebGLVendorSpoofed — depends on WebGL being available +// - Fingerprint/WebGLRendererSpoofed — depends on WebGL being available +// - Environment/FontCount — too few fonts installed +func TestMcp_PatchrightDetectionSuite(t *testing.T) { + c := startContainer(t, map[string]string{ + "HOST_USER": hostUser, + "APP_NAME": "test", + "USER_WORKING_DIR": "/tmp/detect-wd", + }) + + // Verify patchright-mcp-cell is present + _, code := exec(t, c, []string{"sh", "-c", "command -v patchright-mcp-cell"}) + if code != 0 { + t.Fatal("FAIL: patchright-mcp-cell not on PATH") } - if _, ok := cfg.McpServers["nixos"]; !ok { - keys := make([]string, 0, len(cfg.McpServers)) - for k := range cfg.McpServers { - keys = append(keys, k) - } - t.Errorf("FAIL: mcpServers.nixos missing; present keys: [%s]", strings.Join(keys, ", ")) + // Start PulseAudio with null sink — Chromium needs an audio backend + // to produce real AudioContext frequency data (otherwise all-zero = bot signal). + // Uses -n -F to skip default config (no dbus dependency) and explicitly loads + // native-protocol-unix (socket) + null-sink (virtual audio output). + exec(t, c, []string{"gosu", hostUser, "mkdir", "-p", "/tmp/pulse-runtime/pulse"}) + exec(t, c, []string{"gosu", hostUser, "sh", "-c", + "dbus-uuidgen > /etc/machine-id 2>/dev/null || true"}) + _, paCode := exec(t, c, []string{"gosu", hostUser, "sh", "-c", + `XDG_RUNTIME_DIR=/tmp/pulse-runtime pulseaudio --daemonize=yes --exit-idle-time=-1 ` + + `--disable-shm=true -n ` + + `--load="module-null-sink sink_name=NullSink" ` + + `--load="module-native-protocol-unix"`, + }) + if paCode != 0 { + t.Log("WARNING: PulseAudio not available — AudioContext test may fail") } else { - t.Logf("PASS: nixos entry present in nix-mcp-servers.json") + t.Log("PulseAudio started with null sink") } -} -// TestMcpNixosCommandExecutable — the command in nix-mcp-servers.json must be an executable nix store path. -func TestMcpNixosCommandExecutable(t *testing.T) { - c := startEnvContainer(t) + // Create empty .env (wrapper expects it) + exec(t, c, []string{"sh", "-c", "mkdir -p /tmp/detect-wd && touch /tmp/detect-wd/.env"}) - raw, code := exec(t, c, []string{"cat", "/etc/claude-code/nix-mcp-servers.json"}) - if code != 0 { - t.Fatalf("FAIL: could not read nix-mcp-servers.json (exit %d)", code) + // Copy detection page and client into container + ctx := context.Background() + if err := c.CopyToContainer(ctx, + []byte(e2eDetectionPage), "/tmp/detection-page.html", 0o644); err != nil { + t.Fatalf("FAIL: copy detection page: %v", err) } - - var cfg struct { - McpServers map[string]struct { - Command string `json:"command"` - } `json:"mcpServers"` + if err := c.CopyToContainer(ctx, + []byte(e2eDetectionClient), "/tmp/detection-client.py", 0o755); err != nil { + t.Fatalf("FAIL: copy detection client: %v", err) } - if err := json.Unmarshal([]byte(raw), &cfg); err != nil { - t.Fatalf("FAIL: invalid JSON: %v", err) + + // Run detection client — set PULSE_SERVER so Chromium finds PulseAudio + out, exitCode := exec(t, c, []string{"sh", "-c", + "PULSE_SERVER=unix:/tmp/pulse-runtime/pulse/native " + + "XDG_RUNTIME_DIR=/tmp/pulse-runtime " + + "python3 /tmp/detection-client.py"}) + t.Logf("detection client output:\n%s", out) + if exitCode != 0 { + t.Fatalf("FAIL: detection client exited %d", exitCode) } - entry, ok := cfg.McpServers["nixos"] - if !ok { - t.Fatal("FAIL: nixos entry missing — see TestMcpNixosStagingFileExists") + // Parse DETECTION:{json} line + var detectionJSON string + for _, line := range strings.Split(out, "\n") { + if strings.HasPrefix(line, "DETECTION:") { + detectionJSON = strings.TrimPrefix(line, "DETECTION:") + break + } } - if !strings.HasPrefix(entry.Command, "/nix/store/") { - t.Errorf("FAIL: expected nix store path, got %q", entry.Command) + if detectionJSON == "" { + t.Fatal("FAIL: no DETECTION: line in output") } - _, code = exec(t, c, []string{"test", "-x", entry.Command}) - if code != 0 { - t.Errorf("FAIL: command %q is not executable", entry.Command) - } else { - t.Logf("PASS: command %q is executable", entry.Command) - } + var d struct { + // Stealth (init-script) + Webdriver interface{} `json:"webdriver"` + HasChrome bool `json:"hasChrome"` + HasChromeRT bool `json:"hasChromeRuntime"` + PluginsCount int `json:"pluginsCount"` + PluginsIsArray bool `json:"pluginsIsPluginArray"` + Languages []string `json:"languages"` + // WebGL + WebGLAvailable bool `json:"webglAvailable"` + WebGLVendor string `json:"webglVendor"` + WebGLRenderer string `json:"webglRenderer"` + WebGLError string `json:"webglError"` + // Canvas + CanvasAvailable bool `json:"canvasAvailable"` + CanvasLength int `json:"canvasLength"` + CanvasError string `json:"canvasError"` + // Audio + AudioAvailable bool `json:"audioAvailable"` + AudioSum float64 `json:"audioSum"` + AudioError string `json:"audioError"` + // Screen + ScreenWidth int `json:"screenWidth"` + ScreenHeight int `json:"screenHeight"` + ColorDepth int `json:"colorDepth"` + DevicePixelRatio float64 `json:"devicePixelRatio"` + OuterWidth int `json:"outerWidth"` + OuterHeight int `json:"outerHeight"` + // Codecs + CodecH264 string `json:"codecH264"` + CodecWebm string `json:"codecWebm"` + CodecOgg string `json:"codecOgg"` + // Permissions + PermissionsWorks bool `json:"permissionsWorks"` + NotificationState string `json:"notificationState"` + // Connection + HasConnection bool `json:"hasConnection"` + EffectiveType string `json:"effectiveType"` + // Artifacts + SeleniumArtifacts []string `json:"seleniumArtifacts"` + HasCDCArray bool `json:"hasCDCArray"` + HasCDCPromise bool `json:"hasCDCPromise"` + HasCDCSymbol bool `json:"hasCDCSymbol"` + // UA + UserAgent string `json:"userAgent"` + Platform string `json:"platform"` + UAData *struct { + Platform string `json:"platform"` + Architecture string `json:"architecture"` + } `json:"uaData"` + UADataError string `json:"uaDataError"` + // Fonts + DetectedFonts []string `json:"detectedFonts"` + FontCount int `json:"fontCount"` + } + if err := json.Unmarshal([]byte(detectionJSON), &d); err != nil { + t.Fatalf("FAIL: parse detection JSON: %v\nraw: %s", err, detectionJSON) + } + t.Logf("Detection results: %s", detectionJSON) + + // ── Stealth subtests (should be GREEN with current init-script) ─────── + + t.Run("Stealth/WebDriverUndefined", func(t *testing.T) { + if d.Webdriver == true { + t.Errorf("navigator.webdriver = true (detected as bot)") + } + }) + + t.Run("Stealth/ChromeObject", func(t *testing.T) { + if !d.HasChrome { + t.Errorf("window.chrome missing") + } + if !d.HasChromeRT { + t.Errorf("window.chrome.runtime missing") + } + }) + + t.Run("Stealth/Plugins", func(t *testing.T) { + if d.PluginsCount < 3 { + t.Errorf("plugins.length = %d, want >= 3", d.PluginsCount) + } + if !d.PluginsIsArray { + t.Errorf("plugins not instanceof PluginArray") + } + }) + + t.Run("Stealth/Languages", func(t *testing.T) { + found := false + for _, l := range d.Languages { + if l == "en-US" { + found = true + break + } + } + if !found { + t.Errorf("languages = %v, want en-US", d.Languages) + } + }) + + t.Run("Stealth/NoSeleniumArtifacts", func(t *testing.T) { + if len(d.SeleniumArtifacts) > 0 { + t.Errorf("selenium artifacts found: %v", d.SeleniumArtifacts) + } + }) + + t.Run("Stealth/NoCDPArtifacts", func(t *testing.T) { + if d.HasCDCArray || d.HasCDCPromise || d.HasCDCSymbol { + t.Errorf("CDP artifacts found: array=%v promise=%v symbol=%v", + d.HasCDCArray, d.HasCDCPromise, d.HasCDCSymbol) + } + }) + + // ── Fingerprint subtests (RED until Mesa WebGL configured) ──────────── + + t.Run("Fingerprint/WebGLAvailable", func(t *testing.T) { + if !d.WebGLAvailable { + t.Errorf("WebGL context not available (error: %s) — need software GL flags", d.WebGLError) + } + }) + + t.Run("Fingerprint/WebGLVendorSpoofed", func(t *testing.T) { + if !d.WebGLAvailable { + t.Errorf("WebGL not available, cannot verify vendor spoof") + return + } + if d.WebGLVendor != "Intel Inc." { + t.Errorf("WebGL vendor = %q, want 'Intel Inc.'", d.WebGLVendor) + } + }) + + t.Run("Fingerprint/WebGLRendererSpoofed", func(t *testing.T) { + if !d.WebGLAvailable { + t.Errorf("WebGL not available, cannot verify renderer spoof") + return + } + if d.WebGLRenderer != "Intel Iris OpenGL Engine" { + t.Errorf("WebGL renderer = %q, want 'Intel Iris OpenGL Engine'", d.WebGLRenderer) + } + }) + + t.Run("Fingerprint/Canvas2D", func(t *testing.T) { + if !d.CanvasAvailable { + t.Errorf("canvas 2D not available: %s", d.CanvasError) + return + } + // A real canvas fingerprint produces a data URL > 1000 chars + if d.CanvasLength < 1000 { + t.Errorf("canvas dataURL length = %d, want > 1000 (blank canvas?)", d.CanvasLength) + } + }) + + t.Run("Fingerprint/AudioContext", func(t *testing.T) { + if !d.AudioAvailable { + t.Errorf("audio fingerprint not available: %s", d.AudioError) + return + } + if d.AudioSum == 0 { + t.Errorf("audio frequency sum = 0 (no audio data)") + } + }) + + // ── Environment subtests ───────────────────────────────────────────── + + t.Run("Environment/Screen", func(t *testing.T) { + if d.ColorDepth < 24 { + t.Errorf("colorDepth = %d, want >= 24", d.ColorDepth) + } + if d.DevicePixelRatio == 0 { + t.Errorf("devicePixelRatio = 0") + } + }) + + t.Run("Environment/CodecWebm", func(t *testing.T) { + if d.CodecWebm == "" { + t.Errorf("WebM codec not supported") + } + }) + + t.Run("Environment/CodecOgg", func(t *testing.T) { + if d.CodecOgg == "" { + t.Errorf("Ogg codec not supported") + } + }) + + t.Run("Environment/Permissions", func(t *testing.T) { + if !d.PermissionsWorks { + t.Errorf("Permissions API query failed") + } + }) + + t.Run("Environment/FontCount", func(t *testing.T) { + // Real browsers typically detect 10+ fonts from the test set. + // Headless with minimal fonts detects < 5. + t.Logf("detected fonts (%d): %v", d.FontCount, d.DetectedFonts) + if d.FontCount < 23 { + t.Errorf("fontCount = %d, want >= 23 (too few fonts installed)", d.FontCount) + } + }) } -// TestMcpClaudeJsonBackupOnMerge — when ~/.claude.json pre-exists and backupBeforeMerge=true, +// TestMcp_ClaudeJsonBackupOnMerge — when ~/.claude.json pre-exists and backupBeforeMerge=true, // a timestamped backup must be created before the merge overwrites the file. -func TestMcpClaudeJsonBackupOnMerge(t *testing.T) { +func TestMcp_ClaudeJsonBackupOnMerge(t *testing.T) { c := startEnvContainer(t) // Confirm the staging file declares backupBeforeMerge=true. @@ -804,42 +1204,3 @@ func TestMcpClaudeJsonBackupOnMerge(t *testing.T) { } t.Logf("PASS: %s", strings.ReplaceAll(strings.TrimSpace(out), "\n", " | ")) } - -// TestMcpClaudeJsonMerge — entrypoint must merge nix MCP servers into ~/.claude.json. -// Verifies the additive merge model: nix servers land in user config after container start. -func TestMcpClaudeJsonMerge(t *testing.T) { - c := startEnvContainer(t) - - claudeJson := "/home/" + hostUser + "/.claude.json" - - _, code := exec(t, c, []string{"test", "-f", claudeJson}) - if code != 0 { - t.Fatalf("FAIL: %s does not exist after entrypoint", claudeJson) - } - - raw, code := exec(t, c, []string{"cat", claudeJson}) - if code != 0 { - t.Fatalf("FAIL: could not read %s (exit %d)", claudeJson, code) - } - - var cfg struct { - McpServers map[string]json.RawMessage `json:"mcpServers"` - } - if err := json.Unmarshal([]byte(raw), &cfg); err != nil { - t.Fatalf("FAIL: %s contains invalid JSON: %v\n%s", claudeJson, err, raw) - } - - if len(cfg.McpServers) == 0 { - t.Fatalf("FAIL: mcpServers is empty in %s — merge did not run", claudeJson) - } - - if _, ok := cfg.McpServers["nixos"]; !ok { - keys := make([]string, 0, len(cfg.McpServers)) - for k := range cfg.McpServers { - keys = append(keys, k) - } - t.Errorf("FAIL: nixos missing from %s; present: [%s]", claudeJson, strings.Join(keys, ", ")) - } else { - t.Logf("PASS: nixos present in %s (%d server(s) total)", claudeJson, len(cfg.McpServers)) - } -} diff --git a/test/mise_test.go b/test/mise_test.go deleted file mode 100644 index 09736306..00000000 --- a/test/mise_test.go +++ /dev/null @@ -1,277 +0,0 @@ -package container_test - -// mise_test.go — integration tests for the ~/.local/share/mise symlink approach. -// -// Design: MISE_DATA_DIR must be ~/.local/share/mise (CellHome, persisted) not /opt/mise (ephemeral). -// Baked-in tool versions are symlinked from /opt/mise → ~/.local/share/mise at entrypoint time. -// User-installed versions live as real dirs in ~/.local/share/mise and survive container restarts. - -import ( - "fmt" - "strings" - "testing" -) - -// ── 1. MISE_DATA_DIR ────────────────────────────────────────────────────────── - -// TestMiseDataDir — session user's MISE_DATA_DIR must be ~/.local/share/mise, not /opt/mise. -func TestMiseDataDir(t *testing.T) { - c := startEnvContainer(t) - - out, code := asUser(t, c, "echo $MISE_DATA_DIR") - if code != 0 { - t.Fatalf("FAIL: could not read MISE_DATA_DIR (exit %d): %s", code, out) - } - - expected := fmt.Sprintf("/home/%s/.local/share/mise", hostUser) - if out != expected { - t.Errorf("FAIL: MISE_DATA_DIR=%q, want %q", out, expected) - } else { - t.Logf("PASS: MISE_DATA_DIR=%q", out) - } -} - -// ── 2. Baked-in versions symlinked ─────────────────────────────────────────── - -// TestMiseBakedVersionsSymlinked — each baked-in tool version must appear in -// ~/.local/share/mise/installs// as a symlink pointing into /opt/mise. -func TestMiseBakedVersionsSymlinked(t *testing.T) { - c := startEnvContainer(t) - - // ~/.local/share/mise/installs/ must exist and contain at least one tool. - out, code := asUser(t, c, "ls ~/.local/share/mise/installs/ 2>&1") - if code != 0 || strings.TrimSpace(out) == "" { - t.Fatalf("FAIL: ~/.local/share/mise/installs/ missing or empty (exit %d): %s", code, out) - } - t.Logf("tools in ~/.local/share/mise/installs/: %s", strings.TrimSpace(out)) - - // Every version entry must be a symlink targeting /opt/mise/installs/. - out, code = asUser(t, c, ` - bad=0 - for tool in ~/.local/share/mise/installs/*/; do - for ver in "$tool"*/; do - link="${ver%/}" - [ -e "$link" ] || continue - if [ ! -L "$link" ]; then - echo "NOT_SYMLINK: $link" - bad=1 - else - target=$(readlink "$link") - case "$target" in - /opt/mise/*) echo "OK: $(basename "$link") -> $target" ;; - *) echo "WRONG_TARGET: $link -> $target"; bad=1 ;; - esac - fi - done - done - exit $bad - `) - if code != 0 { - t.Errorf("FAIL: baked-in versions not correctly symlinked:\n%s", out) - } else { - t.Logf("PASS:\n%s", out) - } -} - -// ── 3. No dangling symlinks ─────────────────────────────────────────────────── - -// TestMiseNoDanglingSymlinks — after a fresh container start there must be no -// dangling symlinks in ~/.local/share/mise/installs/. -func TestMiseNoDanglingSymlinks(t *testing.T) { - c := startEnvContainer(t) - - out, code := asUser(t, c, ` - found=0 - for tool in ~/.local/share/mise/installs/*/; do - for ver in "$tool"*/; do - link="${ver%/}" - if [ -L "$link" ] && [ ! -e "$link" ]; then - echo "DANGLING: $link -> $(readlink "$link")" - found=1 - fi - done - done - exit $found - `) - if code != 0 { - t.Errorf("FAIL: dangling symlinks found after fresh start:\n%s", out) - } else { - t.Logf("PASS: no dangling symlinks") - } -} - -// ── 4. Node reachable via mise shims ───────────────────────────────────────── - -// TestMiseNodeViaUserShims — node must be reachable through ~/.local/share/mise/shims, -// confirming that MISE_DATA_DIR is correct and reshim ran successfully. -func TestMiseNodeViaUserShims(t *testing.T) { - c := startEnvContainer(t) - - // Shim must live in ~/.local/share/mise/shims, not /opt/mise/shims. - shimPath, code := asUser(t, c, "which node") - if code != 0 { - t.Fatalf("FAIL: node not found on PATH (exit %d): %s", code, shimPath) - } - - expected := fmt.Sprintf("/home/%s/.local/share/mise/shims/node", hostUser) - if shimPath != expected { - t.Errorf("FAIL: node shim at %q, want %q", shimPath, expected) - } else { - t.Logf("PASS: node shim at %q", shimPath) - } - - // Confirm it actually runs. - out, code := asUser(t, c, "node --version") - if code != 0 { - t.Errorf("FAIL: node --version failed (exit %d): %s", code, out) - } else { - t.Logf("PASS: node --version: %s", out) - } -} - -// ── 5. User-installed version not overwritten ───────────────────────────────── - -// TestMiseUserInstallPreserved — setup_mise_home must not overwrite a real dir -// with a symlink (user-installed version must be preserved). -func TestMiseUserInstallPreserved(t *testing.T) { - c := startEnvContainer(t) - - // Create a fake "user-installed" real directory for a non-existent version. - _, code := exec(t, c, []string{"bash", "-c", - "mkdir -p /home/" + hostUser + "/.local/share/mise/installs/node/99.99.99/bin && " + - "printf '#!/bin/sh\\necho v99.99.99\\n' > /home/" + hostUser + "/.local/share/mise/installs/node/99.99.99/bin/node && " + - "chmod +x /home/" + hostUser + "/.local/share/mise/installs/node/99.99.99/bin/node", - }) - if code != 0 { - t.Fatalf("FAIL: could not create fake user install") - } - - // Re-run the symlink setup logic (simulates what entrypoint does on restart). - _, code = exec(t, c, []string{"bash", "-c", ` - baked="/opt/mise" - user_mise="/home/` + hostUser + `/.local/share/mise" - for tool_dir in "$baked/installs"/*/; do - [ -d "$tool_dir" ] || continue - tool_name=$(basename "$tool_dir") - mkdir -p "$user_mise/installs/$tool_name" - for ver_dir in "$tool_dir"*/; do - [ -d "$ver_dir" ] || continue - ver_name=$(basename "$ver_dir") - dest="$user_mise/installs/$tool_name/$ver_name" - [ -d "$dest" ] && [ ! -L "$dest" ] && continue - ln -sfT "$ver_dir" "$dest" - done - done - `}) - if code != 0 { - t.Fatalf("FAIL: re-run of symlink setup failed") - } - - // The real directory must NOT have been replaced by a symlink. - out, code := exec(t, c, []string{"bash", "-c", - "test -L /home/" + hostUser + "/.local/share/mise/installs/node/99.99.99 && echo SYMLINK || echo REAL"}) - if code != 0 || strings.TrimSpace(out) != "REAL" { - t.Errorf("FAIL: user install was converted to symlink: %s", out) - } else { - t.Logf("PASS: user install preserved as real dir") - } -} - -// ── 6. Dangling symlink cleanup ─────────────────────────────────────────────── - -// TestMiseDanglingSymlinkCleaned — dangling symlinks in ~/.local/share/mise/installs/ -// must be removed by setup_mise_home. -func TestMiseDanglingSymlinkCleaned(t *testing.T) { - c := startEnvContainer(t) - - // Inject a dangling symlink pointing to a non-existent /opt/mise path. - _, code := exec(t, c, []string{"bash", "-c", - "mkdir -p /home/" + hostUser + "/.local/share/mise/installs/node && " + - "ln -s /opt/mise/installs/node/0.0.0-nonexistent " + - "/home/" + hostUser + "/.local/share/mise/installs/node/0.0.0-nonexistent", - }) - if code != 0 { - t.Fatalf("FAIL: could not inject dangling symlink") - } - - // Re-run the dangling-symlink cleanup logic from setup_mise_home. - _, code = exec(t, c, []string{"bash", "-c", ` - user_mise="/home/` + hostUser + `/.local/share/mise" - for tool in "$user_mise/installs"/*/; do - for link in "${tool%/}"/*; do - if [ -L "$link" ] && [ ! -e "$link" ]; then rm -f "$link"; fi - done - done - `}) - if code != 0 { - t.Fatalf("FAIL: cleanup logic failed (exit %d)", code) - } - - // The dangling symlink must be gone. - out, _ := exec(t, c, []string{"bash", "-c", - "test -L /home/" + hostUser + "/.local/share/mise/installs/node/0.0.0-nonexistent && echo EXISTS || echo CLEANED", - }) - if strings.TrimSpace(out) != "CLEANED" { - t.Errorf("FAIL: dangling symlink still present after cleanup") - } else { - t.Logf("PASS: dangling symlink cleaned up") - } -} - -// ── 7. Non-interactive shell access ────────────────────────────────────────── - -// TestMiseNonInteractiveShell — tools must be accessible via docker exec -// (non-interactive, non-login shell) through shims on PATH. -func TestMiseNonInteractiveShell(t *testing.T) { - c := startEnvContainer(t) - - out, code := asUser(t, c, "node --version") - if code != 0 { - t.Errorf("FAIL: node not accessible in non-interactive shell (exit %d): %s", code, out) - } else { - t.Logf("PASS: node accessible: %s", out) - } -} - -// ── 8. No ASDF env vars leaked ─────────────────────────────────────────────── - -// TestMiseNoAsdfEnvVarsLeaked — no ASDF_* environment variables should be -// present in the container after migration to mise. -func TestMiseNoAsdfEnvVarsLeaked(t *testing.T) { - c := startEnvContainer(t) - - out, code := asUser(t, c, "env | grep ^ASDF_ || true") - if code != 0 { - t.Fatalf("FAIL: env command failed (exit %d): %s", code, out) - } - - if strings.TrimSpace(out) != "" { - t.Errorf("FAIL: ASDF_* env vars leaked:\n%s", out) - } else { - t.Logf("PASS: no ASDF_* env vars") - } -} - -// ── 9. NPM tools work (mise → node → npm chain) ───────────────────────────── - -// TestMiseNpmToolsAvailable — npm-installed tools from /opt/npm-tools must work, -// verifying the full chain: mise installs node → npm install → tools on PATH. -func TestMiseNpmToolsAvailable(t *testing.T) { - c := startEnvContainer(t) - - // npm itself must be available - out, code := asUser(t, c, "npm --version") - if code != 0 { - t.Fatalf("FAIL: npm not available (exit %d): %s", code, out) - } - t.Logf("PASS: npm version: %s", out) - - // A tool from /opt/npm-tools should be accessible - // patchright-mcp is always in the image package.json - out, code = asUser(t, c, "which mcp-server-patchright 2>/dev/null || which npx 2>/dev/null") - if code != 0 { - t.Errorf("FAIL: no npm tools found on PATH (exit %d): %s", code, out) - } else { - t.Logf("PASS: npm tool found at: %s", out) - } -} diff --git a/test/rdp_test.go b/test/rdp_test.go deleted file mode 100644 index c53728e4..00000000 --- a/test/rdp_test.go +++ /dev/null @@ -1,384 +0,0 @@ -package container_test - -import ( - "context" - osexec "os/exec" - "strconv" - "strings" - "testing" - "time" - - "github.com/testcontainers/testcontainers-go" - "github.com/testcontainers/testcontainers-go/wait" -) - -// startRdpContainer starts a container with GUI enabled and publishes port 3389. -// Waits until xrdp is listening on 3389 (0x0D3D in /proc/net/tcp). -func startRdpContainer(t *testing.T) testcontainers.Container { - t.Helper() - ctx := context.Background() - req := testcontainers.ContainerRequest{ - Image: image(), - ExposedPorts: []string{"3389/tcp", "5900/tcp"}, - Env: map[string]string{ - "HOST_USER": hostUser, - "APP_NAME": "test", - "DEVCELL_GUI_ENABLED": "true", - }, - User: "0", - Cmd: []string{"tail", "-f", "/dev/null"}, - // Port 3389 = 0x0D3D; LISTEN = 0A - WaitingFor: wait.ForExec([]string{"sh", "-c", - "grep -qi 0D3D /proc/net/tcp6 /proc/net/tcp 2>/dev/null && grep -qi ' 0A ' /proc/net/tcp6 /proc/net/tcp 2>/dev/null"}). - WithStartupTimeout(90 * time.Second), - } - c, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ - ContainerRequest: req, - Started: true, - }) - if err != nil { - t.Fatalf("start RDP container: %v", err) - } - t.Cleanup(func() { _ = c.Terminate(ctx) }) - return c -} - -func skipIfNoXrdp(t *testing.T, c testcontainers.Container) { - t.Helper() - _, code := exec(t, c, []string{"sh", "-c", "command -v xrdp"}) - if code != 0 { - t.Skip("skipping: image lacks xrdp") - } -} - -// TestRdpXrdpOnPath — xrdp binary must be on PATH for the session user. -func TestRdpXrdpOnPath(t *testing.T) { - c := startEnvContainer(t) - skipIfNoXrdp(t, c) - out, code := asUser(t, c, "command -v xrdp") - if code != 0 { - t.Fatalf("FAIL: xrdp not on PATH for session user (exit %d)", code) - } - t.Logf("PASS: %s", out) -} - -// TestRdpListensOn3389 — with DEVCELL_GUI_ENABLED=true, xrdp must bind port 3389. -func TestRdpListensOn3389(t *testing.T) { - probeGUI(t) - c := startRdpContainer(t) - _, code := exec(t, c, []string{"pgrep", "xrdp"}) - if code != 0 { - t.Fatalf("FAIL: xrdp process not found (exit %d)", code) - } - out, code := exec(t, c, []string{"sh", "-c", - "grep -i 0D3D /proc/net/tcp6 /proc/net/tcp 2>/dev/null | grep ' 0A '"}) - if code != 0 || !strings.Contains(strings.ToUpper(out), "0D3D") { - t.Errorf("FAIL: port 3389 (0x0D3D) not in LISTEN state:\n%s", out) - } else { - t.Logf("PASS: xrdp listening on :3389\n%s", out) - } -} - -// TestRdpPortPublishedToHost — the published 3389/tcp must map to a non-privileged host port. -func TestRdpPortPublishedToHost(t *testing.T) { - probeGUI(t) - c := startRdpContainer(t) - ctx := context.Background() - mapped, err := c.MappedPort(ctx, "3389/tcp") - if err != nil { - t.Fatalf("FAIL: no mapped port for 3389/tcp: %v", err) - } - port := mapped.Int() - if port < 1024 || port > 65535 { - t.Errorf("FAIL: mapped port %d outside unprivileged range", port) - } else { - t.Logf("PASS: 3389/tcp → host port %d", port) - } -} - -// TestRdpDockerPortByName — `docker port 3389` must return the exact host port. -func TestRdpDockerPortByName(t *testing.T) { - probeGUI(t) - c := startRdpContainer(t) - ctx := context.Background() - - name, err := c.Name(ctx) - if err != nil { - t.Fatalf("FAIL: could not get container name: %v", err) - } - name = strings.TrimPrefix(name, "/") - - out, err := osexec.Command("docker", "port", name, "3389").Output() - if err != nil { - t.Fatalf("FAIL: 'docker port %s 3389' failed: %v", name, err) - } - firstLine := strings.SplitN(strings.TrimSpace(string(out)), "\n", 2)[0] - lastColon := strings.LastIndex(firstLine, ":") - if lastColon < 0 { - t.Fatalf("FAIL: unexpected docker port output: %q", firstLine) - } - hostPort := firstLine[lastColon+1:] - - mapped, err := c.MappedPort(ctx, "3389/tcp") - if err != nil { - t.Fatalf("FAIL: could not get MappedPort: %v", err) - } - want := mapped.Port() - if hostPort != want { - t.Errorf("FAIL: docker port → %q, want %q", hostPort, want) - } else { - t.Logf("PASS: docker port %s 3389 → %s", name, hostPort) - } -} - -// TestRdpConnectWithCreds — xfreerdp +auth-only with correct creds must succeed. -// Note: xfreerdp may return non-zero exit code even on successful auth due to -// TLS negotiation teardown issues. We check xfreerdp's own "exit status" output. -func TestRdpConnectWithCreds(t *testing.T) { - probeGUI(t) - c := startRdpContainer(t) - skipIfNoXfreerdp(t, c) - out, _ := exec(t, c, []string{"sh", "-c", - "DISPLAY=:99 xfreerdp +auth-only /v:127.0.0.1:3389 /u:" + hostUser + " /p:rdp /sec:rdp /cert:ignore 2>&1"}) - // xfreerdp logs "Authentication only, exit status N" where 0 = success. - // Note: process exit code may be non-zero (147/signal) due to TLS teardown even on success. - if strings.Contains(out, "exit status 0") { - t.Logf("PASS: RDP auth-only connection succeeded") - } else if strings.Contains(out, "Authentication only") { - t.Errorf("FAIL: xfreerdp auth returned non-zero status:\n%s", out) - } else { - t.Errorf("FAIL: unexpected xfreerdp output (no auth status line):\n%s", out) - } -} - -// TestRdpNoLoginPrompt — xrdp auto-connects to VNC without showing a login -// screen. With [Xorg] removed and hardcoded creds in [vnc-any], the xrdp log -// should NOT contain "login_wnd". -func TestRdpNoLoginPrompt(t *testing.T) { - probeGUI(t) - c := startRdpContainer(t) - skipIfNoXfreerdp(t, c) - // Clear xrdp log, connect, then check log for VNC connection. - exec(t, c, []string{"sh", "-c", "truncate -s0 /var/log/xrdp.log"}) - // Connect with correct creds — triggers xrdp to proxy to VNC. - exec(t, c, []string{"sh", "-c", - "xfreerdp /v:127.0.0.1:3389 /u:" + hostUser + " /p:rdp /sec:rdp /cert:ignore /timeout:5000 2>&1 &" + - " sleep 3 && kill %1 2>/dev/null; true"}) - // Check xrdp log: should contain VNC connection, not "login_wnd" - out, _ := exec(t, c, []string{"sh", "-c", "cat /var/log/xrdp.log 2>/dev/null"}) - if strings.Contains(out, "login_wnd") { - t.Errorf("FAIL: xrdp showed login window (login_wnd found in log):\n%s", out) - } - if strings.Contains(out, "VNC started connecting") || - strings.Contains(out, "lib_mod_connect") || - strings.Contains(out, "libvnc") { - t.Logf("PASS: xrdp auto-connected to VNC (no login prompt)\n%s", out) - } else { - t.Logf("WARN: could not confirm VNC auto-connect from log (may need DEBUG level):\n%s", out) - } -} - -// TestRdpKickExistingConnection — x11vnc runs without -shared, so a new -// VNC connection (from a second RDP session) should disconnect the first. -func TestRdpKickExistingConnection(t *testing.T) { - probeGUI(t) - c := startRdpContainer(t) - skipIfNoXfreerdp(t, c) - // Start first connection in background. - exec(t, c, []string{"sh", "-c", - "xfreerdp /v:127.0.0.1:3389 /u:" + hostUser + " /p:rdp /sec:rdp /cert:ignore 2>/dev/null &"}) - time.Sleep(3 * time.Second) - - // Start second connection — should kick the first. - exec(t, c, []string{"sh", "-c", - "xfreerdp /v:127.0.0.1:3389 /u:" + hostUser + " /p:rdp /sec:rdp /cert:ignore 2>/dev/null &"}) - time.Sleep(3 * time.Second) - - // After second connect, only one ESTABLISHED VNC connection should remain. - out, _ := exec(t, c, []string{"sh", "-c", - "grep '170C' /proc/net/tcp6 /proc/net/tcp 2>/dev/null | grep -c ' 01 '"}) - count, _ := strconv.Atoi(strings.TrimSpace(out)) - if count <= 1 { - t.Logf("PASS: only %d ESTABLISHED VNC connection(s) — old connection was kicked", count) - } else { - t.Errorf("FAIL: expected 1 ESTABLISHED VNC connection after kick, got %d", count) - } - - // Clean up background xfreerdp processes. - exec(t, c, []string{"sh", "-c", "pkill -f 'xfreerdp.*127.0.0.1:3389' 2>/dev/null; true"}) -} - -// TestRdpDesktopScreenshot — capture X11 screenshot, verify it's non-trivial. -func TestRdpDesktopScreenshot(t *testing.T) { - probeGUI(t) - c := startRdpContainer(t) - _, code := exec(t, c, []string{"sh", "-c", "command -v import"}) - if code != 0 { - t.Skip("skipping: ImageMagick `import` not on PATH") - } - out, code := exec(t, c, []string{"sh", "-c", - "DISPLAY=:99 import -window root /tmp/screenshot.png 2>&1"}) - if code != 0 { - t.Fatalf("FAIL: screenshot capture failed (exit %d):\n%s", code, out) - } - sizeOut, code := exec(t, c, []string{"sh", "-c", "stat -c %s /tmp/screenshot.png"}) - if code != 0 { - t.Fatalf("FAIL: could not stat screenshot: exit %d", code) - } - size, err := strconv.ParseInt(strings.TrimSpace(sizeOut), 10, 64) - if err != nil { - t.Fatalf("FAIL: could not parse file size %q: %v", sizeOut, err) - } - if size < 10*1024 { - t.Errorf("FAIL: screenshot too small (%d bytes) — desktop may not be rendering", size) - } else { - t.Logf("PASS: desktop screenshot captured (%d bytes)", size) - } - - identOut, _ := exec(t, c, []string{"sh", "-c", - "identify /tmp/screenshot.png 2>/dev/null | head -1"}) - if strings.Contains(identOut, "1920x1080") { - t.Logf("PASS: screenshot resolution 1920x1080") - } else { - t.Logf("WARN: unexpected resolution: %s", identOut) - } -} - -// TestRdpLogsToFile — xrdp logs must go to /var/log, not stdout. -func TestRdpLogsToFile(t *testing.T) { - probeGUI(t) - c := startRdpContainer(t) - out, code := exec(t, c, []string{"sh", "-c", - "test -f /var/log/xrdp.log && echo OK"}) - if code != 0 || !strings.Contains(out, "OK") { - t.Fatalf("FAIL: xrdp log files not found in /var/log/") - } - // xrdp.ini must have LogFile pointing to /var/log and syslog disabled - out, _ = exec(t, c, []string{"sh", "-c", - "grep -E '^LogFile=|^EnableSyslog=' /tmp/xrdp/xrdp.ini 2>/dev/null"}) - if !strings.Contains(out, "LogFile=/var/log/xrdp.log") { - t.Errorf("FAIL: xrdp.ini LogFile not set to /var/log/xrdp.log:\n%s", out) - } - if !strings.Contains(out, "EnableSyslog=false") { - t.Errorf("FAIL: xrdp.ini EnableSyslog should be false:\n%s", out) - } else { - t.Logf("PASS: xrdp logs to /var/log/, syslog disabled\n%s", out) - } -} - -// TestRdpCertPersisted — SSL cert should be in /etc/devcell/config/xrdp/ (global config). -func TestRdpCertPersisted(t *testing.T) { - probeGUI(t) - c := startRdpContainer(t) - certDir := "/etc/devcell/config/xrdp" - out, code := exec(t, c, []string{"sh", "-c", - "test -f " + certDir + "/key.pem && test -f " + certDir + "/cert.pem && echo OK"}) - if code != 0 || !strings.Contains(out, "OK") { - t.Fatalf("FAIL: RDP cert not found in %s", certDir) - } - t.Logf("PASS: RDP cert persisted in %s", certDir) -} - -// TestRdpNoXorgSection — [Xorg] section must be removed from xrdp.ini. -func TestRdpNoXorgSection(t *testing.T) { - probeGUI(t) - c := startRdpContainer(t) - out, _ := exec(t, c, []string{"sh", "-c", - "grep -c '\\[Xorg\\]' /tmp/xrdp/xrdp.ini 2>/dev/null || echo 0"}) - count, _ := strconv.Atoi(strings.TrimSpace(out)) - if count > 0 { - t.Errorf("FAIL: [Xorg] section should be removed from xrdp.ini (found %d)", count) - } else { - t.Logf("PASS: no [Xorg] section in xrdp.ini") - } -} - -// TestRdpUserExists — session user must exist for xrdp VNC proxy. -// Note: no PAM password needed — xrdp uses [vnc-any] with hardcoded VNC creds. -func TestRdpUserExists(t *testing.T) { - probeGUI(t) - c := startRdpContainer(t) - out, code := exec(t, c, []string{"sh", "-c", - "id " + hostUser + " && getent passwd " + hostUser + " | cut -d: -f7"}) - if code != 0 { - t.Fatalf("FAIL: user %s does not exist (exit %d)", hostUser, code) - } - if strings.Contains(out, "/bin/zsh") || strings.Contains(out, "/bin/bash") { - t.Logf("PASS: user %s exists with valid shell:\n%s", hostUser, out) - } else { - t.Errorf("FAIL: user %s has unexpected shell:\n%s", hostUser, out) - } -} - -// TestRdpClientResolution — verify the RDP client receives the full 1920x1080 -// desktop without cropping or scaling. Connects xfreerdp requesting 1920x1080, -// checks xrdp negotiated that resolution, and takes a server-side screenshot -// to verify the framebuffer dimensions during an active RDP session. -func TestRdpClientResolution(t *testing.T) { - probeGUI(t) - c := startRdpContainer(t) - skipIfNoXfreerdp(t, c) - - // Verify server-side Xvfb is 1920x1080 before connecting. - xrandrOut, code := exec(t, c, []string{"sh", "-c", "DISPLAY=:99 xrandr 2>&1"}) - if code != 0 { - t.Fatalf("xrandr failed: %s", xrandrOut) - } - if !strings.Contains(xrandrOut, "1920x1080") { - t.Fatalf("server display not 1920x1080 before RDP connect:\n%s", xrandrOut) - } - - // Clear xrdp log to capture only this session's negotiation. - exec(t, c, []string{"sh", "-c", "truncate -s0 /var/log/xrdp.log"}) - - // Start a client Xvfb on :98 (larger than 1920x1080 to avoid constraining xfreerdp). - exec(t, c, []string{"sh", "-c", "Xvfb :98 -screen 0 2560x1440x24 -dpi 96 &"}) - time.Sleep(1 * time.Second) - - // Connect xfreerdp on :98 requesting 1920x1080. Run for 5s then kill. - exec(t, c, []string{"sh", "-c", - "DISPLAY=:98 xfreerdp /v:127.0.0.1:3389 /u:" + hostUser + - " /p:rdp /sec:rdp /cert:ignore /size:1920x1080 /gdi:sw 2>&1 &" + - " sleep 5 && kill %1 2>/dev/null; wait 2>/dev/null; true"}) - - // 1. Check xrdp log for negotiated 1920x1080. - logOut, _ := exec(t, c, []string{"sh", "-c", "cat /var/log/xrdp.log 2>/dev/null"}) - if !strings.Contains(logOut, "1920") || !strings.Contains(logOut, "1080") { - t.Errorf("FAIL: xrdp did not negotiate 1920x1080:\n%s", logOut) - } else { - t.Logf("PASS: xrdp negotiated 1920x1080") - } - - // 2. Verify server display remains 1920x1080 during the session. - xrandrOut, code = exec(t, c, []string{"sh", "-c", "DISPLAY=:99 xrandr 2>&1"}) - if code != 0 || !strings.Contains(xrandrOut, "1920x1080") { - t.Errorf("FAIL: server display not 1920x1080 during RDP session:\n%s", xrandrOut) - } - - // 3. Take server-side screenshot during active session, verify dimensions. - _, hasImport := exec(t, c, []string{"sh", "-c", "command -v import"}) - if hasImport == 0 { - exec(t, c, []string{"sh", "-c", - "DISPLAY=:99 import -window root /tmp/rdp-resolution.png 2>&1"}) - identOut, _ := exec(t, c, []string{"sh", "-c", - "identify /tmp/rdp-resolution.png 2>/dev/null | head -1"}) - if strings.Contains(identOut, "1920x1080") { - t.Logf("PASS: server screenshot 1920x1080 during RDP session") - } else { - t.Errorf("FAIL: server screenshot not 1920x1080: %s", identOut) - } - } - - // Cleanup - exec(t, c, []string{"sh", "-c", - "pkill -f 'xfreerdp.*127.0.0.1:3389' 2>/dev/null; pkill -f 'Xvfb :98' 2>/dev/null; true"}) -} - -// skipIfNoXfreerdp skips the test if xfreerdp is not on PATH inside the container. -func skipIfNoXfreerdp(t *testing.T, c testcontainers.Container) { - t.Helper() - _, code := exec(t, c, []string{"sh", "-c", "command -v xfreerdp"}) - if code != 0 { - t.Skip("skipping: xfreerdp not on PATH") - } -} diff --git a/test/runtime_test.go b/test/runtime_test.go new file mode 100644 index 00000000..f8b651c3 --- /dev/null +++ b/test/runtime_test.go @@ -0,0 +1,971 @@ +// runtime_test.go — session user, nix env, shell, mise, git, persistent home tests + +package container_test + +import ( + "context" + "fmt" + "strconv" + "strings" + "testing" + "time" + + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/wait" + "golang.org/x/mod/semver" +) + +const hostUser = "testuser" + +// ── helpers ─────────────────────────────────────────────────────────────────── + +func startEnvContainer(t *testing.T) testcontainers.Container { + t.Helper() + return startContainer(t, map[string]string{ + "HOST_USER": hostUser, + "APP_NAME": "test", + }) +} + +// asUser runs cmd as the session user inside a login shell so ~/.bashrc is sourced. +func asUser(t *testing.T, c testcontainers.Container, cmd string) (string, int) { + t.Helper() + return exec(t, c, []string{"gosu", hostUser, "bash", "-lc", cmd}) +} + +// startContainerWithStaleHome starts a container where $HOME is pre-seeded +// with stale state that simulates a persistent bind mount from a previous +// image build. +func startContainerWithStaleHome(t *testing.T) testcontainers.Container { + t.Helper() + ctx := context.Background() + img := image() + volName := "devcell-stale-home-" + time.Now().Format("150405") + + // Step 1: Create a volume and seed it with stale content. + seedScript := ` +set -e +mkdir -p /home/testuser/.config/nix +mkdir -p /home/testuser/.config/mise +mkdir -p /home/testuser/.config/fontconfig/conf.d +mkdir -p /home/testuser/.fluxbox/styles/devcell-ocean +ln -s /nix/store/STALE_HASH_DOES_NOT_EXIST-home-manager-files/.config/mise/config.toml \ + /home/testuser/.config/mise/config.toml +ln -s /nix/store/STALE_HASH_DOES_NOT_EXIST-home-manager-files/.tool-versions \ + /home/testuser/.tool-versions +ln -s /nix/store/STALE_HASH_DOES_NOT_EXIST-home-manager-files/.config/fontconfig/conf.d/10-hm-fonts.conf \ + /home/testuser/.config/fontconfig/conf.d/10-hm-fonts.conf +ln -s /nix/store/STALE_HASH_DOES_NOT_EXIST-home-manager-files/.fluxbox/styles/devcell-ocean/theme.cfg \ + /home/testuser/.fluxbox/styles/devcell-ocean/theme.cfg +chown -R 1000:1000 /home/testuser +echo "STALE_SEED_DONE" +` + // Seed using a throwaway alpine container with the volume. + seedReq := testcontainers.ContainerRequest{ + Image: "alpine:latest", + Cmd: []string{"sh", "-c", seedScript}, + Mounts: testcontainers.Mounts( + testcontainers.VolumeMount(volName, "/home/testuser"), + ), + WaitingFor: wait.ForLog("STALE_SEED_DONE").WithStartupTimeout(15 * time.Second), + } + seedC, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: seedReq, + Started: true, + }) + if err != nil { + t.Fatalf("seed container: %v", err) + } + _ = seedC.Terminate(ctx) + t.Logf("Seeded stale home volume: %s", volName) + + // Step 2: Start the real container with the pre-seeded volume. + req := testcontainers.ContainerRequest{ + Image: img, + Env: map[string]string{ + "HOST_USER": hostUser, + "APP_NAME": "test", + }, + User: "0", + Cmd: []string{"tail", "-f", "/dev/null"}, + Mounts: testcontainers.Mounts( + testcontainers.VolumeMount(volName, "/home/testuser"), + ), + WaitingFor: wait.ForExec([]string{"pgrep", "tail"}). + WithStartupTimeout(30 * time.Second), + } + c, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: req, + Started: true, + }) + if err != nil { + t.Fatalf("start container with stale home: %v", err) + } + t.Cleanup(func() { + _ = c.Terminate(ctx) + // Remove the volume after test. + removeVolume(volName) + }) + return c +} + +// removeVolume removes a Docker volume (best-effort). +func removeVolume(name string) { + ctx := context.Background() + cli, err := testcontainers.NewDockerClientWithOpts(ctx) + if err != nil { + return + } + defer cli.Close() + cli.VolumeRemove(ctx, name, true) //nolint:errcheck +} + +// --- Environment --- + +// TestEnv_NixLdLibraryPath -- .nix-ld-library-path must include GUI libs from the +// full profile closure, not just the base packages. +// Regression: generateNixLdPath activation ran after writeBoundary but before +// linkGeneration, scanning the old/empty profile generation instead of the current one. +func TestEnv_NixLdLibraryPath(t *testing.T) { + c := startEnvContainer(t) + + // The file must exist and be non-empty. + out, code := exec(t, c, []string{"cat", "/opt/devcell/.nix-ld-library-path"}) + if code != 0 || out == "" { + t.Fatalf("FAIL: /opt/devcell/.nix-ld-library-path missing or empty (exit %d)", code) + } + entries := strings.Split(out, ":") + t.Logf("INFO: .nix-ld-library-path has %d entries", len(entries)) + + // Critical GUI libraries that Electron/Chromium needs. + // These come from the desktop module's runtimeLibs and home.packages. + // If generateNixLdPath scans the wrong generation, none of these appear. + requiredSubstrings := []string{ + "gtk+3", // GTK 3 + "cairo-", // 2D rendering + "pango-", // text layout + "nss-", // network security + "nspr-", // Netscape Portable Runtime + "alsa-lib-", // audio + "dbus-", // D-Bus IPC + "libxkbcommon", // keyboard handling + "mesa", // OpenGL/Mesa + } + + for _, sub := range requiredSubstrings { + found := false + for _, e := range entries { + if strings.Contains(e, sub) { + found = true + break + } + } + if !found { + t.Errorf("FAIL: .nix-ld-library-path missing %q — activation script likely scanned stale profile", sub) + } else { + t.Logf("PASS: found %q in .nix-ld-library-path", sub) + } + } +} + +// TestEnv_LdLibraryPathSession -- session user's LD_LIBRARY_PATH must include +// GUI libraries loaded from .nix-ld-library-path. +func TestEnv_LdLibraryPathSession(t *testing.T) { + c := startEnvContainer(t) + + out, code := asUser(t, c, "echo $LD_LIBRARY_PATH") + if code != 0 || out == "" { + t.Fatalf("FAIL: LD_LIBRARY_PATH not set in user session (exit %d)", code) + } + entries := strings.Split(out, ":") + t.Logf("INFO: LD_LIBRARY_PATH has %d entries", len(entries)) + + // Spot-check a few GUI libs that must be resolvable. + for _, sub := range []string{"gtk+3", "cairo-", "nss-"} { + found := false + for _, e := range entries { + if strings.Contains(e, sub) { + found = true + break + } + } + if !found { + t.Errorf("FAIL: LD_LIBRARY_PATH missing %q — GUI libs not exported to session", sub) + } else { + t.Logf("PASS: found %q in LD_LIBRARY_PATH", sub) + } + } +} + +// TestEnv_NixProfilePath -- /opt/devcell must exist and contain a .nix-profile. +func TestEnv_NixProfilePath(t *testing.T) { + c := startEnvContainer(t) + + for _, path := range []string{ + "/opt/devcell", + "/opt/devcell/.nix-profile", + "/opt/devcell/.nix-profile/bin", + } { + _, code := exec(t, c, []string{"test", "-e", path}) + if code != 0 { + t.Errorf("FAIL: %s does not exist", path) + } else { + t.Logf("PASS: %s exists", path) + } + } +} + +// TestEnv_NixProfile -- home-manager native profile path must resolve. +func TestEnv_NixProfile(t *testing.T) { + c := startEnvContainer(t) + out, code := exec(t, c, []string{"readlink", "-f", "/opt/devcell/.local/state/nix/profiles/profile"}) + if code != 0 || !strings.Contains(out, "/nix/store/") { + t.Fatalf("FAIL: /opt/devcell/.local/state/nix/profiles/profile doesn't resolve to /nix/store (exit %d): %q", code, out) + } + t.Logf("PASS: nix profile -> %s", out) +} + +// TestEnv_SessionIdentity -- $HOME and $USER must match HOST_USER. +func TestEnv_SessionIdentity(t *testing.T) { + c := startEnvContainer(t) + + expectedHome := "/home/" + hostUser + + cases := []struct { + name string + cmd string + contains string + }{ + {"whoami", "whoami", hostUser}, + {"HOME", "echo $HOME", expectedHome}, + {"USER", "echo $USER", hostUser}, + } + + for _, tc := range cases { + out, code := exec(t, c, []string{"gosu", hostUser, "bash", "-lc", tc.cmd}) + if code != 0 || !strings.Contains(out, tc.contains) { + t.Errorf("FAIL %s: want %q, got %q (exit %d)", tc.name, tc.contains, out, code) + } else { + t.Logf("PASS %s: %q", tc.name, out) + } + } +} + +// TestEnv_WritePaths -- GOPATH and home must be writable by the session user. +func TestEnv_WritePaths(t *testing.T) { + c := startEnvContainer(t) + + // GOPATH must not be inside /opt/devcell + gopath, code := exec(t, c, []string{"gosu", hostUser, "bash", "-lc", "go env GOPATH"}) + if code != 0 { + t.Fatalf("FAIL: could not get GOPATH (exit %d): %s", code, gopath) + } + if strings.HasPrefix(gopath, "/opt/devcell") { + t.Errorf("FAIL: GOPATH=%q points into /opt/devcell -- session user can't write there", gopath) + } else { + t.Logf("PASS: GOPATH=%q (not in /opt/devcell)", gopath) + } + + // GOPATH must be writable + probe := gopath + "/.write-test-" + strconv.FormatInt(time.Now().UnixNano(), 36) + _, code = exec(t, c, []string{"gosu", hostUser, "bash", "-lc", + fmt.Sprintf("mkdir -p %q && rmdir %q", probe, probe)}) + if code != 0 { + t.Errorf("FAIL: GOPATH=%q is not writable by session user", gopath) + } else { + t.Logf("PASS: GOPATH=%q is writable", gopath) + } + + // $HOME must be writable + _, code = exec(t, c, []string{"gosu", hostUser, "bash", "-lc", + "touch ~/.write-test && rm ~/.write-test"}) + if code != 0 { + t.Errorf("FAIL: $HOME is not writable by session user") + } else { + t.Logf("PASS: $HOME is writable") + } + + // /opt/devcell must NOT be writable by session user (it's the nix env, owned by devcell) + _, code = exec(t, c, []string{"gosu", hostUser, "bash", "-lc", + "touch /opt/devcell/.write-test 2>/dev/null"}) + if code == 0 { + t.Errorf("FAIL: session user can write to /opt/devcell -- should be read-only") + // cleanup + exec(t, c, []string{"rm", "-f", "/opt/devcell/.write-test"}) //nolint + } else { + t.Logf("PASS: /opt/devcell is read-only for session user") + } +} + +// TestEnv_BasePermissions -- /opt/devcell directories must be owned by devcell (uid 1000). +func TestEnv_BasePermissions(t *testing.T) { + c := startEnvContainer(t) + + dirs := []string{ + "/opt/devcell", + "/opt/devcell/.config", + "/opt/devcell/.config/nix", + "/opt/devcell/.config/devcell", + "/opt/devcell/.nix-profile", + "/opt/mise", + "/opt/npm-tools", + "/opt/python-tools", + } + + for _, dir := range dirs { + out, code := exec(t, c, []string{"stat", "-c", "%u:%g", dir}) + if code != 0 { + t.Errorf("FAIL: %s does not exist", dir) + continue + } + if out != "1000:1000" { + t.Errorf("FAIL: %s owned by %s, want 1000:1000", dir, out) + } else { + t.Logf("PASS: %s owned by %s", dir, out) + } + } +} + +// TestEnv_ImageVersionStamps -- /etc/devcell/base-image-version and +// /etc/devcell/user-image-version must exist and contain a commit-date stamp. +func TestEnv_ImageVersionStamps(t *testing.T) { + c := startEnvContainer(t) + + for _, tc := range []struct { + name string + path string + }{ + {"base-image-version", "/etc/devcell/base-image-version"}, + {"user-image-version", "/etc/devcell/user-image-version"}, + } { + out, code := exec(t, c, []string{"cat", tc.path}) + if code != 0 { + t.Errorf("FAIL %s: file not found at %s", tc.name, tc.path) + continue + } + out = strings.TrimSpace(out) + if out == "" { + t.Errorf("FAIL %s: file is empty", tc.name) + } else if !strings.Contains(out, "-") { + t.Errorf("FAIL %s: expected - format, got %q", tc.name, out) + } else { + t.Logf("PASS %s: %s", tc.name, out) + } + } +} + +// TestEnv_StartupTime -- container must reach ready state within budget. +func TestEnv_StartupTime(t *testing.T) { + const budgetSeconds = 10 // generous -- tighten after refactor confirmed + + start := time.Now() + _ = startContainer(t, map[string]string{ + "HOST_USER": hostUser, + "APP_NAME": "test", + }) + elapsed := time.Since(start) + + t.Logf("Startup time: %.1fs (budget: %ds)", elapsed.Seconds(), budgetSeconds) + if elapsed > time.Duration(budgetSeconds)*time.Second { + t.Errorf("FAIL: startup took %.1fs, over %ds budget", elapsed.Seconds(), budgetSeconds) + } else { + t.Logf("PASS: within budget") + } +} + +// --- Shell --- + +// TestShell_StarshipConfigExists verifies the home-manager-generated config +// is present and contains the expected unicode character symbol. +func TestShell_StarshipConfigExists(t *testing.T) { + if testing.Short() { + t.Skip("skipping in short mode") + } + c := startEnvContainer(t) + + out, code := asUser(t, c, "cat ~/.config/starship.toml") + if code != 0 { + t.Fatalf("starship.toml not found (exit %d): %s", code, out) + } + + if !strings.Contains(out, "\u2022") { + t.Errorf("FAIL: starship.toml missing \u2022 character symbol:\n%s", out) + } else { + t.Logf("PASS: starship.toml contains \u2022 symbol") + } + + if !strings.Contains(out, "add_newline = false") { + t.Errorf("FAIL: starship.toml missing add_newline setting:\n%s", out) + } +} + +// TestShell_StarshipPromptRenders runs `starship prompt` directly and verifies +// the rendered output contains the configured unicode symbols. +func TestShell_StarshipPromptRenders(t *testing.T) { + if testing.Short() { + t.Skip("skipping in short mode") + } + c := startEnvContainer(t) + + out, code := asUser(t, c, "cd /tmp && starship prompt") + if code != 0 { + t.Fatalf("starship prompt failed (exit %d): %s", code, out) + } + + if !strings.Contains(out, "\u2022") { + t.Errorf("FAIL: starship prompt output missing \u2022 symbol: %q", out) + } else { + t.Logf("PASS: starship prompt rendered \u2022: %q", out) + } +} + +// TestShell_ZshStarshipIntegration verifies the complete integration chain: +// zsh sources .zshrc -> starship init is evaluated -> starship prompt renders. +func TestShell_ZshStarshipIntegration(t *testing.T) { + if testing.Short() { + t.Skip("skipping in short mode") + } + c := startEnvContainer(t) + + out, code := asUser(t, c, `zsh -c 'source ~/.zshrc 2>/dev/null; starship prompt'`) + if code != 0 { + t.Fatalf("zsh + starship prompt failed (exit %d): %s", code, out) + } + + if !strings.Contains(out, "\u2022") { + t.Errorf("FAIL: zsh->.zshrc->starship prompt missing \u2022 symbol: %q", out) + } else { + t.Logf("PASS: zsh->.zshrc->starship prompt rendered \u2022: %q", out) + } +} + +// TestShell_ZshAutosuggestions verifies the zsh-autosuggestions plugin is loaded. +func TestShell_ZshAutosuggestions(t *testing.T) { + if testing.Short() { + t.Skip("skipping in short mode") + } + c := startEnvContainer(t) + + out, code := asUser(t, c, "grep -l autosuggestions ~/.zshrc") + if code != 0 { + t.Fatalf("FAIL: zsh-autosuggestions not referenced in .zshrc (exit %d): %s", code, out) + } + t.Logf("PASS: zsh-autosuggestions found in .zshrc") +} + +// TestShell_ZshSyntaxHighlighting verifies the syntax-highlighting plugin is loaded. +func TestShell_ZshSyntaxHighlighting(t *testing.T) { + if testing.Short() { + t.Skip("skipping in short mode") + } + c := startEnvContainer(t) + + out, code := asUser(t, c, "grep -l syntax-highlighting ~/.zshrc") + if code != 0 { + t.Fatalf("FAIL: zsh-syntax-highlighting not referenced in .zshrc (exit %d): %s", code, out) + } + t.Logf("PASS: zsh-syntax-highlighting found in .zshrc") +} + +// --- Mise --- + +// TestMise_DataDir -- session user's MISE_DATA_DIR must be ~/.local/share/mise, not /opt/mise. +func TestMise_DataDir(t *testing.T) { + c := startEnvContainer(t) + + out, code := asUser(t, c, "echo $MISE_DATA_DIR") + if code != 0 { + t.Fatalf("FAIL: could not read MISE_DATA_DIR (exit %d): %s", code, out) + } + + expected := fmt.Sprintf("/home/%s/.local/share/mise", hostUser) + if out != expected { + t.Errorf("FAIL: MISE_DATA_DIR=%q, want %q", out, expected) + } else { + t.Logf("PASS: MISE_DATA_DIR=%q", out) + } +} + +// TestMise_NodeViaUserShims -- node must be reachable through ~/.local/share/mise/shims. +func TestMise_NodeViaUserShims(t *testing.T) { + c := startEnvContainer(t) + + // Shim must live in ~/.local/share/mise/shims, not /opt/mise/shims. + shimPath, code := asUser(t, c, "which node") + if code != 0 { + t.Fatalf("FAIL: node not found on PATH (exit %d): %s", code, shimPath) + } + + expected := fmt.Sprintf("/home/%s/.local/share/mise/shims/node", hostUser) + if shimPath != expected { + t.Errorf("FAIL: node shim at %q, want %q", shimPath, expected) + } else { + t.Logf("PASS: node shim at %q", shimPath) + } + + // Confirm it actually runs. + out, code := asUser(t, c, "node --version") + if code != 0 { + t.Errorf("FAIL: node --version failed (exit %d): %s", code, out) + } else { + t.Logf("PASS: node --version: %s", out) + } +} + +// TestMise_UserInstallPreserved -- setup_mise_home must not overwrite a real dir +// with a symlink (user-installed version must be preserved). +func TestMise_UserInstallPreserved(t *testing.T) { + c := startEnvContainer(t) + + // Create a fake "user-installed" real directory for a non-existent version. + _, code := exec(t, c, []string{"bash", "-c", + "mkdir -p /home/" + hostUser + "/.local/share/mise/installs/node/99.99.99/bin && " + + "printf '#!/bin/sh\\necho v99.99.99\\n' > /home/" + hostUser + "/.local/share/mise/installs/node/99.99.99/bin/node && " + + "chmod +x /home/" + hostUser + "/.local/share/mise/installs/node/99.99.99/bin/node", + }) + if code != 0 { + t.Fatalf("FAIL: could not create fake user install") + } + + // Re-run the symlink setup logic (simulates what entrypoint does on restart). + _, code = exec(t, c, []string{"bash", "-c", ` + baked="/opt/mise" + user_mise="/home/` + hostUser + `/.local/share/mise" + for tool_dir in "$baked/installs"/*/; do + [ -d "$tool_dir" ] || continue + tool_name=$(basename "$tool_dir") + mkdir -p "$user_mise/installs/$tool_name" + for ver_dir in "$tool_dir"*/; do + [ -d "$ver_dir" ] || continue + ver_name=$(basename "$ver_dir") + dest="$user_mise/installs/$tool_name/$ver_name" + [ -d "$dest" ] && [ ! -L "$dest" ] && continue + ln -sfT "$ver_dir" "$dest" + done + done + `}) + if code != 0 { + t.Fatalf("FAIL: re-run of symlink setup failed") + } + + // The real directory must NOT have been replaced by a symlink. + out, code := exec(t, c, []string{"bash", "-c", + "test -L /home/" + hostUser + "/.local/share/mise/installs/node/99.99.99 && echo SYMLINK || echo REAL"}) + if code != 0 || strings.TrimSpace(out) != "REAL" { + t.Errorf("FAIL: user install was converted to symlink: %s", out) + } else { + t.Logf("PASS: user install preserved as real dir") + } +} + +// TestMise_DanglingSymlinkCleaned -- dangling symlinks in ~/.local/share/mise/installs/ +// must be removed by setup_mise_home. +func TestMise_DanglingSymlinkCleaned(t *testing.T) { + c := startEnvContainer(t) + + // Inject a dangling symlink pointing to a non-existent /opt/mise path. + _, code := exec(t, c, []string{"bash", "-c", + "mkdir -p /home/" + hostUser + "/.local/share/mise/installs/node && " + + "ln -s /opt/mise/installs/node/0.0.0-nonexistent " + + "/home/" + hostUser + "/.local/share/mise/installs/node/0.0.0-nonexistent", + }) + if code != 0 { + t.Fatalf("FAIL: could not inject dangling symlink") + } + + // Re-run the dangling-symlink cleanup logic from setup_mise_home. + _, code = exec(t, c, []string{"bash", "-c", ` + user_mise="/home/` + hostUser + `/.local/share/mise" + for tool in "$user_mise/installs"/*/; do + for link in "${tool%/}"/*; do + if [ -L "$link" ] && [ ! -e "$link" ]; then rm -f "$link"; fi + done + done + `}) + if code != 0 { + t.Fatalf("FAIL: cleanup logic failed (exit %d)", code) + } + + // The dangling symlink must be gone. + out, _ := exec(t, c, []string{"bash", "-c", + "test -L /home/" + hostUser + "/.local/share/mise/installs/node/0.0.0-nonexistent && echo EXISTS || echo CLEANED", + }) + if strings.TrimSpace(out) != "CLEANED" { + t.Errorf("FAIL: dangling symlink still present after cleanup") + } else { + t.Logf("PASS: dangling symlink cleaned up") + } +} + +// TestMise_NonInteractiveShell -- tools must be accessible via docker exec +// (non-interactive, non-login shell) through shims on PATH. +func TestMise_NonInteractiveShell(t *testing.T) { + c := startEnvContainer(t) + + out, code := asUser(t, c, "node --version") + if code != 0 { + t.Errorf("FAIL: node not accessible in non-interactive shell (exit %d): %s", code, out) + } else { + t.Logf("PASS: node accessible: %s", out) + } +} + +// TestMise_NoAsdfEnvVarsLeaked -- no ASDF_* environment variables should be +// present in the container after migration to mise. +func TestMise_NoAsdfEnvVarsLeaked(t *testing.T) { + c := startEnvContainer(t) + + out, code := asUser(t, c, "env | grep ^ASDF_ || true") + if code != 0 { + t.Fatalf("FAIL: env command failed (exit %d): %s", code, out) + } + + if strings.TrimSpace(out) != "" { + t.Errorf("FAIL: ASDF_* env vars leaked:\n%s", out) + } else { + t.Logf("PASS: no ASDF_* env vars") + } +} + +// TestMise_GlobalConfigEnvVar -- MISE_GLOBAL_CONFIG_FILE must point to a valid +// nix store path, not a file in $HOME. +func TestMise_GlobalConfigEnvVar(t *testing.T) { + c := startEnvContainer(t) + + out, code := asUser(t, c, "echo $MISE_GLOBAL_CONFIG_FILE") + if code != 0 || strings.TrimSpace(out) == "" { + t.Fatalf("FAIL: MISE_GLOBAL_CONFIG_FILE not set (exit %d): %s", code, out) + } + if !strings.HasPrefix(out, "/nix/store/") { + t.Errorf("FAIL: MISE_GLOBAL_CONFIG_FILE=%q, want /nix/store/... path", out) + } + + // The file must actually exist and be readable. + _, code = asUser(t, c, fmt.Sprintf("test -f %q", out)) + if code != 0 { + t.Errorf("FAIL: MISE_GLOBAL_CONFIG_FILE=%q does not exist or is not readable", out) + } else { + t.Logf("PASS: MISE_GLOBAL_CONFIG_FILE=%q (valid nix store path)", out) + } +} + +// TestMise_DefaultNpmPackagesEnvVar -- MISE_NODE_DEFAULT_PACKAGES_FILE must point +// to a valid nix store path. +func TestMise_DefaultNpmPackagesEnvVar(t *testing.T) { + c := startEnvContainer(t) + + out, code := asUser(t, c, "echo $MISE_NODE_DEFAULT_PACKAGES_FILE") + if code != 0 || strings.TrimSpace(out) == "" { + t.Fatalf("FAIL: MISE_NODE_DEFAULT_PACKAGES_FILE not set (exit %d): %s", code, out) + } + if !strings.HasPrefix(out, "/nix/store/") { + t.Errorf("FAIL: MISE_NODE_DEFAULT_PACKAGES_FILE=%q, want /nix/store/... path", out) + } + + // The file must actually exist and be readable. + _, code = asUser(t, c, fmt.Sprintf("test -f %q", out)) + if code != 0 { + t.Errorf("FAIL: MISE_NODE_DEFAULT_PACKAGES_FILE=%q does not exist or is not readable", out) + } else { + t.Logf("PASS: MISE_NODE_DEFAULT_PACKAGES_FILE=%q (valid nix store path)", out) + } +} + +// TestMise_NpmToolsAvailable -- npm-installed tools from /opt/npm-tools must work. +func TestMise_NpmToolsAvailable(t *testing.T) { + c := startEnvContainer(t) + + // npm itself must be available + out, code := asUser(t, c, "npm --version") + if code != 0 { + t.Fatalf("FAIL: npm not available (exit %d): %s", code, out) + } + t.Logf("PASS: npm version: %s", out) + + // A tool from /opt/npm-tools should be accessible + out, code = asUser(t, c, "which mcp-server-patchright 2>/dev/null || which npx 2>/dev/null") + if code != 0 { + t.Errorf("FAIL: no npm tools found on PATH (exit %d): %s", code, out) + } else { + t.Logf("PASS: npm tool found at: %s", out) + } +} + +// --- Git --- + +// TestGit_CommitUsesEnvIdentity starts a container with explicit git env vars, +// creates a commit, and verifies git log shows the configured identity. +func TestGit_CommitUsesEnvIdentity(t *testing.T) { + if testing.Short() { + t.Skip("skipping in short mode") + } + + c := startContainer(t, map[string]string{ + "HOST_USER": hostUser, + "APP_NAME": "test", + "GIT_AUTHOR_NAME": "Test Author", + "GIT_AUTHOR_EMAIL": "test@devcell.io", + "GIT_COMMITTER_NAME": "Test Committer", + "GIT_COMMITTER_EMAIL": "committer@devcell.io", + }) + + // Init repo, make a commit + out, code := asUser(t, c, ` + cd /tmp && + mkdir gittest && cd gittest && + git init && + touch file.txt && + git add file.txt && + git commit -m "test commit" && + git log -1 --format='%an|%ae|%cn|%ce' + `) + if code != 0 { + t.Fatalf("FAIL: git commit failed (exit %d):\n%s", code, out) + } + + // Parse last line of output (git log format) + lines := strings.Split(strings.TrimSpace(out), "\n") + logLine := lines[len(lines)-1] + parts := strings.Split(logLine, "|") + if len(parts) != 4 { + t.Fatalf("FAIL: unexpected git log format: %q", logLine) + } + + if parts[0] != "Test Author" { + t.Errorf("author name: want %q, got %q", "Test Author", parts[0]) + } + if parts[1] != "test@devcell.io" { + t.Errorf("author email: want %q, got %q", "test@devcell.io", parts[1]) + } + if parts[2] != "Test Committer" { + t.Errorf("committer name: want %q, got %q", "Test Committer", parts[2]) + } + if parts[3] != "committer@devcell.io" { + t.Errorf("committer email: want %q, got %q", "committer@devcell.io", parts[3]) + } + t.Logf("PASS: git identity = %s", logLine) +} + +// TestGit_CommitUsesGitConfig verifies that when no GIT_* env vars are set, +// git reads identity from ~/.config/git/config inside the container. +func TestGit_CommitUsesGitConfig(t *testing.T) { + if testing.Short() { + t.Skip("skipping in short mode") + } + + c := startContainer(t, map[string]string{ + "HOST_USER": hostUser, + "APP_NAME": "test", + }) + + // Write a git config file, init repo, commit, check identity + out, code := asUser(t, c, ` + mkdir -p ~/.config/git && + cat > ~/.config/git/config <<'GITCFG' +[user] + name = Config User + email = config@devcell.io +GITCFG + cd /tmp && + mkdir gitcfgtest && cd gitcfgtest && + git init && + touch file.txt && + git add file.txt && + git commit -m "config commit" && + git log -1 --format='%an|%ae' + `) + if code != 0 { + t.Fatalf("FAIL: git commit with config failed (exit %d):\n%s", code, out) + } + + lines := strings.Split(strings.TrimSpace(out), "\n") + logLine := lines[len(lines)-1] + parts := strings.Split(logLine, "|") + if len(parts) != 2 { + t.Fatalf("FAIL: unexpected git log format: %q", logLine) + } + + if parts[0] != "Config User" { + t.Errorf("author name: want %q, got %q", "Config User", parts[0]) + } + if parts[1] != "config@devcell.io" { + t.Errorf("author email: want %q, got %q", "config@devcell.io", parts[1]) + } + t.Logf("PASS: git identity from config = %s", logLine) +} + +// --- Persistent Home --- + +// TestPersistentHome_NixConf verifies that nix.conf is present in $HOME even +// when an empty .config/nix/ dir already exists from a previous session. +func TestPersistentHome_NixConf(t *testing.T) { + if testing.Short() { + t.Skip("skipping in short mode") + } + c := startContainerWithStaleHome(t) + + // nix.conf must exist and contain experimental-features. + out, code := asUser(t, c, "cat $HOME/.config/nix/nix.conf") + if code != 0 { + t.Fatalf("FAIL: nix.conf not found in $HOME/.config/nix/ (exit %d)\n"+ + "Root cause: 20-homedir.sh skips copy when dir exists (stale bind mount)", code) + } + if !strings.Contains(out, "experimental-features") { + t.Errorf("FAIL: nix.conf exists but missing experimental-features:\n%s", out) + } else { + t.Logf("PASS: nix.conf contains experimental-features") + } +} + +// TestPersistentHome_ToolVersions verifies that .tool-versions in $HOME is a +// valid plain file (not a dangling symlink to a GC'd nix store path). +func TestPersistentHome_ToolVersions(t *testing.T) { + if testing.Short() { + t.Skip("skipping in short mode") + } + c := startContainerWithStaleHome(t) + + // .tool-versions must be a readable file (not dangling symlink). + out, code := asUser(t, c, "cat $HOME/.tool-versions") + if code != 0 { + t.Fatalf("FAIL: .tool-versions not readable (exit %d)\n"+ + "Root cause: dangling symlink from previous home-manager generation", code) + } + // Must contain at least one tool line (e.g., "node 24.13.1"). + if !strings.Contains(out, "node") { + t.Errorf("FAIL: .tool-versions doesn't contain expected tools:\n%s", out) + } else { + t.Logf("PASS: .tool-versions readable with tools: %s", strings.ReplaceAll(out, "\n", ", ")) + } + + // Must NOT be a symlink (should be a plain file copy). + linkOut, code := exec(t, c, []string{"gosu", hostUser, "bash", "-lc", + "test -L $HOME/.tool-versions && echo SYMLINK || echo PLAIN"}) + if strings.Contains(linkOut, "SYMLINK") { + t.Errorf("FAIL: .tool-versions is still a symlink (should be plain file)") + } else { + t.Logf("PASS: .tool-versions is a plain file") + } + _ = code +} + +// TestPersistentHome_MiseConfig verifies that mise can read its global config +// despite a stale config.toml symlink in $HOME. +func TestPersistentHome_MiseConfig(t *testing.T) { + if testing.Short() { + t.Skip("skipping in short mode") + } + c := startContainerWithStaleHome(t) + + // The config file (via env var or direct path) must be readable. + out, code := asUser(t, c, "cat \"$MISE_GLOBAL_CONFIG_FILE\" 2>/dev/null || cat $HOME/.config/mise/config.toml") + if code != 0 { + t.Fatalf("FAIL: mise config not readable via env var or direct path (exit %d)", code) + } + t.Logf("PASS: mise config readable: %.80s...", out) + + // $HOME/.config/mise/config.toml must NOT be a dangling symlink. + out, code = exec(t, c, []string{"gosu", hostUser, "bash", "-lc", + "test -e $HOME/.config/mise/config.toml && echo OK || echo DANGLING"}) + if strings.Contains(out, "DANGLING") { + t.Errorf("FAIL: $HOME/.config/mise/config.toml is a dangling symlink\n" + + "Root cause: stale nix store symlink persisted on bind mount") + } else { + t.Logf("PASS: mise config.toml is not dangling") + } +} + +// TestPersistentHome_NoDanglingSymlinks is the catch-all persistence bug detector. +func TestPersistentHome_NoDanglingSymlinks(t *testing.T) { + if testing.Short() { + t.Skip("skipping in short mode") + } + c := startContainerWithStaleHome(t) + + // Find all dangling symlinks pointing to /nix/store under $HOME, + // excluding tmp/ (nix build artifacts). + out, code := exec(t, c, []string{"gosu", hostUser, "bash", "-lc", ` + found=0 + while IFS= read -r link; do + target=$(readlink "$link" 2>/dev/null) + case "$target" in /nix/store/*) + if [ ! -e "$link" ]; then + echo "DANGLING: $link -> $target" + found=1 + fi + ;; esac + done < <(find "$HOME" -maxdepth 4 -type l -not -path "*/tmp/*" 2>/dev/null) + exit $found + `}) + + if code != 0 { + t.Errorf("FAIL: dangling nix-store symlinks found in $HOME:\n%s\n"+ + "Root cause: stale symlinks from previous home-manager generation persist on bind mount", out) + } else { + t.Logf("PASS: no dangling nix-store symlinks in $HOME") + } +} + +// TestPersistentHome_FontConfig verifies fontconfig files are valid after +// entrypoint runs with stale $HOME. +func TestPersistentHome_FontConfig(t *testing.T) { + if testing.Short() { + t.Skip("skipping in short mode") + } + c := startContainerWithStaleHome(t) + + // Fontconfig conf.d files must resolve (not dangle). + out, code := exec(t, c, []string{"gosu", hostUser, "bash", "-lc", + "test -e $HOME/.config/fontconfig/conf.d/10-hm-fonts.conf && echo OK || echo DANGLING"}) + if strings.Contains(out, "DANGLING") { + t.Errorf("FAIL: fontconfig 10-hm-fonts.conf is dangling\n" + + "Root cause: stale nix store symlink persisted on bind mount") + } else { + t.Logf("PASS: fontconfig config resolves") + } + _ = code +} + +// TestPersistentHome_StarshipConfig verifies starship config is accessible +// regardless of stale $HOME state. +func TestPersistentHome_StarshipConfig(t *testing.T) { + if testing.Short() { + t.Skip("skipping in short mode") + } + c := startContainerWithStaleHome(t) + + // STARSHIP_CONFIG env var must point to a readable file. + out, code := asUser(t, c, `test -f "$STARSHIP_CONFIG" && echo OK || echo MISSING`) + if code != 0 || strings.Contains(out, "MISSING") { + t.Errorf("FAIL: STARSHIP_CONFIG not readable (exit %d, out: %s)", code, out) + } else { + t.Logf("PASS: STARSHIP_CONFIG readable") + } +} + +// --- Toolchain --- + +// TestClaude_CodeVersion -- claude CLI must be >= 2.1.74 (from nixpkgs-unstable). +func TestClaude_CodeVersion(t *testing.T) { + c := startEnvContainer(t) + + const minVersion = "v2.1.74" + + out, code := asUser(t, c, "claude --version") + if code != 0 { + t.Fatalf("FAIL: claude not available (exit %d): %s", code, out) + } + + // claude --version outputs e.g. "2.1.25 (Claude Code)" + ver := strings.Fields(out)[0] // "2.1.25" + semVer := "v" + ver // semver requires "v" prefix + + if !semver.IsValid(semVer) { + t.Fatalf("FAIL: could not parse claude version %q as semver", ver) + } + if semver.Compare(semVer, minVersion) < 0 { + t.Errorf("FAIL: claude version %s < minimum %s", ver, minVersion) + } else { + t.Logf("PASS: claude version %s >= %s", ver, minVersion) + } +} diff --git a/test/shell_test.go b/test/shell_test.go deleted file mode 100644 index 309952ae..00000000 --- a/test/shell_test.go +++ /dev/null @@ -1,127 +0,0 @@ -package container_test - -// shell_test.go — e2e tests for zsh + starship prompt configuration. -// -// Verifies that starship renders the configured prompt symbols correctly, -// both via `starship prompt` (direct) and via `unbuffer zsh` (full shell). -// -// Run: -// go test -v -run TestShell -timeout 120s ./... - -import ( - "strings" - "testing" -) - -// TestShellStarshipBinary verifies starship is installed and on PATH. -func TestShellStarshipBinary(t *testing.T) { - if testing.Short() { - t.Skip("skipping in short mode") - } - c := startEnvContainer(t) - out, code := asUser(t, c, "starship --version") - if code != 0 { - t.Fatalf("starship not found on PATH (exit %d): %s", code, out) - } - t.Logf("PASS: %s", strings.TrimSpace(out)) -} - -// TestShellStarshipConfigExists verifies the home-manager-generated config -// is present and contains the expected unicode character symbol. -func TestShellStarshipConfigExists(t *testing.T) { - if testing.Short() { - t.Skip("skipping in short mode") - } - c := startEnvContainer(t) - - out, code := asUser(t, c, "cat ~/.config/starship.toml") - if code != 0 { - t.Fatalf("starship.toml not found (exit %d): %s", code, out) - } - - // Verify the unicode symbol from shell.nix made it through nix string escaping - if !strings.Contains(out, "•") { - t.Errorf("FAIL: starship.toml missing • character symbol:\n%s", out) - } else { - t.Logf("PASS: starship.toml contains • symbol") - } - - if !strings.Contains(out, "add_newline = false") { - t.Errorf("FAIL: starship.toml missing add_newline setting:\n%s", out) - } -} - -// TestShellStarshipPromptRenders runs `starship prompt` directly and verifies -// the rendered output contains the configured unicode symbols. -// No PTY needed — starship prompt writes to stdout. -func TestShellStarshipPromptRenders(t *testing.T) { - if testing.Short() { - t.Skip("skipping in short mode") - } - c := startEnvContainer(t) - - // starship prompt renders the prompt string to stdout. - // Run from /tmp so directory module shows "tmp". - out, code := asUser(t, c, "cd /tmp && starship prompt") - if code != 0 { - t.Fatalf("starship prompt failed (exit %d): %s", code, out) - } - - if !strings.Contains(out, "•") { - t.Errorf("FAIL: starship prompt output missing • symbol: %q", out) - } else { - t.Logf("PASS: starship prompt rendered •: %q", out) - } -} - -// TestShellZshStarshipIntegration verifies the complete integration chain: -// zsh sources .zshrc → starship init is evaluated → starship prompt renders •. -// Uses `zsh -c 'source ~/.zshrc; starship prompt'` to test without PTY complexity. -func TestShellZshStarshipIntegration(t *testing.T) { - if testing.Short() { - t.Skip("skipping in short mode") - } - c := startEnvContainer(t) - - // Source .zshrc (which contains starship init) then render the prompt. - // This tests the full chain without needing a PTY. - out, code := asUser(t, c, `zsh -c 'source ~/.zshrc 2>/dev/null; starship prompt'`) - if code != 0 { - t.Fatalf("zsh + starship prompt failed (exit %d): %s", code, out) - } - - if !strings.Contains(out, "•") { - t.Errorf("FAIL: zsh→.zshrc→starship prompt missing • symbol: %q", out) - } else { - t.Logf("PASS: zsh→.zshrc→starship prompt rendered •: %q", out) - } -} - -// TestShellZshAutosuggestions verifies the zsh-autosuggestions plugin is loaded. -func TestShellZshAutosuggestions(t *testing.T) { - if testing.Short() { - t.Skip("skipping in short mode") - } - c := startEnvContainer(t) - - // Check if the autosuggestions plugin source file exists in the zshrc - out, code := asUser(t, c, "grep -l autosuggestions ~/.zshrc") - if code != 0 { - t.Fatalf("FAIL: zsh-autosuggestions not referenced in .zshrc (exit %d): %s", code, out) - } - t.Logf("PASS: zsh-autosuggestions found in .zshrc") -} - -// TestShellZshSyntaxHighlighting verifies the syntax-highlighting plugin is loaded. -func TestShellZshSyntaxHighlighting(t *testing.T) { - if testing.Short() { - t.Skip("skipping in short mode") - } - c := startEnvContainer(t) - - out, code := asUser(t, c, "grep -l syntax-highlighting ~/.zshrc") - if code != 0 { - t.Fatalf("FAIL: zsh-syntax-highlighting not referenced in .zshrc (exit %d): %s", code, out) - } - t.Logf("PASS: zsh-syntax-highlighting found in .zshrc") -} diff --git a/test/testdata/devcell-config-simple/devcell/Dockerfile b/test/testdata/devcell-config-simple/devcell/Dockerfile new file mode 100644 index 00000000..3e010174 --- /dev/null +++ b/test/testdata/devcell-config-simple/devcell/Dockerfile @@ -0,0 +1,48 @@ +#FROM ghcr.io/dimmkirr/devcell:ultimate-local +FROM ghcr.io/dimmkirr/devcell:base-local + +# Stamp user image version (commit SHA + build date) +ARG GIT_COMMIT=unknown +USER 0 +RUN mkdir -p /etc/devcell && \ + echo "${GIT_COMMIT}-$(date -u +%Y%m%dT%H%M%SZ)" > /etc/devcell/user-image-version +USER devcell + +# Copy flake + lock. The glob (flake.*) makes flake.lock optional — first build +# won't have one yet; nix creates it and subsequent builds reuse it, pinning +# inputs so the base image's /nix/store paths are found without re-downloading. +COPY --chown=devcell:usergroup nixhome/ /opt/devcell/.config/devcell/nixhome/ +COPY --chown=devcell:usergroup flake.* /opt/devcell/.config/devcell/ + +# Activate the nix profile. +# NIX_REFRESH is set to "--refresh" by `cell build --no-cache` to bust nix flake cache. +ARG NIX_REFRESH="" +RUN ARCH=$(uname -m) && \ + [ "$ARCH" = "aarch64" ] && ARCH_SUFFIX="-aarch64" || ARCH_SUFFIX="" && \ + home-manager switch \ + --flake "/opt/devcell/.config/devcell#devcell-ultimate${ARCH_SUFFIX}" \ + --impure $NIX_REFRESH + +# Install language runtimes via mise (separate layer — cached when only nix config changes). +RUN (mkdir -p /opt/mise 2>/dev/null || sudo mkdir -p /opt/mise) && \ + cd /opt/devcell && MISE_DATA_DIR=/opt/mise MISE_YES=1 mise install && \ + for tool_dir in /opt/mise/installs/*/; do \ + tool=$(basename "$tool_dir"); \ + version_dir=$(ls -1d "${tool_dir}"*/ 2>/dev/null | head -1); \ + if [ -n "$version_dir" ]; then ln -sfT "$version_dir" "/opt/mise/$tool"; fi; \ + done + +# Add mise-installed tool bins to PATH via stable symlinks +ENV PATH="/opt/mise/node/bin:/opt/mise/go/bin:${PATH}" + +# Agent CLI tools (claude, codex, etc.) +COPY --chown=devcell:usergroup package.json /opt/npm-tools/ +RUN cd /opt/npm-tools && npm install +ENV PATH="/opt/npm-tools/node_modules/.bin:${PATH}" + +# Python tools +COPY --chown=devcell:usergroup pyproject.toml /opt/python-tools/ +SHELL ["/bin/bash", "-c"] +RUN cd /opt/python-tools && uv sync +SHELL ["/bin/sh", "-c"] +ENV PATH="/opt/python-tools/.venv/bin:${PATH}" diff --git a/test/testdata/devcell-config-simple/devcell/README.md b/test/testdata/devcell-config-simple/devcell/README.md new file mode 100644 index 00000000..5e7bf039 --- /dev/null +++ b/test/testdata/devcell-config-simple/devcell/README.md @@ -0,0 +1,4 @@ +# Testdata +``` +XDG_CONFIG_HOME="/Users/dmitry/dev/dimmkirr/devcell/test/testdata/devcell-config-simple" DEVCELL_NIXHOME_PATH=/Users/dmitry/dev/dimmkirr/devcell/nixhome cell build --debug +``` diff --git a/test/testdata/devcell-config-simple/devcell/devcell.toml b/test/testdata/devcell-config-simple/devcell/devcell.toml new file mode 100644 index 00000000..5ed8faad --- /dev/null +++ b/test/testdata/devcell-config-simple/devcell/devcell.toml @@ -0,0 +1,66 @@ +# ~/.config/devcell/devcell.toml +# Global DevCell configuration. Project-level overrides go in /.devcell.toml + +[cell] +# Override the default image tag. +# Available tags: latest-base, latest-go, latest-node, latest-python, +# latest-electronics, latest-fullstack, latest-ultimate (default) +# image_tag = "latest-go" +# Enable GUI (Xvfb + VNC + browser). Injects DEVCELL_GUI_ENABLED=true. +gui = true +# Timezone (IANA format). If omitted, inherits host $TZ. +# timezone = "Europe/Prague" + +[claude] +# Route Claude Code through a local ollama instance (Anthropic API compat). +# Equivalent to: cell claude --ollama +# use_ollama = false + +# [git] +# Git identity forwarded into the container. +# If omitted and no GIT_AUTHOR_* env vars are set, ~/.config/git/config is mounted read-only instead. +# author_name = "Your Name" +# author_email = "you@example.com" +# committer_name = "Your Name" # defaults to author_name if omitted +# committer_email = "you@example.com" # defaults to author_email if omitted + +[env] +# Extra environment variables forwarded into the container. +# GITHUB_TOKEN = "ghp_xxx" +# ANTHROPIC_API_KEY = "sk-ant-xxx" + +[mise] +# mise runtime settings forwarded as MISE_ env vars. +# See: https://mise.jdx.dev/configuration.html +# trusted_config_paths = "/" +# idiomatic_version_file = "true" + +# [[volumes]] +# Extra volume mounts appended to docker run. +# mount = "~/work/secrets:/run/secrets:ro" +# +# [[volumes]] +# mount = "~/.ssh:/home/user/.ssh:ro" + +[models] +default = "ollama/qwen3-coder-next:latest" + +[models.providers.ollama] +models = ["qwen3-coder-next:latest", "qwen3-coder-next:latest-128k", "qwen3-coder:30b", "qwen3-coder:30b-128k", "glm-4.7-flash:latest", "deepseek-r1:32b", "qwen2.5-coder:32b", "qwen3:8b", "qwen3:8b-128k", "qwen3:0.6b"] + + +[packages.npm] +# npm packages installed in the container. Edit and run 'cell build'. +# NOTE: claude-code and opencode are managed via nix (nixhome/modules/base.nix). +# Only add packages here that are NOT in nixhome. +"@openai/codex" = "^0.96.0" +"@opentofu/opentofu-mcp-server" = "^0.1.5" +"patchright-mcp" = "^0.0.68" +"@playwright/test" = "^1.57.0" +"@slidev/cli" = "^52.11.0" + +[packages.python] +# Python packages installed in the container. Edit and run 'cell build'. +"pre-commit" = "*" +# "httpie" = "*" +# "pipx" = "*" diff --git a/test/testdata/devcell-config-simple/devcell/flake.nix b/test/testdata/devcell-config-simple/devcell/flake.nix new file mode 100644 index 00000000..e10569e5 --- /dev/null +++ b/test/testdata/devcell-config-simple/devcell/flake.nix @@ -0,0 +1,15 @@ +{ + description = "DevCell user profile — customise and run 'cell build'"; + + # Follows main branch by default. To pin a specific release: + # inputs.devcell.url = "github:DimmKirr/devcell/v1.0.0?dir=nixhome"; + # To use your own nixhome fork: + # inputs.devcell.url = "github:yourusername/nixhome"; + inputs.devcell.url = "path:./nixhome"; + + outputs = { self, devcell, ... }: { + # Re-export upstream home-manager configurations. + # To add your own packages, override or extend a configuration here. + homeConfigurations = devcell.homeConfigurations; + }; +} diff --git a/test/testdata/devcell-config-simple/devcell/homedir/.config/starship.toml b/test/testdata/devcell-config-simple/devcell/homedir/.config/starship.toml new file mode 100644 index 00000000..a4f86070 --- /dev/null +++ b/test/testdata/devcell-config-simple/devcell/homedir/.config/starship.toml @@ -0,0 +1,30 @@ +# Starship prompt configuration — https://starship.rs/config/ +# This file is scaffolded by `cell init` into homedir/.config/starship.toml +# and copied into the container at ~/.config/starship.toml by the entrypoint. +# Edit to customize your prompt per-project; run `cell build` is NOT needed. + +format = "$directory$git_branch$git_status$character" +right_format = "$cmd_duration" +add_newline = false + +[character] +success_symbol = "[•](bold green)" +error_symbol = "[•](bold red)" + +[directory] +style = "bold cyan" +truncation_length = 3 +truncate_to_repo = true + +[git_branch] +format = "[$branch]($style) " +style = "bold purple" + +[git_status] +format = "([$all_status$ahead_behind]($style) )" +style = "bold yellow" + +[cmd_duration] +min_time = 2_000 +format = "[$duration]($style)" +style = "bold yellow" diff --git a/test/testdata/devcell-config-simple/devcell/package.json b/test/testdata/devcell-config-simple/devcell/package.json new file mode 100644 index 00000000..3a41caea --- /dev/null +++ b/test/testdata/devcell-config-simple/devcell/package.json @@ -0,0 +1,12 @@ +{ + "dependencies": { + "@openai/codex": "^0.96.0", + "@opentofu/opentofu-mcp-server": "^0.1.5", + "@playwright/test": "^1.57.0", + "@slidev/cli": "^52.11.0", + "patchright-mcp": "^0.0.68" + }, + "name": "devcell-tools", + "private": true, + "version": "1.0.0" +} diff --git a/test/testdata/devcell-config-simple/devcell/pyproject.toml b/test/testdata/devcell-config-simple/devcell/pyproject.toml new file mode 100644 index 00000000..8d389599 --- /dev/null +++ b/test/testdata/devcell-config-simple/devcell/pyproject.toml @@ -0,0 +1,7 @@ +[project] +name = "devcell-tools" +version = "1.0.0" +requires-python = ">=3.13" +dependencies = [ + "pre-commit", +] diff --git a/test/testdata/devcell-config-simple/devcell/xrdp/cert.pem b/test/testdata/devcell-config-simple/devcell/xrdp/cert.pem new file mode 100644 index 00000000..0da99ee5 --- /dev/null +++ b/test/testdata/devcell-config-simple/devcell/xrdp/cert.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDBTCCAe2gAwIBAgIUAaJexYor1b6djH2nyW8aKDESTcYwDQYJKoZIhvcNAQEL +BQAwEjEQMA4GA1UEAwwHZGV2Y2VsbDAeFw0yNjAzMTIxMjEzMjNaFw0yNzAzMTIx +MjEzMjNaMBIxEDAOBgNVBAMMB2RldmNlbGwwggEiMA0GCSqGSIb3DQEBAQUAA4IB +DwAwggEKAoIBAQCsiwiEZEorLqHKmO9wXZ+tL5OQOCPNsgOODjlsQs85kA/msWCc ++pC3fbKuYWlZJuoAxi7mcC4qNYHabNDmNWvsK+qqzgTMtpD35AsrBc5G9bUBf0m9 +Zc+XxYX7nvLwrSJQsI+wZNbKBByBGQjWaVsIJoPiJzerzTyIUuRUWUT/gbGDx1j4 +uEZkjL9JQEJlXFLHfNSqkly0kIwPk7eHTnzJz6K260CuqMMUqLKsbdii8cHA5rCh +cFwwMGzuAjIpRc9gJ1IV+KmL60klSu7zfMPt6suh72Dlc41e2naK9wAVni+PTSlA +G41HCJ0rDtUPcnwTWUinSb01alflyz1cgRdnAgMBAAGjUzBRMB0GA1UdDgQWBBR0 +V7pJxHCS91x6oZHvmi9//fRvBjAfBgNVHSMEGDAWgBR0V7pJxHCS91x6oZHvmi9/ +/fRvBjAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQBQWr7hiEjO +Kp1eKC3GlX2iq0hZlL+A3qm64hoTj+lKpMuLbz8P7MGuXC0OYE/8ognMmqH3TVm7 +9g1N8CDOwKTQ63svQ7nPunfxXMDwLs0eGToDFhKVd2/gS08dbZyDM/ovxj8wVz/e +Tu93UO4o3NwpVZ9Fk890UcIfT5uEwMr6UANiklbbT7nZWB906BwW4uw5TxUXHtp6 +gIuCKdDpD+dkIKgNt2lVU6WyE8WlSzMuPfbHC4IVSSb7qaxCPugGA6SpYIb4zFnm +4cCqBHEspXcshbSp+QJribBUw8KsW0mq6cAPbCFqnBYQqKCxTjEuI9IasUR+dvai +4S/pMXrbxhbn +-----END CERTIFICATE----- diff --git a/test/testdata/devcell-config-simple/devcell/xrdp/key.pem b/test/testdata/devcell-config-simple/devcell/xrdp/key.pem new file mode 100644 index 00000000..baa1cada --- /dev/null +++ b/test/testdata/devcell-config-simple/devcell/xrdp/key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCsiwiEZEorLqHK +mO9wXZ+tL5OQOCPNsgOODjlsQs85kA/msWCc+pC3fbKuYWlZJuoAxi7mcC4qNYHa +bNDmNWvsK+qqzgTMtpD35AsrBc5G9bUBf0m9Zc+XxYX7nvLwrSJQsI+wZNbKBByB +GQjWaVsIJoPiJzerzTyIUuRUWUT/gbGDx1j4uEZkjL9JQEJlXFLHfNSqkly0kIwP +k7eHTnzJz6K260CuqMMUqLKsbdii8cHA5rChcFwwMGzuAjIpRc9gJ1IV+KmL60kl +Su7zfMPt6suh72Dlc41e2naK9wAVni+PTSlAG41HCJ0rDtUPcnwTWUinSb01alfl +yz1cgRdnAgMBAAECggEAAQ17bfyt+9PLK+Zr9jZeri/58/7Lr8ppaCP2aLktn3nI +Bw3HpkgxutTa/WJdwgFRRdOaX1CJj5CyVp1PAaCLedjjaSBcT3gWVqopq/JbSaaK +cKm7TvBV6GSi8R/ioTeahn3VRUEQPbzxruidW7Av3H2zqfJi+LrbUYTunK8gvc2l +BfOmrNoh4sRzUyCVGnElPUJk7PbdlyErOx/++K3/9+DKDXSbGxlnLXaDi6GX/3o6 +VCowDPDUWiRY4h6+GEv9ie3IDNDuE6bNszHBBveN7vRJJdRTeiZXaqdMgdMddLDO +jcY8ypcXUtBRujT6FlgkRnZu5FdvRFI90/KknubGOQKBgQDd/hiEbJRxoqzBVMmH +lUedVyYMPRxzVAT1f0RuI0d1y3aHN02ThHpEMJR3UjyNUVuZBzLmZKH4lvw11Tim +fiQ8b3wOhqPfLK8ch5NKR4NcTkm/EgesuzXj89IvCaYxT+0lVJ5xOrE8TsQ8fM99 +KdCpQxVbiVq2ujB6WCYtpdMSzwKBgQDG+axLa+4AyVfFBzKbbJLU+sO5JthDqGjI +bE85l3sIx3C8J8xFGMgyJQzdCJN+EU8AB83nLkN6IfyaQJ3eqh27HX7+B0mMNsLw +U16BGieRV5DDf30CgaWOURH+b4X70m94Mkd8sYJGXA6s9lwuLkMzbVFAY57Q2vDH +jmkLvJ236QKBgQCqGJBgp3H2wFburDDfGpsNDEGzDwBFvhMuqJ2eZpnSwFvJr65Z +YiDtLuwToYeUIMq2rrVpo6IYAFiwRvhpWi4ZwLKlzrSxxGNI0Pki6TjV+Ya4SPBo +geoBCEn8kMAu+LcU64mb6QT02MW0M04o8WlYyHYM9Wn0qSWMGQ7n2y+oMQKBgBIW +xKkeqMZ9h3MsEle66SZfnceJVoPgHZaK8jDkKpVDvGX+oD1v4VNdtuupSe9eF3aD +Px7TP+Msj9BeK3wAry9BoTfq5a7Pk61Rhvfprssx0MFVJ8JAkxJ7IE2nSeD44pVk +zMbzREqtN9BVnU+9ePKF76sQ43FNVjJuKfKdNSFxAoGBAIfFWfGQOW+HI+sK/9Sw +xpZBdF7fUK7RWs4leQQAhwZsyubQVh3/39j0jUlffZXefpSXa6or7g0/7vIag8PJ +lh8dIN5uhBrYT+RLKMNMi+bu/B1bURPzBy8GxiCZRDMGBuq8LsvwYDt7BJWe0WlU +ul2I1MmhcSjwQ1uerffQ3BBv +-----END PRIVATE KEY----- diff --git a/test/testdata/wallpaper-4corners.png b/test/testdata/wallpaper-4corners.png new file mode 100644 index 00000000..60b17b8b Binary files /dev/null and b/test/testdata/wallpaper-4corners.png differ diff --git a/test/tmux_test.go b/test/tmux_test.go deleted file mode 100644 index 4ecef0ce..00000000 --- a/test/tmux_test.go +++ /dev/null @@ -1,24 +0,0 @@ -package container_test - -// tmux_test.go — smoke test: tmux and tmuxp must be available in the container. - -import ( - "strings" - "testing" -) - -// TestTmuxBinaries — tmux and tmuxp must be on PATH for the session user. -func TestTmuxBinaries(t *testing.T) { - c := startEnvContainer(t) - for _, tool := range []struct{ bin, args string }{ - {"tmux", "-V"}, - {"tmuxp", "--version"}, - } { - out, code := asUser(t, c, tool.bin+" "+tool.args) - if code != 0 { - t.Errorf("FAIL %s not available (exit %d): %s", tool.bin, code, out) - } else { - t.Logf("PASS %s: %s", tool.bin, strings.SplitN(out, "\n", 2)[0]) - } - } -} diff --git a/test/toolchain_test.go b/test/toolchain_test.go deleted file mode 100644 index b60985bd..00000000 --- a/test/toolchain_test.go +++ /dev/null @@ -1,33 +0,0 @@ -package container_test - -// toolchain_test.go — language runtime and dev tool availability tests. - -import ( - "strings" - "testing" -) - -// TestToolchain — key dev tools must be on PATH for the session user. -func TestToolchain(t *testing.T) { - c := startEnvContainer(t) - - tools := []struct { - bin string - versionCmd string - }{ - {"go", "go version"}, - {"node", "node --version"}, - {"terraform", "terraform version"}, - {"tofu", "tofu version"}, - {"home-manager", "home-manager --version"}, - } - - for _, tool := range tools { - out, code := asUser(t, c, tool.versionCmd) - if code != 0 { - t.Errorf("FAIL %s: not available (exit %d): %s", tool.bin, code, out) - } else { - t.Logf("PASS %s: %s", tool.bin, strings.SplitN(out, "\n", 2)[0]) - } - } -} diff --git a/test/vagrant/Vagrantfile b/test/vagrant/Vagrantfile new file mode 100644 index 00000000..568dc0a4 --- /dev/null +++ b/test/vagrant/Vagrantfile @@ -0,0 +1,47 @@ +# E2E install test: clean Debian Linux VM via QEMU. +# Validates: Homebrew install → cell claude --version → image pull → version output. +# +# Usage: +# vagrant up --provider=qemu +# vagrant destroy -f +# +# Prerequisites: +# vagrant plugin install vagrant-qemu +# +# Environment variables: +# CELL_VERSION — devcell version to test (default: 0.0.0) + +CELL_VERSION = ENV["CELL_VERSION"] || "0.0.0" + +Vagrant.configure("2") do |config| + config.vm.box = "generic/debian12" + config.vm.hostname = "devcell-e2e" + + # SSH over QEMU user-mode networking can be slow + config.vm.boot_timeout = 600 + config.ssh.connect_timeout = 60 + config.ssh.insert_key = false + + # Auto-detect QEMU share dir (firmware, EFI vars) — works with Nix and Homebrew installs + qemu_bin = `which qemu-system-aarch64 2>/dev/null`.strip + qemu_share = if !qemu_bin.empty? + real = `realpath #{qemu_bin}`.strip + File.join(File.dirname(File.dirname(real)), "share", "qemu") + else + "/opt/homebrew/share/qemu" + end + + config.vm.provider "qemu" do |qe| + qe.memory = "4096" + qe.cpus = 2 + qe.ssh_port = 50022 + qe.qemu_dir = qemu_share + end + + # Forward nothing — this is a disposable test VM + + config.vm.provision "shell", + path: "test-install.sh", + env: { "CELL_VERSION" => CELL_VERSION }, + privileged: false +end diff --git a/test/vagrant/test-install.sh b/test/vagrant/test-install.sh new file mode 100755 index 00000000..0037c3ed --- /dev/null +++ b/test/vagrant/test-install.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# E2E install test: validates full new-user pipeline on a clean Linux VM. +# +# 1. Install Homebrew (Linuxbrew) +# 2. Install devcell via Homebrew cask +# 3. Run `cell claude --version` and assert version output +# +# Parameterized via CELL_VERSION env var (default: 0.0.0). +set -euo pipefail + +CELL_VERSION="${CELL_VERSION:-0.0.0}" + +echo "=== E2E Install Test (cell v${CELL_VERSION}) ===" + +# ---------- 1. Ensure Docker is available ---------- +if ! command -v docker &>/dev/null; then + echo "--- Installing Docker ---" + sudo apt-get update -qq + sudo apt-get install -y -qq ca-certificates curl gnupg + sudo install -m 0755 -d /etc/apt/keyrings + curl -fsSL https://download.docker.com/linux/debian/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg + sudo chmod a+r /etc/apt/keyrings/docker.gpg + echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \ + sudo tee /etc/apt/sources.list.d/docker.list > /dev/null + sudo apt-get update -qq + sudo apt-get install -y -qq docker-ce docker-ce-cli containerd.io + sudo usermod -aG docker vagrant +fi + +echo "Docker: $(docker --version)" + +# ---------- 2. Install Homebrew ---------- +if ! command -v brew &>/dev/null; then + echo "--- Installing Homebrew ---" + NONINTERACTIVE=1 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" + # Add to PATH for this session + eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)" +fi + +echo "Homebrew: $(brew --version | head -1)" + +# ---------- 3. Install devcell ---------- +echo "--- Installing devcell (cask devcell-dev@${CELL_VERSION}) ---" +brew install --cask "DimmKirr/tap/devcell-dev@${CELL_VERSION}" + +CELL_BIN="$(brew --prefix)/bin/cell" +if [ ! -x "$CELL_BIN" ]; then + echo "FAIL: cell binary not found at $CELL_BIN" + exit 1 +fi + +echo "cell binary: $CELL_BIN" + +# ---------- 4. Run cell claude --version ---------- +echo "--- Running: cell claude --version ---" + +# cell prints version to stderr; capture both streams +OUTPUT=$("$CELL_BIN" claude --version 2>&1) || true + +echo "$OUTPUT" + +# ---------- 5. Assert version output ---------- +# Expected pattern: "cell v0.0.0-..." or "cell 0.0.0-..." +if echo "$OUTPUT" | grep -qE "cell\s+v?[0-9]+\.[0-9]+\.[0-9]+"; then + echo "" + echo "=== PASS: cell claude --version returned a valid version ===" +else + echo "" + echo "=== FAIL: version string not found in output ===" + exit 1 +fi diff --git a/test/vnc_test.go b/test/vnc_test.go deleted file mode 100644 index 41b3e976..00000000 --- a/test/vnc_test.go +++ /dev/null @@ -1,212 +0,0 @@ -package container_test - -// vnc_test.go — Tests for VNC server availability and port publishing. -// -// Covers two bugs in the `task vnc` flow: -// Bug 1: `docker compose port cell 5900` matches any running session for -// service 'cell' — ambiguous when multiple sessions are live. -// Fix: `docker port 5900` targets the exact container. -// Bug 2: EXT_VNC_PORT can resolve to a privileged port (<1024) when -// PORT_PREFIX is a small digit (TMUX_PANE %0–%9, no SESSION_PORT_PREFIX). -// -// Run against the GUI-capable image (base-gui or ultimate): -// DEVCELL_TEST_IMAGE=ghcr.io/dimmkirr/devcell:latest-ultimate go test -v -run TestVnc ./... - -import ( - "context" - osexec "os/exec" - "strings" - "testing" - "time" - - "github.com/testcontainers/testcontainers-go" - "github.com/testcontainers/testcontainers-go/wait" -) - -// startVncContainer starts a container with DEVCELL_GUI_ENABLED=true and -// publishes port 5900/tcp to a random host port. Waits until x11vnc is -// reachable from the test host (Xvfb + fluxbox each sleep 1 in entrypoint). -// -// Note: ss/netstat/nc are absent in this image — use /proc/net or -// wait.ForListeningPort (probes from outside via the published host port). -// skipIfNoGUI skips VNC tests when the image lacks GUI binaries (e.g. nix-only image). -func skipIfNoGUI(t *testing.T, c testcontainers.Container) { - t.Helper() - _, code := exec(t, c, []string{"sh", "-c", "command -v x11vnc"}) - if code != 0 { - t.Skip("skipping: image lacks x11vnc (nix-only image without GUI support)") - } -} - -// probeGUI starts a lightweight container to check for GUI support, then skips if absent. -func probeGUI(t *testing.T) { - t.Helper() - c := startEnvContainer(t) - skipIfNoGUI(t, c) -} - -func startVncContainer(t *testing.T) testcontainers.Container { - t.Helper() - ctx := context.Background() - req := testcontainers.ContainerRequest{ - Image: image(), - ExposedPorts: []string{"5900/tcp"}, - Env: map[string]string{ - "HOST_USER": hostUser, - "APP_NAME": "test", - "DEVCELL_GUI_ENABLED": "true", - }, - User: "0", - Cmd: []string{"tail", "-f", "/dev/null"}, - // Check /proc/net/tcp for port 5900 (0x170C) in LISTEN state (0A). - // ForListeningPort requires host→container TCP which is unreliable in CI; - // /proc/net is always available without ss/nc. - // entrypoint: Xvfb (sleep 1) → fluxbox (sleep 1) → x11vnc → allow 60s. - WaitingFor: wait.ForExec([]string{"sh", "-c", - "grep -qi 170C /proc/net/tcp6 /proc/net/tcp 2>/dev/null && grep -qi ' 0A ' /proc/net/tcp6 /proc/net/tcp 2>/dev/null"}). - WithStartupTimeout(60 * time.Second), - } - c, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ - ContainerRequest: req, - Started: true, - }) - if err != nil { - t.Fatalf("start VNC container: %v", err) - } - t.Cleanup(func() { _ = c.Terminate(ctx) }) - return c -} - -// TestVncX11vncOnPath — x11vnc binary must be on PATH for the session user. -// Does not require DEVCELL_GUI_ENABLED; validates the package is installed in the image. -func TestVncX11vncOnPath(t *testing.T) { - c := startEnvContainer(t) - skipIfNoGUI(t, c) - out, code := asUser(t, c, "command -v x11vnc") - if code != 0 { - t.Fatalf("FAIL: x11vnc not on PATH for session user (exit %d)", code) - } - t.Logf("PASS: %s", out) -} - -// TestVncListensOn5900 — with DEVCELL_GUI_ENABLED=true, x11vnc must bind port 5900. -// Validates the entrypoint GUI stack: Xvfb → fluxbox → x11vnc all start cleanly. -// -// ss/netstat/nc are absent in this image; uses /proc/net/tcp6 and /proc/net/tcp directly. -// Port 5900 = 0x170C; LISTEN state = 0A. -func TestVncListensOn5900(t *testing.T) { - probeGUI(t) - c := startVncContainer(t) // already waited for port to be reachable - // Verify x11vnc process is alive - _, code := exec(t, c, []string{"pgrep", "x11vnc"}) - if code != 0 { - t.Fatalf("FAIL: x11vnc process not found (exit %d)", code) - } - // Verify the socket is in LISTEN state via /proc/net (always available, no ss/netstat needed) - out, code := exec(t, c, []string{"sh", "-c", - "grep -i 170C /proc/net/tcp6 /proc/net/tcp 2>/dev/null | grep ' 0A '"}) - if code != 0 || !strings.Contains(strings.ToUpper(out), "170C") { - t.Errorf("FAIL: port 5900 (0x170C) not in LISTEN state in /proc/net:\n%s", out) - } else { - t.Logf("PASS: x11vnc listening on :5900\n%s", out) - } -} - -// TestVncPortPublishedToHost — the published 5900/tcp must map to a non-privileged host port. -// Analogous to `${EXT_VNC_PORT}:5900` in compose.yml; reproduces Bug 2 if the mapping -// fails (Docker refuses to bind privileged ports without root). -func TestVncPortPublishedToHost(t *testing.T) { - probeGUI(t) - c := startVncContainer(t) - ctx := context.Background() - mapped, err := c.MappedPort(ctx, "5900/tcp") - if err != nil { - t.Fatalf("FAIL: no mapped port for 5900/tcp: %v", err) - } - port := mapped.Int() - if port < 1024 || port > 65535 { - t.Errorf("FAIL: mapped port %d is outside unprivileged range [1024,65535]", port) - } else { - t.Logf("PASS: 5900/tcp → host port %d", port) - } -} - -// TestVncDynamicResolution — xrandr resolution change must be picked up by x11vnc. -// Verifies that Xvfb supports RandR and x11vnc (with -xrandr) reflects the new size. -func TestVncDynamicResolution(t *testing.T) { - probeGUI(t) - c := startVncContainer(t) - - // Default resolution should be 1920x1080 - out, code := exec(t, c, []string{"sh", "-c", "DISPLAY=:99 xrandr 2>&1"}) - if code != 0 { - t.Fatalf("xrandr failed (exit %d): %s", code, out) - } - if !strings.Contains(out, "1920x1080") { - t.Fatalf("expected default 1920x1080 in xrandr output: %s", out) - } - t.Logf("PASS: default resolution is 1920x1080") - - // Add a new mode and switch to it - _, code = exec(t, c, []string{"sh", "-c", - "DISPLAY=:99 xrandr --newmode 2560x1440 0 2560 2560 2560 2560 1440 1440 1440 1440 2>/dev/null; " + - "DISPLAY=:99 xrandr --addmode screen 2560x1440 2>/dev/null; " + - "DISPLAY=:99 xrandr -s 2560x1440 2>/dev/null"}) - if code != 0 { - t.Skipf("xrandr mode change not supported (Xvfb RANDR is limited to initial resolution; would need Xvnc for dynamic resize) (exit %d)", code) - } - - // Verify new resolution - out, code = exec(t, c, []string{"sh", "-c", "DISPLAY=:99 xrandr 2>&1"}) - if code != 0 { - t.Fatalf("xrandr check failed (exit %d): %s", code, out) - } - if !strings.Contains(out, "2560x1440") || !strings.Contains(out, "*") { - t.Errorf("expected 2560x1440 to be active resolution: %s", out) - } else { - t.Logf("PASS: resolution changed to 2560x1440") - } -} - -// TestVncDockerPortByName — `docker port 5900` must return the exact -// host port for the named container. This is the fix for Bug 1 in Taskfile.yml `vnc` task: -// -// BEFORE (bug): docker compose port cell 5900 — matches any session for service 'cell' -// AFTER (fix): docker port 5900 — unambiguous, exact container -// -// Runs `docker port` from the test host (same Docker socket testcontainers uses) and -// cross-checks the result against testcontainers' own MappedPort. -func TestVncDockerPortByName(t *testing.T) { - probeGUI(t) - c := startVncContainer(t) - ctx := context.Background() - - name, err := c.Name(ctx) - if err != nil { - t.Fatalf("FAIL: could not get container name: %v", err) - } - name = strings.TrimPrefix(name, "/") - - out, err := osexec.Command("docker", "port", name, "5900").Output() - if err != nil { - t.Fatalf("FAIL: 'docker port %s 5900' failed: %v\nEnsure the Docker socket is accessible from the test runner.", name, err) - } - // Output format per line: "0.0.0.0:" or "[::]:" — port is after last colon. - firstLine := strings.SplitN(strings.TrimSpace(string(out)), "\n", 2)[0] - lastColon := strings.LastIndex(firstLine, ":") - if lastColon < 0 { - t.Fatalf("FAIL: unexpected 'docker port' output: %q", firstLine) - } - hostPort := firstLine[lastColon+1:] - - mapped, err := c.MappedPort(ctx, "5900/tcp") - if err != nil { - t.Fatalf("FAIL: could not get testcontainers MappedPort: %v", err) - } - want := mapped.Port() - if hostPort != want { - t.Errorf("FAIL: 'docker port %s 5900' → %q, want %q", name, hostPort, want) - } else { - t.Logf("PASS: 'docker port %s 5900' → %s (matches MappedPort)", name, hostPort) - } -} diff --git a/test/workflow_test.go b/test/workflow_test.go deleted file mode 100644 index 1fa157d9..00000000 --- a/test/workflow_test.go +++ /dev/null @@ -1,242 +0,0 @@ -package container_test - -import ( - "bytes" - "os" - osexec "os/exec" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/DimmKirr/devcell/internal/scaffold" - "github.com/creack/pty" -) - -// TestBaseImage validates base image capabilities via direct docker run. -// CI runs this with DEVCELL_TEST_IMAGE pointing to the base image. -func TestBaseImage(t *testing.T) { - if testing.Short() { - t.Skip("skipping in short mode") - } - img := image() - - t.Run("bash_echo", func(t *testing.T) { - out, err := osexec.Command("docker", "run", "--rm", - "--entrypoint", "bash", - img, - "-c", "echo 123", - ).CombinedOutput() - if err != nil { - t.Fatalf("docker run bash echo: %v\noutput: %s", err, out) - } - if !strings.Contains(string(out), "123") { - t.Errorf("expected output to contain '123', got: %s", out) - } - t.Logf("PASS: %s", strings.TrimSpace(string(out))) - }) - - t.Run("nix_version", func(t *testing.T) { - out, err := osexec.Command("docker", "run", "--rm", - "--entrypoint", "bash", - img, - "-lc", "nix --version", - ).CombinedOutput() - if err != nil { - t.Fatalf("docker run nix --version: %v\noutput: %s", err, out) - } - if !strings.Contains(strings.ToLower(string(out)), "nix") { - t.Errorf("expected output to contain 'nix', got: %s", out) - } - t.Logf("PASS: %s", strings.TrimSpace(string(out))) - }) - - t.Run("home_manager", func(t *testing.T) { - out, err := osexec.Command("docker", "run", "--rm", - "--entrypoint", "bash", - img, - "-lc", "home-manager --version", - ).CombinedOutput() - if err != nil { - t.Fatalf("docker run home-manager --version: %v\noutput: %s", err, out) - } - if !strings.Contains(string(out), ".") { - t.Errorf("expected home-manager version with '.', got: %s", out) - } - t.Logf("PASS: home-manager %s", strings.TrimSpace(string(out))) - }) - - t.Run("nix_profile_activated", func(t *testing.T) { - out, err := osexec.Command("docker", "run", "--rm", - "--entrypoint", "bash", - img, - "-lc", "readlink -f /opt/devcell/.nix-profile", - ).CombinedOutput() - if err != nil { - t.Fatalf("readlink nix-profile: %v\noutput: %s", err, out) - } - if !strings.Contains(string(out), "/nix/store/") { - t.Errorf("expected nix-profile to point into /nix/store/, got: %s", out) - } - t.Logf("PASS: nix-profile → %s", strings.TrimSpace(string(out))) - }) -} - -// TestCellShell validates the cell shell command end-to-end via PTY. -// CI runs this with DEVCELL_TEST_IMAGE pointing to the ultimate image. -func TestCellShell(t *testing.T) { - if testing.Short() { - t.Skip("skipping in short mode") - } - - // Build cell binary. - cellBin := filepath.Join(t.TempDir(), "cell") - build := osexec.Command("go", "build", "-o", cellBin, "./cmd") - build.Dir = filepath.Join("..") - build.Stdout = os.Stdout - build.Stderr = os.Stderr - if err := build.Run(); err != nil { - t.Fatalf("go build cell: %v", err) - } - - // Scaffold config directory (cell shell needs devcell.toml). - // Use manual cleanup via docker because the container creates - // root-owned files (e.g. xrdp/cert.pem) that t.TempDir() can't remove. - configDir, err := os.MkdirTemp("", "celltest-config-*") - if err != nil { - t.Fatalf("mkdtemp config: %v", err) - } - t.Cleanup(func() { - osexec.Command("docker", "run", "--rm", - "-v", configDir+":"+configDir, - "alpine", "rm", "-rf", configDir, - ).Run() - os.RemoveAll(configDir) - }) - devcellConfigDir := filepath.Join(configDir, "devcell") - if err := scaffold.Scaffold(devcellConfigDir, "", "", false); err != nil { - t.Fatalf("scaffold: %v", err) - } - - projectDir := t.TempDir() - userImage := image() // pre-built image from DEVCELL_TEST_IMAGE - - // cellShellHome creates a manually-managed HOME directory with the - // subdirectories that BuildArgv bind-mounts into the container. - // cell shell bind-mounts CellHome into the container, and the container - // creates files owned by its internal user. t.TempDir() cleanup can't - // remove those files (permission denied), so we clean up via docker. - cellShellHome := func(t *testing.T) string { - t.Helper() - home, err := os.MkdirTemp("", "celltest-home-*") - if err != nil { - t.Fatalf("mkdtemp: %v", err) - } - // Create directories that BuildArgv mounts from $HOME. - for _, sub := range []string{".claude/commands", ".claude/agents", ".claude/skills"} { - if err := os.MkdirAll(filepath.Join(home, sub), 0o755); err != nil { - t.Fatalf("mkdir %s: %v", sub, err) - } - } - t.Cleanup(func() { - osexec.Command("docker", "run", "--rm", - "-v", home+":"+home, - "alpine", "rm", "-rf", home, - ).Run() - os.RemoveAll(home) - }) - return home - } - - t.Run("bash_echo", func(t *testing.T) { - home := cellShellHome(t) - cmd := osexec.Command(cellBin, "shell", "--", "bash", "-c", "echo 123") - cmd.Dir = projectDir - cmd.Env = append(os.Environ(), - "XDG_CONFIG_HOME="+configDir, - "HOME="+home, - "DEVCELL_USER_IMAGE="+userImage, - ) - - out := runPTY(t, cmd) - if !strings.Contains(out, "123") { - t.Errorf("expected cell shell output to contain '123', got: %s", out) - } - }) - - t.Run("nix_version", func(t *testing.T) { - home := cellShellHome(t) - cmd := osexec.Command(cellBin, "shell", "--", "bash", "-lc", "nix --version") - cmd.Dir = projectDir - cmd.Env = append(os.Environ(), - "XDG_CONFIG_HOME="+configDir, - "HOME="+home, - "DEVCELL_USER_IMAGE="+userImage, - ) - - out := strings.ToLower(runPTY(t, cmd)) - if !strings.Contains(out, "nix") { - t.Errorf("expected cell shell output to contain 'nix', got: %s", out) - } - }) - - t.Run("spinner_visible", func(t *testing.T) { - home := cellShellHome(t) - // No --debug flag, so the Go spinner should render in PTY output. - cmd := osexec.Command(cellBin, "shell", "--", "echo", "done") - cmd.Dir = projectDir - cmd.Env = append(os.Environ(), - "XDG_CONFIG_HOME="+configDir, - "HOME="+home, - "DEVCELL_USER_IMAGE="+userImage, - ) - - out := runPTY(t, cmd) - t.Logf("PTY output (raw): %q", out) - - // Check for the "Opening Cell" status line. - if !strings.Contains(out, "Opening Cell") { - t.Fatalf("'Opening Cell' text not found in PTY output") - } - t.Logf("PASS: 'Opening Cell' rendered in PTY output") - - // If Docker successfully started the container, verify command output. - // Docker may refuse the mount when TMPDIR is not in shared paths - // (e.g. Docker Desktop file sharing). The spinner still renders. - if strings.Contains(out, "mounts denied") { - t.Logf("SKIP: Docker mount denied (TMPDIR not in Docker shared paths) — spinner verified") - } else if !strings.Contains(out, "done") { - t.Errorf("expected command output 'done' in PTY output") - } - }) -} - -// runPTY starts cmd in a PTY, collects output, and returns it. -// It runs io.Copy in a goroutine so cmd.Wait() can proceed even if the -// PTY slave side isn't closed yet (common with docker-backed commands). -func runPTY(t *testing.T, cmd *osexec.Cmd) string { - t.Helper() - ptmx, err := pty.Start(cmd) - if err != nil { - t.Fatalf("pty.Start: %v", err) - } - - var buf bytes.Buffer - done := make(chan struct{}) - go func() { - buf.ReadFrom(ptmx) - close(done) - }() - - if err := cmd.Wait(); err != nil { - t.Logf("cmd.Wait: %v (output so far: %s)", err, buf.String()) - } - ptmx.Close() - - select { - case <-done: - case <-time.After(5 * time.Second): - t.Log("warning: PTY read didn't finish within 5s after process exit") - } - return buf.String() -} diff --git a/web/public/logos/1password.svg b/web/public/logos/1password.svg new file mode 100644 index 00000000..bd942a63 --- /dev/null +++ b/web/public/logos/1password.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + diff --git a/web/public/logos/chrome.svg b/web/public/logos/chrome.svg new file mode 100644 index 00000000..db830716 --- /dev/null +++ b/web/public/logos/chrome.svg @@ -0,0 +1,11 @@ + + + Google Chrome + + + + + + + + diff --git a/web/public/logos/claude-icon.svg b/web/public/logos/claude-icon.svg new file mode 100644 index 00000000..88968da6 --- /dev/null +++ b/web/public/logos/claude-icon.svg @@ -0,0 +1,7 @@ + + + Claude + + + + diff --git a/web/public/logos/docker-icon.svg b/web/public/logos/docker-icon.svg new file mode 100644 index 00000000..3fafadf3 --- /dev/null +++ b/web/public/logos/docker-icon.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/web/public/logos/git-icon.svg b/web/public/logos/git-icon.svg new file mode 100644 index 00000000..9f766008 --- /dev/null +++ b/web/public/logos/git-icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/web/public/logos/go.svg b/web/public/logos/go.svg new file mode 100644 index 00000000..ec964ffc --- /dev/null +++ b/web/public/logos/go.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/web/public/logos/google-maps.svg b/web/public/logos/google-maps.svg new file mode 100644 index 00000000..be7a5cdd --- /dev/null +++ b/web/public/logos/google-maps.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/web/public/logos/inkscape.svg b/web/public/logos/inkscape.svg new file mode 100644 index 00000000..4f12e204 --- /dev/null +++ b/web/public/logos/inkscape.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + diff --git a/web/public/logos/kicad.svg b/web/public/logos/kicad.svg new file mode 100644 index 00000000..3d939fbe --- /dev/null +++ b/web/public/logos/kicad.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/web/public/logos/linear-icon.svg b/web/public/logos/linear-icon.svg new file mode 100644 index 00000000..3b872246 --- /dev/null +++ b/web/public/logos/linear-icon.svg @@ -0,0 +1,7 @@ + + + Linear + + + + diff --git a/web/public/logos/nix.svg b/web/public/logos/nix.svg new file mode 100644 index 00000000..da62d1f6 --- /dev/null +++ b/web/public/logos/nix.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/web/public/logos/nodejs-icon.svg b/web/public/logos/nodejs-icon.svg new file mode 100644 index 00000000..abf449bc --- /dev/null +++ b/web/public/logos/nodejs-icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/web/public/logos/notion-icon.svg b/web/public/logos/notion-icon.svg new file mode 100644 index 00000000..15ce3452 --- /dev/null +++ b/web/public/logos/notion-icon.svg @@ -0,0 +1,8 @@ + + + Notion + + + + + diff --git a/web/public/logos/ollama.svg b/web/public/logos/ollama.svg new file mode 100644 index 00000000..53bb6deb --- /dev/null +++ b/web/public/logos/ollama.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + diff --git a/web/public/logos/openai-icon.svg b/web/public/logos/openai-icon.svg new file mode 100644 index 00000000..d9445723 --- /dev/null +++ b/web/public/logos/openai-icon.svg @@ -0,0 +1,7 @@ + + + OpenAI + + + + diff --git a/web/public/logos/opentofu.svg b/web/public/logos/opentofu.svg new file mode 100644 index 00000000..2001c227 --- /dev/null +++ b/web/public/logos/opentofu.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + diff --git a/web/public/logos/packer.svg b/web/public/logos/packer.svg new file mode 100644 index 00000000..baa136ea --- /dev/null +++ b/web/public/logos/packer.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/web/public/logos/playwright.svg b/web/public/logos/playwright.svg new file mode 100644 index 00000000..03ee7d93 --- /dev/null +++ b/web/public/logos/playwright.svg @@ -0,0 +1,13 @@ + + + Playwright + + + + + + + + + + \ No newline at end of file diff --git a/web/public/logos/postgresql.svg b/web/public/logos/postgresql.svg new file mode 100644 index 00000000..0bdb3e3e --- /dev/null +++ b/web/public/logos/postgresql.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/web/public/logos/python.svg b/web/public/logos/python.svg new file mode 100644 index 00000000..cfbb36f3 --- /dev/null +++ b/web/public/logos/python.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/web/public/logos/ruby.svg b/web/public/logos/ruby.svg new file mode 100644 index 00000000..eaae0bdb --- /dev/null +++ b/web/public/logos/ruby.svg @@ -0,0 +1,139 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/public/logos/sqlite.svg b/web/public/logos/sqlite.svg new file mode 100644 index 00000000..bbbc2254 --- /dev/null +++ b/web/public/logos/sqlite.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/web/public/logos/swift.svg b/web/public/logos/swift.svg new file mode 100644 index 00000000..819c4112 --- /dev/null +++ b/web/public/logos/swift.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/web/public/logos/terraform-icon.svg b/web/public/logos/terraform-icon.svg new file mode 100644 index 00000000..e8f7abc8 --- /dev/null +++ b/web/public/logos/terraform-icon.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/web/public/logos/tmux.svg b/web/public/logos/tmux.svg new file mode 100644 index 00000000..34b2ea1b --- /dev/null +++ b/web/public/logos/tmux.svg @@ -0,0 +1 @@ +tmux \ No newline at end of file diff --git a/web/src/pages/index.astro b/web/src/pages/index.astro index d0ca4c79..49411556 100644 --- a/web/src/pages/index.astro +++ b/web/src/pages/index.astro @@ -1,10 +1,93 @@ --- import Base from '../layouts/Base.astro'; -const profiles = [ - { category: "AI Agents", tools: ["Claude Code", "OpenAI Codex", "OpenCode"] }, - { category: "Languages & Runtimes", tools: ["Node.js", "Python", "Go", "Ruby", "Swift / Clang / LLVM"] }, - { category: "Infrastructure", tools: ["Terraform", "OpenTofu", "Docker", "Compose", "Packer"] }, +const stacks = [ + { category: "AI Agents", tools: ["Claude Code", "OpenAI Codex", "OpenCode"] }, + { category: "Languages & Runtimes", tools: ["Go", "Node.js", "Python", "Ruby", "Swift / LLVM"] }, + { category: "Infrastructure & IaC", tools: ["Terraform", "OpenTofu", "Docker", "Compose", "Packer", "Helm", "Nix"] }, + { category: "Finance & Data", tools: ["Yahoo Finance", "EdgarTools SEC", "FRED API"] }, + { category: "Productivity & Travel", tools: ["Linear", "Notion", "Inoreader RSS", "Google Maps", "TripIt"] }, + { category: "Electronics & Design", tools: ["KiCad", "Inkscape (vector graphics)", "ngspice", "ESPHome", "wokwi-cli"] }, + { category: "Desktop & Browser", tools: ["VNC", "RDP", "Chromium (stealth)", "Playwright", "PulseAudio"] }, +]; + +const logos = [ + { src: "/logos/claude-icon.svg", alt: "Claude", title: "Claude Code" }, + { src: "/logos/openai-icon.svg", alt: "OpenAI", title: "OpenAI Codex" }, + { src: "/logos/ollama.svg", alt: "Ollama", title: "Ollama (local models)" }, + { src: "/logos/go.svg", alt: "Go", title: "Go" }, + { src: "/logos/nodejs-icon.svg", alt: "Node.js", title: "Node.js 22" }, + { src: "/logos/python.svg", alt: "Python", title: "Python 3.13" }, + { src: "/logos/ruby.svg", alt: "Ruby", title: "Ruby" }, + { src: "/logos/swift.svg", alt: "Swift", title: "Swift / LLVM" }, + { src: "/logos/terraform-icon.svg", alt: "Terraform", title: "Terraform" }, + { src: "/logos/opentofu.svg", alt: "OpenTofu", title: "OpenTofu" }, + { src: "/logos/docker-icon.svg", alt: "Docker", title: "Docker" }, + { src: "/logos/nix.svg", alt: "Nix", title: "Nix" }, + { src: "/logos/packer.svg", alt: "Packer", title: "Packer" }, + { src: "/logos/git-icon.svg", alt: "Git", title: "Git" }, + { src: "/logos/tmux.svg", alt: "tmux", title: "tmux (terminal multiplexer)" }, + { src: "/logos/chrome.svg", alt: "Chromium", title: "Stealth Chromium" }, + { src: "/logos/playwright.svg", alt: "Playwright", title: "Playwright" }, + { src: "/logos/google-maps.svg", alt: "Google Maps", title: "Google Maps MCP" }, + { src: "/logos/linear-icon.svg", alt: "Linear", title: "Linear MCP" }, + { src: "/logos/notion-icon.svg", alt: "Notion", title: "Notion MCP" }, + { src: "/logos/inkscape.svg", alt: "Inkscape", title: "Inkscape (vector graphics)" }, + { src: "/logos/kicad.svg", alt: "KiCad", title: "KiCad EDA" }, + { src: "/logos/sqlite.svg", alt: "SQLite", title: "SQLite" }, + { src: "/logos/postgresql.svg", alt: "PostgreSQL", title: "PostgreSQL" }, + { src: "/logos/1password.svg", alt: "1Password", title: "1Password (secret injection)" }, +]; + +const stackColumns = ["base", "go", "node", "python", "fullstack", "electronics", "ultimate"]; +const stackFeatures = [ + { name: "Dev essentials", note: "zsh, git, tmux, ripgrep, jq, sqlite, mise, nix", in: ["base","go","node","python","fullstack","electronics","ultimate"] }, + { name: "Go environment", note: "Go toolchain", in: ["go","fullstack","ultimate"] }, + { name: "Node.js environment", note: "Node.js 22, npm", in: ["node","fullstack","ultimate"] }, + { name: "Python environment", note: "Python 3.13, uv", in: ["python","fullstack","ultimate"] }, + { name: "Infra tools", note: "Terraform, OpenTofu, Packer, Helm", in: ["go","fullstack","ultimate"] }, + { name: "Stealth browser", note: "Anti-fingerprint Chromium + Playwright", in: ["node","python","fullstack","ultimate"], partial: ["node","python","fullstack"] }, + { name: "Electronics & DIY", note: "KiCad, ngspice, ESPHome, wokwi-cli", in: ["electronics","ultimate"] }, + { name: "GUI desktop", note: "VNC + RDP + Fluxbox + PulseAudio", in: ["electronics","ultimate"] }, + { name: "12+ MCP servers", note: "Finance, maps, productivity, vector graphics (Inkscape) — growing", in: ["ultimate"] }, +]; + +const dcRows = [ + { feature: "Designed for", devcell: "AI coding agents (Claude Code, Codex, OpenCode)", devcontainers: "VS Code and GitHub Codespaces" }, + { feature: "Setup", devcell: "cell claude — zero config, works from any project", devcontainers: "Write a Dockerfile + devcontainer.json per project" }, + { feature: "Toolchain", devcell: "7 pre-built Nix-pinned stacks. Nothing to maintain", devcontainers: "You build and maintain your own image" }, + { feature: "MCP servers", devcell: "12+ servers auto-merged into agent config at startup", devcontainers: "Not included. Install and configure yourself" }, + { feature: "Secrets", devcell: "1Password injection at runtime, never written to disk", devcontainers: "Env files or docker-compose secrets" }, + { feature: "Browser", devcell: "Stealth Chromium + Playwright, passes bot detection", devcontainers: "Not included" }, + { feature: "GUI desktop", devcell: "VNC + RDP built in, watch the agent work visually", devcontainers: "Not included" }, + { feature: "Permissions", devcell: "Host UID pass-through, no file ownership issues", devcontainers: "remoteUser config in devcontainer.json" }, + { feature: "Agent auth", devcell: "Claude Max/Pro subscription works directly", devcontainers: "You handle auth yourself" }, +]; + +const faqItems = [ + { q: "Do I need an API key or Claude subscription?", + a: "Bring your own license or model. Claude Max, Pro, and API keys all work — devcell starts the same client you already use, just inside a container. Same goes for Codex and OpenCode." }, + { q: "Can the agent install packages inside the container?", + a: "Yes. The agent can run apt, npm install, pip install, nix — whatever the project needs. Network access is unrestricted. Port forwarding for dev servers is coming soon." }, + { q: "What about file permissions?", + a: "Permissions are pass-through. The container user matches your host UID, so files the agent creates are owned by you. No chown headaches." }, + { q: "Does my existing Claude Code config carry over?", + a: "Your project-level config (CLAUDE.md, .claude/) is mounted with your project. Host ~/.claude/skills is mounted read-write, the rest of ~/.claude is read-only. Your host-machine config stays safe. (Subject to change.)" }, + { q: "Why not just run Docker myself?", + a: "You can. devcell saves you from maintaining a Dockerfile per project, wiring up MCP server configs, forwarding git identity, injecting secrets, and setting up entrypoint orchestration. One command instead of 20 minutes of Docker plumbing." }, + { q: "How big are the images?", + a: "Base is ~1.3 GB, ultimate is ~20 GB. First run builds locally (~5 min). Pre-built images are published to ghcr.io for faster pulls." }, +]; + +const vsRows = [ + { feature: "Built for", devcell: "AI coding agents, with finance, maps, and design MCP servers included", openclaw: "Multi-channel messaging hub (WhatsApp, Telegram, Slack)" }, + { feature: "Setup", devcell: "cd my-project && cell claude", openclaw: "Gateway daemon + WebSocket config + channel integrations" }, + { feature: "Toolchain", devcell: "7 Nix-pinned stacks with per-project config. Go, Node.js, Python, Terraform, Chromium, KiCad", openclaw: "Shell + file access, no pre-built dev environment. Tool config is system-wide only" }, + { feature: "MCP servers", devcell: "12 curated, auto-merged at startup. Backing tools (OpenTofu, KiCad, Inkscape) ship in the image", openclaw: 'Open marketplace, no mandatory code review. 1,184 malicious skills found [1]' }, + { feature: "Security", devcell: "Mandatory container isolation, no exposed ports, no plugin marketplace", openclaw: 'Network-exposed by default. 21,000+ instances found on public internet [2]' }, + { feature: "Claude sub", devcell: "Claude Max and Pro work out of the box. Runs Claude Code directly", openclaw: "Subscription auth blocked by Anthropic since Jan 2026. Requires API key (pay-per-use) or a proxy like OpenRouter" }, + { feature: "Privacy", devcell: "Agent runs locally, talks to the API from your machine", openclaw: "Messages route through centralized server infrastructure" }, + { feature: "Isolation", devcell: "Always on. SSH keys, credentials, host FS unreachable", openclaw: 'Optional, off by default. Multiple critical CVEs in 2026 [3] [4]' }, ]; --- @@ -17,9 +100,9 @@ const profiles = [ margin: 0 auto; padding: 4rem 2rem 3rem; display: grid; - grid-template-columns: 1fr 1fr; - gap: 4rem; - align-items: center; + grid-template-columns: 7fr 5fr; + gap: 3rem; + align-items: start; } @media (max-width: 768px) { @@ -106,6 +189,8 @@ const profiles = [ border: var(--border); box-shadow: var(--shadow-lg); line-height: 1.9; + position: sticky; + top: 5rem; } .terminal-bar { @@ -248,21 +333,21 @@ const profiles = [ border-bottom: var(--border); } - .profiles-grid { + .stacks-grid { border: var(--border); margin-bottom: 1rem; } - .profile-row { + .stack-row { display: grid; grid-template-columns: 13rem 1fr; border-bottom: 1px solid #CCCCCC; align-items: baseline; } - .profile-row:last-child { border-bottom: none; } + .stack-row:last-child { border-bottom: none; } - .profile-cat { + .stack-cat { font-size: 0.72rem; font-weight: 900; text-transform: uppercase; @@ -271,7 +356,7 @@ const profiles = [ border-right: 1px solid #CCCCCC; } - .profile-tools { + .stack-tools { font-family: var(--font-mono); font-size: 0.78rem; color: #444; @@ -279,23 +364,72 @@ const profiles = [ } @media (max-width: 600px) { - .profile-row { grid-template-columns: 1fr; } - .profile-cat { border-right: none; border-bottom: 1px solid #CCCCCC; } + .stack-row { grid-template-columns: 1fr; } + .stack-cat { border-right: none; border-bottom: 1px solid #CCCCCC; } } - .profiles-more { + .stacks-more { font-family: var(--font-mono); font-size: 0.78rem; color: #888; } - .profiles-more a { + .stacks-more a { color: var(--black); font-weight: 900; text-decoration: underline; text-underline-offset: 2px; } + /* LOGO GRID */ + .logo-grid { + display: flex; + flex-wrap: wrap; + gap: 1.25rem; + align-items: center; + justify-content: center; + padding: 1.5rem 0; + margin-bottom: 1rem; + } + + .logo-grid img { + height: 28px; + width: auto; + filter: grayscale(100%) opacity(0.5); + transition: filter 0.2s; + } + + .logo-grid img:hover { + filter: grayscale(0%) opacity(1); + } + + .logo-item { + position: relative; + display: flex; + align-items: center; + } + + .logo-item::after { + content: attr(data-title); + position: absolute; + bottom: calc(100% + 6px); + left: 50%; + transform: translateX(-50%); + background: var(--black); + color: var(--white); + font-family: var(--font-mono); + font-size: 0.65rem; + padding: 0.25rem 0.5rem; + white-space: nowrap; + opacity: 0; + pointer-events: none; + transition: opacity 0.15s; + } + + .logo-item:hover::after { + opacity: 1; + } + /* EYEBROW STAR LINK */ .eyebrow-stars { color: var(--black); @@ -403,20 +537,241 @@ const profiles = [ margin-bottom: 0.35rem; color: var(--black); } + + /* PLAN GRID */ + .plan-grid { + border: var(--border); + box-shadow: var(--shadow); + overflow-x: auto; + margin-bottom: 1.5rem; + } + + .plan-grid table { + width: 100%; + border-collapse: collapse; + min-width: 680px; + } + + .plan-grid th { + font-size: 0.7rem; + font-weight: 900; + text-transform: uppercase; + letter-spacing: 0.5px; + padding: 0.7rem 0.5rem; + text-align: center; + border-bottom: var(--border); + border-right: 1px solid #CCC; + background: var(--white); + } + + .plan-grid th:first-child { + text-align: left; + padding-left: 1rem; + width: 11rem; + } + + .plan-grid th:last-child { border-right: none; } + + .plan-grid th.col-default { + background: var(--accent); + } + + .plan-grid td { + font-family: var(--font-mono); + font-size: 0.82rem; + padding: 0.5rem 0.5rem; + text-align: center; + border-bottom: 1px solid #CCC; + border-right: 1px solid #CCC; + color: #DDD; + } + + .plan-grid td:first-child { + text-align: left; + padding-left: 1rem; + font-family: var(--font-sans); + font-weight: 700; + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--black); + } + + .plan-grid td:last-child { border-right: none; } + .plan-grid tr:last-child td { border-bottom: none; } + + .plan-grid td.col-default { + background: color-mix(in srgb, var(--accent) 12%, white); + } + + .plan-grid .has { + color: var(--black); + font-weight: 700; + } + + .plan-grid sup { + color: #888; + font-size: 0.6rem; + font-weight: 400; + } + + /* IMAGE STACKS TABLE */ + .tag-default { + background: var(--accent); + font-size: 0.6rem; + font-weight: 900; + text-transform: uppercase; + letter-spacing: 0.5px; + padding: 0.1em 0.35em; + margin-left: 0.5rem; + border: 1px solid var(--black); + } + + /* QUICKSTART WRAP */ + .qs-wrap { + background: var(--bg-muted); + border-top: var(--border); + border-bottom: var(--border); + } + + /* VS COMPARISON */ + .compare-wrap { + background: var(--bg-muted); + border-top: var(--border); + border-bottom: var(--border); + } + + .vs-table { + border: var(--border); + box-shadow: var(--shadow); + overflow-x: auto; + } + + .vs-header { + display: grid; + grid-template-columns: 9rem 1fr 1fr; + border-bottom: var(--border); + background: var(--white); + } + + .vs-header span { + font-size: 0.72rem; + font-weight: 900; + text-transform: uppercase; + letter-spacing: 1px; + padding: 0.7rem 1rem; + } + + .vs-header span:not(:last-child) { border-right: 1px solid #CCC; } + + .vs-row { + display: grid; + grid-template-columns: 9rem 1fr 1fr; + border-bottom: 1px solid #CCC; + background: var(--white); + } + + .vs-row:last-child { border-bottom: none; } + + .vs-feature { + font-size: 0.72rem; + font-weight: 900; + text-transform: uppercase; + letter-spacing: 0.5px; + padding: 0.7rem 1rem; + border-right: 1px solid #CCC; + } + + .vs-cell { + font-family: var(--font-mono); + font-size: 0.78rem; + color: #444; + padding: 0.7rem 1rem; + line-height: 1.55; + } + + .vs-cell:first-of-type { border-right: 1px solid #CCC; } + + .vs-cell sup a { + color: #888; + font-size: 0.65rem; + text-decoration: none; + font-family: var(--font-mono); + } + .vs-cell sup a:hover { color: var(--black); text-decoration: underline; } + + .vs-sources { + font-family: var(--font-mono); + font-size: 0.68rem; + color: #888; + line-height: 1.8; + margin-top: 0.75rem; + } + .vs-sources a { color: #666; text-decoration: none; } + .vs-sources a:hover { color: var(--black); text-decoration: underline; } + + @media (max-width: 600px) { + .vs-header, .vs-row { grid-template-columns: 6rem 1fr 1fr; } + .vs-cell, .vs-feature { font-size: 0.7rem; padding: 0.55rem 0.6rem; } + } + + /* FAQ */ + .faq-list { list-style: none; border: var(--border); box-shadow: var(--shadow); } + + .faq-list details { + border-bottom: 1px solid #CCC; + background: var(--white); + } + + .faq-list details:last-child { border-bottom: none; } + + .faq-list summary { + font-size: 0.82rem; + font-weight: 700; + padding: 0.9rem 1.25rem; + cursor: pointer; + list-style: none; + display: flex; + align-items: center; + gap: 0.75rem; + } + + .faq-list summary::-webkit-details-marker { display: none; } + + .faq-list summary::before { + content: '+'; + font-family: var(--font-mono); + font-size: 1rem; + font-weight: 400; + color: #888; + flex-shrink: 0; + width: 1rem; + text-align: center; + } + + .faq-list details[open] summary::before { content: '−'; } + + .faq-list .faq-answer { + font-family: var(--font-mono); + font-size: 0.78rem; + color: #444; + line-height: 1.65; + padding: 0 1.25rem 1rem 3rem; + } - +
-

AI Agent Sandbox · Apache 2.0 · ★ Star on GitHub

+

AI Agent Sandbox · Open Source · Apache 2.0 · ★ Star on GitHub

Agentic Coding,
Without the
Blast Radius.

- You want your AI agent to just do things — no approval prompts, full dev cycle. - But locally, auto-approve means the agent has your SSH keys, other repos, - and every credential on the machine.

- devcell is an AI agent sandbox: your project directory is mounted inside, - everything else stays out. + Your AI agent can rm -rf / and you're fine. + Auto-approve on your bare machine means the agent sees your SSH keys, other repos, + every credential on disk.

+ devcell puts a container between your project and everything else. + Your code goes in. Nothing else comes along.

View on GitHub → @@ -435,7 +790,7 @@ const profiles = [
~/myproject $ cell claude
-
starting sandbox…
+
Opening Cell myproject …
✔ mounted /home/alex/myproject
───────────────────────────────────────
@@ -449,7 +804,7 @@ const profiles = [ row5-->
-
  Claude Code v2.1.49
+          
  Claude Code
 · Sonnet 4.6 · Claude Max
   /myproject
    
@@ -468,84 +823,216 @@ const profiles = [

Blast radius bounded

-

SSH keys, other repos, host APIs — unreachable. The agent edits freely inside your project; your host system is completely untouched.

+

SSH keys, other repos, host APIs: unreachable. The agent edits freely inside your project. Your host system stays untouched.

One command, any project

-

cd my-project && cell claude — working directory mounted automatically. No per-project config. Works for Claude Code, Codex, and OpenCode.

+

cd my-project && cell claude. Working directory mounted automatically, no per-project config needed. Works with Codex and OpenCode too.

-

Toolchain that won't break

-

Go, Node.js, Python, Ruby, Swift, Terraform and more, pre-installed and community-vetted. No download URLs to die. No version drift between machines.

+

Version-locked toolchain

+

Go, Node.js, Python, Terraform, and more. Nix-pinned at build time. No download URLs that go stale, no version drift between machines.

Secrets never touch your repo

-

Inject credentials at runtime via 1Password — API keys, tokens, and secrets stay in your vault, never written to the project directory.

+

Pass secrets via 1Password, non-sensitive vars via plaintext config. Credentials stay in your vault, injected at runtime, never written to the project directory or shell history.

-

Local MCPs, zero data leakage

-

Playwright, Chromium, and other MCP servers run inside the sandbox. No host installs, no external servers — your data never leaves the machine.

+

MCP servers with real tools behind them

+

Not just config stubs. KiCad, Inkscape, and OpenTofu ship in the image alongside their MCP servers, so the agent can actually run tofu plan, analyze PCBs, or edit SVGs. 12 servers today, more with each release.

-

See what the agent sees

-

Connect via VNC or RDP to watch the agent interact with GUI apps, browsers, and desktop tools in real time.

+

Stealth Chromium built in

+

Anti-fingerprint Chromium with Playwright, ready for scraping and browser automation. Passes bot detection out of the box. Connect via VNC or RDP to watch it work.

+
+
+ +

Quickstart

+
+
    +
  1. + 1 +
    +

    Install

    +

    brew install DimmKirr/tap/devcell. Requires docker.
    Platforms: macOS, Linux, Windows(not verified yet)

    +
    +
  2. +
  3. + 2 +
    +

    Run from any project

    +

    cd my-project && cell claude. First run builds the sandbox image (~5 min). Works with cell codex and cell opencode too.

    +
    +
  4. +
+ +
+
+
+ + + +
+ # macOS & Linux + brew install DimmKirr/tap/devcell + + # run from any project directory + cd ~/dev/my-project + cell claude +
+
+
+
+
+
-

Batteries included.

-
- {profiles.map((group) => ( -
- {group.category} - {group.tools.join(' · ')} +

What ships in the box.

+

Everything below ships in the ultimate stack. Pick a focused stack below and get exactly what you need.

+
+ {stacks.map((group) => ( +
+ {group.category} + {group.tools.join(' · ')}
))}
-

Also includes Electronics & IoT, Desktop & GUI, Media & Docs, and local MCPs (Playwright, Chromium). See all included tools →

-

Every tool comes from nixhome — fork it to add your own packages without losing upstream updates.

+
+ {logos.map((logo) => ( + {logo.alt} + ))} +
+

Drop a .tool-versions for runtime versions, add packages via config, extend a stack with nix overlays, or fork nixhome and build your own. Upstream updates still merge cleanly.

-
- -

Quickstart

-
-
    -
  1. - 1 -
    -

    Install

    -

    macOS: brew install DimmKirr/tap/devcell
    Linux: download from GitHub releases. Requires docker.

    -
    +
    + +

    Pick your stack.

    +

    Need a different mix? Set stack and modules in your devcell.toml to combine what you need.

    +
    + + + + + {stackColumns.map(col => ( + + ))} + + + + {stackFeatures.map(feat => ( + + + {stackColumns.map(col => { + const included = feat.in.includes(col); + const isPartial = feat.partial?.includes(col); + return ( + + ); + })} + + ))} + +
    {col}
    {feat.name} + *" : "✓") : "—"} /> +
    +
    +

    * Headless only. GUI desktop (VNC/RDP) available in electronics and ultimate stacks.

    +

    Multi-arch: linux/amd64 and linux/arm64. Published to ghcr.io/dimmkirr/devcell.

    +

    Base nix image size ~1.3 GB.

    +
    + +
    + +

    FAQ

    +
      + {faqItems.map((item) => ( +
    • +
      + {item.q} +

      {item.a}

      +
    • -
    • - 2 -
      -

      Run from any project

      -

      cd my-project && cell claude — first run auto-builds the sandbox image (~5 min). Works with cell codex and cell opencode too.

      + ))} +
    • +
      + How is this different from Dev Containers? +
      +

      Dev Containers are editor-first — great for VS Code workflows. DevCell is agent-first — built for AI coding agents that run headless with auto-approve.

      +
      +
      + + DevCell + Dev Containers +
      + {dcRows.map((r) => ( +
      + {r.feature} + {r.devcell} + {r.devcontainers} +
      + ))} +
      -
    • -
- -
-
-
- - - + + +
  • +
    + How is this different from OpenClaw? +
    +

    OpenClaw is an open-source messaging gateway that added AI agent features. Different architecture, different trade-offs.

    +
    +
    + + DevCell + OpenClaw +
    + {vsRows.map((r) => ( +
    + {r.feature} + + +
    + ))} +
    +

    OpenClaw's individual bugs get patched. But the architecture keeps producing them: an open plugin marketplace, network-exposed by default, and unauthenticated local WebSockets created three separate critical vulnerability classes in under two months.

    +
    + [1] Malicious ClawHub Skills Stealing Data — The Hacker News, Feb 2026
    + [2] OpenClaw in the Wild: Mapping Public Exposure — Censys, Jan 2026
    + [3] CVE-2026-25253: One-Click RCE — SonicWall. Affects all versions before v2026.1.29
    + [4] ClawJacked: Website-to-Agent Hijack — Oasis Security. Affects all versions before v2026.2.25 +
    - # macOS - brew install DimmKirr/tap/devcell - - # run from any project directory - cd ~/dev/my-project - cell claude -
  • + + + +
    + +
    +
    + +

    Ready to try it?

    +
    + State persists + Sessions run inside tmux — reconnect anytime without losing work. Except your secrets, everything is saved per session in ~/.devcell/<SESSION>/: a dedicated home directory for each container. Your project directory is mounted read-write. +
    +
    +
    + + +
    + brew install DimmKirr/tap/devcell + cd ~/dev/my-project + cell claude
    +

    First run scaffolds config and builds the ultimate stack (~5 min). You get the full toolchain, all MCP servers, GUI desktop, and stealth Chromium out of the box.

    +