diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..30bb3c2 --- /dev/null +++ b/.envrc @@ -0,0 +1,2 @@ +dotenv_if_exists .env.local +use flake diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..d3a53cf --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# flake.lock is machine-generated JSON that cannot be merged by git. +# Always take the branch version on conflict — regenerate after merge if needed. +flake.lock merge=binary diff --git a/.github/actions/setup-nix/action.yml b/.github/actions/setup-nix/action.yml new file mode 100644 index 0000000..11fca39 --- /dev/null +++ b/.github/actions/setup-nix/action.yml @@ -0,0 +1,17 @@ +name: Setup Nix +description: Install Nix with nixpkgs-unstable channel + +runs: + using: composite + steps: + # Unset GITHUB_TOKEN so cachix/install-nix-action doesn't write a + # repo-scoped token to nix.conf. That token only covers this repo, + # so authenticated requests to other public repos (e.g. toolbox) fail + # with 401 instead of succeeding anonymously. + - name: Unset GITHUB_TOKEN for Nix + shell: bash + run: echo "GITHUB_TOKEN=" >> "$GITHUB_ENV" + - uses: cachix/install-nix-action@v27 + with: + nix_path: nixpkgs=channel:nixos-unstable + - uses: DeterminateSystems/magic-nix-cache-action@v8 diff --git a/.github/workflows/build-base-image.yml b/.github/workflows/build-base-image.yml new file mode 100644 index 0000000..d0f03a3 --- /dev/null +++ b/.github/workflows/build-base-image.yml @@ -0,0 +1,51 @@ +name: Build forage-base image + +on: + push: + branches: [main] + paths: + - "images/forage-base/**" + pull_request: + paths: + - "images/forage-base/**" + workflow_dispatch: + +env: + REGISTRY: ghcr.io + IMAGE_NAME: firefly-engineering/forage-base + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - uses: actions/checkout@v4 + + - uses: docker/setup-buildx-action@v3 + + - uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - uses: docker/metadata-action@v5 + id: meta + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=raw,value=latest,enable={{is_default_branch}} + type=sha + + - uses: docker/build-push-action@v5 + with: + context: images/forage-base + platforms: linux/amd64,linux/arm64 + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d466a4f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,68 @@ +name: CI + +on: + pull_request: + branches: [main] + merge_group: + +jobs: + format: + if: github.event_name != 'merge_group' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/setup-nix + + - name: Check Nix formatting + run: nix fmt -- --ci . + + lint: + if: github.event_name != 'merge_group' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/setup-nix + + - name: Run linter + run: nix develop .#ci --command bash -c "cd packages/forage-ctl && golangci-lint run" + + build: + if: github.event_name != 'merge_group' + needs: [format, lint] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/setup-nix + + - name: Build forage-ctl + run: nix build .#forage-ctl + + - name: Build docs + run: nix build .#docs + + test: + if: github.event_name != 'merge_group' + needs: [build] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/setup-nix + + - name: Run tests + run: nix develop .#ci --command bash -c "cd packages/forage-ctl && go test ./..." + + e2e: + if: github.event_name == 'merge_group' + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/setup-nix + + - name: Enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666"' | sudo tee /etc/udev/rules.d/99-kvm.rules + sudo udevadm control --reload-rules && sudo udevadm trigger --name-match=kvm + + - name: Run E2E tests + run: nix run .#e2e-driver -- 2>&1 diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..45701c2 --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,44 @@ +name: Deploy docs to GitHub Pages + +on: + push: + branches: [main] + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Nix + uses: cachix/install-nix-action@v27 + with: + nix_path: nixpkgs=channel:nixos-unstable + + - name: Build docs + run: nix build .#docs + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: result + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b3011d5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +result +result-* +.direnv/ +docs/book/ + +# Go vendor directories (dependencies managed by Nix) +**/vendor/ + +.claude/ + +# E2E test VM disk images +*.qcow2 + +# local environment files +.*.local diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..e9cbc79 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,11 @@ +## Work Management + +This project tracks work with `bw` (beadwork), which persists to git plans, progress, and decisions survive +compaction, session boundaries, and context loss. + +ALWAYS run `bw prime` before starting work. Without it, you're missing workflow context, current state, and repo +hygiene warnings. Work done without priming often conflicts with in-progress changes. + +Committing, closing issues, and syncing are part of completing a task not separate actions requiring additional +permission. + diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/DESIGN.md b/DESIGN.md index 4f282ad..aaefa20 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -96,17 +96,49 @@ The nix store is bind-mounted read-only. All nix operations go through the host' **Verified:** Tested with `unshare --mount` and confirmed nix builds work. -### Instance Tracking: Stateless +### Nix Registry Pinning -Instead of maintaining state files, we derive instance information from: -- Running systemd-nspawn containers (machinectl list) -- Container naming convention: `forage-{name}` -- Introspection of bind mounts for workspace info +Sandboxes automatically have a pinned nix registry that matches the host's nixpkgs version: + +```nix +# Generated in container config +environment.etc."nix/registry.json".text = builtins.toJSON { + version = 2; + flakes = [ + { + from = { type = "indirect"; id = "nixpkgs"; }; + to = { + type = "github"; + owner = "NixOS"; + repo = "nixpkgs"; + rev = "..."; # Automatically set to host's nixpkgs revision + }; + } + ]; +}; +``` + +**Benefits:** +- All agents use the same nixpkgs version +- Reproducible tool installations across sandboxes +- No accumulation of different nixpkgs versions in store +- Pinned to the same nixpkgs used to build the sandbox + +**Implementation:** The host module exposes its nixpkgs input revision via `config.json`, and the container config generator injects this into each sandbox's registry. + +### Instance Tracking: Metadata-Driven + +Sandbox state is tracked via metadata files in `/var/lib/firefly-forage/sandboxes/`: +- Each sandbox has a `{name}.json` metadata file and a `{name}.nix` config +- Runtime state (running/stopped) is derived from the container runtime (machinectl) +- Container naming convention: configurable, defaults to `forage-{name}` +- Generated files (skills, permissions) are staged in `{name}.generated/` directories +- The `gc` command reconciles metadata with actual container state Benefits: -- No state to corrupt or get out of sync -- System is the source of truth -- Simpler implementation +- Metadata enables rich operations (workspace mode, template, network slot) +- Runtime state is always from the source of truth (container runtime) +- gc provides eventual consistency if metadata drifts ### User Identity: Same UID as Host @@ -141,6 +173,249 @@ The wrapper: - Agent cannot easily discover where auth came from - Provides minimal protection against credential exfiltration +### JJ Workspace Integration + +Each sandbox uses a separate jj workspace, enabling parallel agent work on the same repository without conflicts. + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ Host │ +│ │ +│ ~/projects/myrepo/ │ +│ ├── .jj/ ◄─────────────────────────┐ │ +│ ├── src/ │ shared │ +│ └── ... │ (read-only) │ +│ │ │ +│ /var/lib/forage/workspaces/ │ │ +│ ├── sandbox-a/ ◄── jj workspace ─────────┤ │ +│ │ ├── src/ (separate working copy) │ │ +│ │ └── ... │ │ +│ └── sandbox-b/ ◄── jj workspace ─────────┘ │ +│ ├── src/ (separate working copy) │ +│ └── ... │ +│ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +**How it works:** +1. `forage-ctl up` creates a jj workspace at a persistent location +2. The workspace shares the repo's `.jj` directory (operation log, etc.) +3. Each sandbox gets its own working copy of the files +4. Changes in one sandbox don't affect others until committed +5. Agents can work in parallel on different changes + +**CLI integration:** +```bash +# Create sandbox with jj workspace +forage-ctl up agent-a --template claude --repo ~/projects/myrepo + +# This internally runs: +# jj workspace add /var/lib/forage/workspaces/agent-a --name agent-a + +# Multiple agents on same repo +forage-ctl up agent-b --template claude --repo ~/projects/myrepo +forage-ctl up agent-c --template opencode --repo ~/projects/myrepo +``` + +**Cleanup:** +```bash +# Remove sandbox and its workspace +forage-ctl down agent-a +# Internally: jj workspace forget agent-a && rm -rf workspace +``` + +### Skill Injection + +Sandboxes automatically include "skills" - configuration that teaches agents about available tools and project conventions. + +**Injection location:** `.claude/forage-skills.md` (or similar) + +This avoids modifying the project's `CLAUDE.md` which may contain valuable upstream information. Claude Code loads instructions from multiple files in `.claude/`. + +``` +workspace/ +├── .claude/ +│ ├── forage-skills.md ◄── Injected by forage (sandbox-specific) +│ └── settings.json ◄── May also inject settings here +├── CLAUDE.md ◄── Untouched (from upstream repo) +└── src/ +``` + +**Injected content (.claude/forage-skills.md):** +```markdown +# Firefly Forage Sandbox Environment + +This workspace is running inside a Firefly Forage sandbox. + +## Version Control: JJ (Jujutsu) + +Use `jj` instead of `git` for all version control operations: + +- `jj status` - Show working copy status +- `jj diff` - Show changes +- `jj new` - Create new change +- `jj describe -m "message"` - Set change description +- `jj bookmark set main` - Update bookmark + +This is an isolated jj workspace. Your changes won't affect other +workspaces until you explicitly share them. + +## Available Tools + +- `rg` (ripgrep) - Fast recursive search +- `fd` - Fast file finder +- `jq` - JSON processing +- `nix build` - Build nix expressions (uses host daemon) + +## Sandbox Constraints + +- The nix store is read-only (builds go through host daemon) +- Network access: [full|restricted|none] +- This container is ephemeral - only /workspace persists +``` + +**Skill sources (in priority order):** +1. **Project skills**: From repo's existing `CLAUDE.md` (untouched, highest priority) +2. **Forage skills**: Injected `.claude/forage-skills.md` (sandbox-aware instructions) +3. **Template skills**: From sandbox template configuration +4. **User skills**: Custom per-sandbox overrides + +**Configuration:** +```nix +templates.claude = { + skills = { + jj = true; # Include jj skill (default: true) + nix = true; # Include nix skill (default: true) + + # Additional custom instructions + custom = '' + ## Testing Requirements + Always write tests before implementation. + ''; + }; + + # Optionally inject into .claude/settings.json + claudeSettings = { + # Any claude-code settings to inject + }; +}; +``` + +**Cleanup:** The injected `.claude/forage-skills.md` is created at sandbox start and can be removed on sandbox down if desired (though it's harmless to leave). + +### Tmux Session Management + +Each sandbox runs the agent inside a tmux session for better terminal handling and attach/detach capability. + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Sandbox Container │ +│ │ +│ tmux session: "forage" │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ Window 0: agent │ │ +│ │ ┌─────────────────────────────────────────────────┐ │ │ +│ │ │ $ claude │ │ │ +│ │ │ Claude Code ready... │ │ │ +│ │ │ > │ │ │ +│ │ └─────────────────────────────────────────────────┘ │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ │ +│ sshd │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +**Benefits:** +- **Attach/detach**: Connect to running agent, disconnect without stopping it +- **Session persistence**: Agent keeps running if SSH disconnects +- **Multiple windows**: Agent in one window, shell in another +- **Scrollback**: Review agent's previous output +- **Resilience**: Survives network interruptions +- **Sub-agent support**: Compatible with tools like opencode extensions that spawn sub-agents in tmux panes + +**CLI integration:** +```bash +# Connect to sandbox (attaches to tmux session) +forage-ctl ssh myproject +# → ssh ... -t 'tmux attach -t forage' + +# Start agent in sandbox (creates tmux session) +forage-ctl start myproject +# → Creates tmux session, starts claude in it + +# Detach: Ctrl-b d (standard tmux) +# Reattach: forage-ctl ssh myproject + +# Run shell alongside agent +forage-ctl shell myproject +# → Attaches to tmux, creates new window with shell +``` + +**Tmux configuration:** +```bash +# /etc/tmux.conf in sandbox +set -g prefix C-b +set -g mouse on +set -g history-limit 50000 +set -g status-style 'bg=colour235 fg=colour136' +set -g status-left '[forage] ' +``` + +### Gateway Access (Future) + +Instead of exposing one SSH port per sandbox, a single gateway service provides access to all sandboxes through a selection interface. + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Host Machine │ +│ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ forage-gateway (port 2200) │ │ +│ │ │ │ +│ │ ┌─────────────────────────────────────────────────┐ │ │ +│ │ │ Firefly Forage - Select Sandbox │ │ │ +│ │ │ │ │ │ +│ │ │ > myproject claude running 2h ago │ │ │ +│ │ │ agent-a claude running 30m ago │ │ │ +│ │ │ agent-b multi running 5m ago │ │ │ +│ │ │ │ │ │ +│ │ │ [Enter] Attach [n] New [d] Down [q] Quit │ │ │ +│ │ └─────────────────────────────────────────────────┘ │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ +│ │ sandbox-myproj │ │ sandbox-agent-a │ │ sandbox-agent-b │ │ +│ │ tmux: forage │ │ tmux: forage │ │ tmux: forage │ │ +│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +**Benefits:** +- **Single port**: Only one port to expose/forward for remote access +- **Discoverability**: See all sandboxes at a glance +- **Simpler firewall**: No dynamic port range needed +- **Better UX**: Interactive selection instead of remembering names + +**Implementation options:** +1. **TUI selector**: fzf/gum-based picker that runs `machinectl shell` or SSH to selected sandbox +2. **Custom shell**: Login shell that presents the picker, then `exec`s into chosen sandbox +3. **SSH ForceCommand**: SSH config that runs the selector before allowing access + +**Access patterns:** +```bash +# Interactive: land in selector +ssh -p 2200 forage@hostname + +# Direct: skip selector, go straight to sandbox +ssh -p 2200 forage@hostname myproject + +# From selector, attach to sandbox's tmux session +# → machinectl shell forage-myproject /bin/bash -c 'tmux attach -t forage' +``` + ### Network Isolation Modes | Mode | Description | Use Case | @@ -163,18 +438,30 @@ firefly-forage/ ├── README.md # User documentation │ ├── modules/ -│ ├── host.nix # NixOS module for host machine -│ └── sandbox.nix # Container configuration generator +│ └── host.nix # NixOS module for host machine │ ├── lib/ -│ ├── mkSandbox.nix # Sandbox template builder -│ ├── mkAgentWrapper.nix # Auth wrapper generator -│ └── types.nix # Custom types for options +│ ├── default.nix # Library entry point (mkSandboxConfig, mkAgentWrapper, etc.) +│ ├── mkSandboxConfig.nix # Container NixOS configuration generator +│ └── skills.nix # Skill content generation +│ +├── docs/ # Documentation (mdBook) │ └── packages/ - └── forage-ctl/ # CLI management tool + └── forage-ctl/ # CLI management tool (Go) ├── default.nix - └── forage-ctl.sh + ├── main.go + ├── cmd/ # CLI commands + └── internal/ # Business logic + ├── config/ # Configuration loading + ├── generator/ # Nix config generation + ├── injection/ # Contribution/injection system + ├── network/ # Network isolation + ├── proxy/ # API proxy + ├── runtime/ # Container runtimes + ├── sandbox/ # Sandbox lifecycle + ├── skills/ # Project analysis for skills + └── workspace/ # VCS workspace backends ``` ## Configuration Interface @@ -273,32 +560,44 @@ claude claude full Claude Code agent sandbox multi claude,opencode full Multi-agent sandbox isolated claude none Network-isolated sandbox -# Create and start a sandbox +# Create and start a sandbox (with workspace directory) forage-ctl up --template