diff --git a/EXTENSIONS_PLAN.md b/EXTENSIONS_PLAN.md new file mode 100644 index 0000000..38110e5 --- /dev/null +++ b/EXTENSIONS_PLAN.md @@ -0,0 +1,276 @@ +# Rootcell Extensions Implementation Plan + +Status: Phase 5 documentation and migration notes are implemented; the V1 Rootcell extensions plan is complete. + +## Goal + +Add an opt-in Rootcell extension mechanism so optional Rootcell capabilities can be installed and activated per instance without shipping every optional integration to every user by default. + +Rootcell extensions are not the same thing as Pi extensions. A Rootcell extension may install/configure Pi extensions, add host commands/tunnels, ship firewall services, or eventually target other coding harnesses such as Claude Code or Codex. Pi integration is the first use case, not the abstraction boundary. + +The initial two built-in Rootcell extension ids are `pi-plannotator` and +`pi-subagents` because they install/configure Pi resources. The `pi-` prefix is +part of the name, not the extension abstraction. + +Initial target extensions: + +- Existing Pi `subagent` package/extension currently installed for every agent VM by `home.nix`; should move behind opt-in. +- Plannotator Pi package (`@plannotator/pi-extension` / `apps/pi-extension`) which requires host-browser access to a server running inside the agent VM. +- The existing `spy` feature is a plausible future Rootcell extension, reinforcing that the feature must not be coupled one-to-one to Pi extensions. + +## Resolved decisions summary + +- Rootcell extensions are per-instance opt-ins, persisted in host-side `instances//extensions.txt`. +- `extensions.txt` is dotenv-style `id=true|false`, seeded with all known extensions set to `false`. +- Initial UX: `rootcell extension list`, `rootcell extension enable `, `rootcell extension disable `, and `rootcell edit extensions`. +- Extension-specific commands live under `rootcell extension ...`; initial operational command is `rootcell extension pi-plannotator tunnel`. +- `extension enable/disable` seed config files if needed, are idempotent, do not provision, and always print instance-qualified provision guidance. +- Operational extension commands require the selected existing instance to have that extension enabled; completions should hide disabled extension command groups. +- Rootcell extension definitions are static built-ins for V1, but shaped for future third-party extraction. +- Rootcell extensions are harness-agnostic and not 1:1 with Pi extensions. +- Guest contributions are separate NixOS/Home Manager module fragments hooked into agent NixOS, firewall NixOS, and agent Home Manager via generated aggregators. +- Built-in guest/Nix payloads live under top-level `extensions/`; TypeScript registry/host command code lives under `src/rootcell/extensions/`. +- Generated aggregators live in the per-instance generated dir, are copied explicitly into the VM, and use repo-relative imports. +- Extension resources should be installed declaratively via Nix/Home Manager, not by imperative host copies into final guest paths. +- Do not let Home Manager own Pi's user-editable `~/.pi/agent/settings.json`; use Pi auto-discovery/package-compatible filesystem locations. +- Plannotator uses the published npm package if suitable, installed through Nix/Home Manager while preserving source/package inspectability. +- Plannotator sets `PLANNOTATOR_REMOTE=true` and `PLANNOTATOR_PORT=19432` in the enabled Home Manager module. +- Plannotator tunnel goes through firewall ProxyJump to the agent VM, binds host side to `127.0.0.1`, prefers local port `19432`, chooses another free local port on conflict, prints the URL, and stays foreground until Ctrl-C. +- Plannotator tunnel does not start/provision VMs, does not health-check the service, does not open a browser, and is separate from running Pi. +- `pi-subagents` opt-in preserves current behavior: Pi subagent extension plus example agents. +- Existing VMs keep old subagent files until explicit provision; after provisioning with `pi-subagents=false`, managed subagent resources are removed. +- Testing should focus on the Rootcell extension framework, command dispatch, generated config, completions, and tunnel wiring, not Plannotator product behavior. + +## Non-goals for V1 + +- No third-party Rootcell extension loading yet; keep the built-in registry extractable for later. +- No auto-provision or extension drift detection; extension changes require explicit `./rootcell provision`. +- No background tunnel supervision or tunnel daemon management. +- No Rootcell integration tests for actual Plannotator product behavior. +- No Home Manager ownership of Pi's user-editable `~/.pi/agent/settings.json`. + +## Observed codebase facts + +- The host command is implemented in TypeScript under `src/rootcell/` and exposed by `src/bin/rootcell.ts`. +- Rootcell currently has a flat set of built-in host subcommands in `src/rootcell/metadata.ts` and `src/rootcell/args.ts`. +- Agent provisioning copies a fixed file set (`VM_FILES.agent`) and builds `agent-vm.nix` plus `home.nix` via Home Manager. +- `home.nix` currently unconditionally installs: + - Pi itself. + - Rootcell skills under `~/.pi/agent/skills/`. + - The Pi `subagent` extension under `~/.pi/agent/extensions/subagent`. + - Example subagent agent definitions under `~/.pi/agent/agents/`. +- The `jmp/plan-browser-spy-replacement` branch adds a reusable-looking host-to-guest local port forward concept (`VmProvider.forwardLocalPort`) for browser UI access. +- Plannotator starts HTTP servers from inside Pi and uses: + - `PLANNOTATOR_REMOTE` to force remote-session behavior. + - `PLANNOTATOR_PORT`, defaulting to `19432` for remote sessions. + - browser URLs of the form `http://localhost:`. + - `0.0.0.0` binding in remote mode and `127.0.0.1` otherwise. + +## Architecture + +1. Add a static built-in Rootcell extension registry describing optional features for the first iteration. Design the registry shape as a future public extension-definition API so third-party Rootcell extensions can be supported later without a rewrite. Include human-readable descriptions for help/docs, but default `extension list` only shows id/status. Do not include `defaultEnabled` in the registry; all known extensions are seeded as disabled in `extensions.txt` by policy. +2. Persist enabled extensions per Rootcell instance in a human-editable `extensions.txt` file at `instances//extensions.txt`, next to that instance's `.env` and `secrets.env` files. Add this path to `instancePaths` as `extensionsPath`. The file is dotenv-like and seeded with every known extension defaulting to false, e.g. `pi-plannotator=false` and `pi-subagents=false`, so users can discover available extensions without the CLI. If missing for an existing instance, seed it the same way and log the path. Comments and blank lines are supported and preserved. Existing ordering is preserved. Missing newly-known extension keys are appended with `false`. Unknown keys are warned about and ignored by the current Rootcell version, but preserved when the CLI rewrites the file for forward/backward compatibility. +3. During provisioning, render generated Nix/config that installs only enabled Rootcell extension resources. Extension-provided guest files should be installed by NixOS/Home Manager modules or Nix derivations wherever possible, not manually copied into final guest locations by the host CLI. Rootcell may still copy the Rootcell repo/generated Nix inputs into the VM so Nix can evaluate them, but ownership of guest-visible extension resources should be declarative through Nix/Home Manager. Model this as hook points in the master guest configs: agent NixOS, firewall NixOS, and agent Home Manager import generated extension module lists. Extensions create/implement separate module files referenced by those masters; extensions must not edit the master config files themselves. +4. Add host command surface to manage extensions under `rootcell extension`. Parse `extension` as a top-level Rootcell subcommand that captures the rest; dispatch nested commands manually through an extension command dispatcher rather than modeling every nested command directly in yargs. This supports dynamic enabled-extension completions now and future custom extension completion behavior. Initial commands: + - `rootcell extension list` showing all known extensions and enabled/disabled status, plus a warning/list for unknown valid keys found in `extensions.txt`; do not include a `requiresProvision`/apply column in the first iteration + - `rootcell extension enable ` + - `rootcell extension disable ` + - `rootcell extension pi-plannotator tunnel` +5. Reuse or generalize the SSH local-port forwarding implementation from the spy-browser branch for browser-backed extensions. +6. Implement Plannotator as the first browser-backed extension: + - install/configure the Plannotator Pi package in the agent VM only when opted in; + - set remote-friendly environment variables for Pi sessions; + - provide a host command that opens/maintains a tunnel from host localhost to the agent VM Plannotator server port via the firewall/SSH transport. +7. Move the built-in `subagent` Pi extension and bundled example agents behind the same opt-in mechanism. + +## Detailed decision log + +### Scope and registry + +- Rootcell extensions are harness-agnostic and not one-to-one with Pi extensions. A Rootcell extension can contribute Pi resources, other harness resources, host commands, tunnels, firewall services, or guest modules. +- The first iteration uses a static built-in registry only, but the registry shape should be future-compatible with third-party Rootcell extension definitions. +- Registry metadata should include ids, descriptions, `requiresProvision`, guest hook contributions, and extension host commands. It should not include `defaultEnabled`; all known extensions are seeded disabled by policy. +- Extension ids use lowercase kebab-case suitable for CLI and file keys. +- Registry descriptions are for help/docs; default list output remains id/status only. + +### Instance config and UX + +- Extensions are enabled/disabled per Rootcell instance, not globally or per invocation. +- Persist state in host-side `instances//extensions.txt`, exposed as `instancePaths(...).extensionsPath`. Do not copy this file into the VM. +- `extensions.txt` is seeded with all known extensions set to `false`, e.g. `pi-plannotator=false` and `pi-subagents=false`. Missing files for existing instances are seeded the same way and logged. +- Parse `extensions.txt` with Rootcell dotenv-style semantics: skip blank/comment lines, split on the first `=`, and treat missing `=` as an empty value. +- Boolean parsing accepts `true`, `1`, `yes`, `on` as true; `false`, `0`, `no`, `off`, and empty values as false. Invalid key syntax or invalid boolean values fail clearly. +- Comments, blank lines, and existing ordering are preserved. Missing newly-known extension keys are appended with `false`. Unknown valid keys are warned/ignored by this version but preserved on rewrite. +- Legacy first-party keys `plannotator` and `subagent` are migrated to `pi-plannotator` and `pi-subagents` when Rootcell rewrites `extensions.txt`. +- `extension enable`/`disable` reject unknown ids for V1. Unknown valid keys may be preserved if already present, but the CLI should not create them until third-party definitions exist. +- Initial UX: `rootcell extension list`, `rootcell extension enable `, `rootcell extension disable `, and `rootcell edit extensions`. +- `rootcell extension list` seeds config if missing, shows all known extension ids and enabled status, and reports unknown valid keys separately. It should not load provider config or secrets and should not show `requiresProvision`/apply columns. +- `extension enable`/`disable` seed config files if needed, are idempotent, do not provision, and always print instance-qualified provision guidance because prior enable/disable may not have been provisioned yet. +- `rootcell extension` with no nested command is incomplete/invalid and should show help/completion guidance; missing ids/subcommands are parse errors, not prompts. +- Editing extensions while VMs are running is allowed, but changes apply only after explicit `./rootcell provision`. +- The file contains no secrets; normal user-editable permissions (`0644` subject to umask) are fine, preserving existing permissions where practical. + +### Command parsing and completion + +- Add `extension` as a top-level Rootcell subcommand that captures the rest. Dispatch nested commands manually through an extension command dispatcher rather than modeling all nested commands in yargs. +- Extension-specific host commands live under `rootcell extension ` to avoid top-level namespace clutter. +- Extension host commands use a minimal registry interface (`name`, `description`, `complete`, `run`) and a narrow V1 context, not the full `RootcellApp`. Expose only required helpers/data such as config, provider/tunnel helper, logging, enabled-state helpers, and VM-running checks. +- Enabled state gates extension-specific operational command availability. `requiresProvision` controls enable/disable guidance only. +- Dynamic shell completion should inspect current words, including `--instance`/`-i`, read that instance's `extensions.txt`, and offer operational extension ids only when enabled. +- Completion must not write/seed files; if `extensions.txt` is missing, assume all known extensions are disabled. +- `rootcell extension ` should always include management commands (`list`, `enable`, `disable`) plus enabled extension ids. `enable ` suggests disabled known ids; `disable ` suggests enabled known ids. + +### Guest configuration hooks + +- Provide hook points for agent NixOS, firewall NixOS, and agent Home Manager. +- Master configs (`agent-vm.nix`, `firewall-vm.nix`, `home.nix`) import generated aggregator modules. Each enabled extension contributes separate module fragment file(s); generated aggregators compose them with Nix `imports = [ ... ]`. Extensions never mutate master files or shared aggregators directly. +- Generated aggregators live in the per-instance generated directory, are copied explicitly into the VM as `generated/extensions-agent-vm.nix`, `generated/extensions-firewall-vm.nix`, and `generated/extensions-home-manager.nix`, and use repo-relative imports. Do not copy the whole generated directory. +- Rootcell writes valid empty aggregators before provisioning/evaluation, and master configs guard imports with `builtins.pathExists` for clean checkout/direct evaluation safety. +- Write/update aggregators before any path that may evaluate guest Nix, not only during `provision`. +- Extension guest resources should be installed declaratively via NixOS/Home Manager modules or Nix derivations, not by imperative host copies into final guest paths. Rootcell may still copy repo/generated Nix inputs into the VM for evaluation. +- Do not add new extension-specific `specialArgs` in V1. Extension modules receive existing args (`username` today) and keep constants in their own files unless a future API needs more. +- TypeScript registry/host command code lives under `src/rootcell/extensions/`; built-in guest/Nix payloads live under top-level `extensions/`. Keep path/metadata design extractable so built-ins can later move out-of-repo. +- Copy top-level `extensions/` to both VMs initially. Activation still happens only through generated imports. +- Hook model may represent firewall-only contributions, but do not support firewall-only Rootcell operation; Rootcell remains an agent+firewall system. +- Do not add extension-specific provisioned-state checks or auto-provision behavior in V1. Explicit `./rootcell provision` is required after extension changes. Log enabled extensions concisely during provision. + +### Pi integration + +- Nix controls pinned package content and Pi loads resources through normal mechanisms. +- Do not let Home Manager own or clobber user-editable `~/.pi/agent/settings.json`. Pi code inspection found only global/project `settings.json` sources and no separate settings fragment/include mechanism. +- Use Pi auto-discovery/package-compatible filesystem locations for Rootcell-managed resources. Pi auto-discovers `~/.pi/agent/extensions`, `skills`, `prompts`, and `themes`; for an extension directory, `package.json` with a `pi` manifest is honored before `index.ts`/`index.js` fallback. +- Home Manager may manage specific Rootcell-owned files/subdirectories under Pi auto-discovery roots while leaving parent directories user-writable. +- Leave exact Plannotator package-compatible filesystem layout to implementation, constrained by: do not own `settings.json`, preserve Pi loading semantics, and keep package/source files inspectable. + +### Plannotator + +- Prefer the published npm package `@plannotator/pi-extension`, pinned by version/hash, if it contains source-like extension files and built browser assets needed at runtime. +- Install/configure via Nix/Home Manager during VM provisioning, following the existing Pi/subagent pattern. Do not use mutable in-VM `pi install` or host-side prefetch/copy cache. +- Preserve a source-like npm package layout in the VM so Pi and the agent can inspect JS/TS code; do not rename/reshape it just because the Rootcell extension id is `pi-plannotator`. +- Set `PLANNOTATOR_REMOTE=true` and `PLANNOTATOR_PORT=19432` in the enabled Plannotator Home Manager module/user environment so any Pi invocation sees them. +- Keep Plannotator tunnel and Pi execution separate. Users run `rootcell extension pi-plannotator tunnel` in one terminal and start Pi normally in another. +- `rootcell extension pi-plannotator tunnel` requires the selected existing instance to have Plannotator enabled and the agent VM running. It does not seed, start, provision, or health-check. +- The tunnel goes through the firewall via SSH ProxyJump to the agent VM, forwarding to agent-side port `19432`. Bind host side to `127.0.0.1` only. +- Prefer host local port `19432`; if busy, choose another free localhost port and print the actual URL. +- Foreground until Ctrl-C only. Do not add background mode, browser auto-open, or `--open` in V1. Print a concise forwarding/Ctrl-C message and the host URL. +- Tunnel metadata should support both agent and firewall target roles for future extensions. Reuse the generic `VmProvider.forwardLocalPort` / SSH tunnel implementation from the spy browser branch once merged. + +### Subagent migration + +- Move current unconditional subagent install into the `pi-subagents` Rootcell extension's Home Manager hook. +- Enabling `pi-subagents` preserves current behavior: install the Pi subagent extension plus bundled example agents (`planner.md`, `reviewer.md`, `scout.md`, `worker.md`). +- All extensions default false. Existing VMs keep current files until explicit provision; after provisioning with `pi-subagents=false`, managed subagent resources are removed. Document how to opt back in. + +### Testing + +- Test the Rootcell extension framework: config parsing/rewrites, boolean handling, comment/unknown-key preservation, generated Nix aggregators, command dispatch, explicit-provision workflow, dynamic completions across instances, and tunnel setup behavior. +- Do not add Rootcell integration tests for actual Plannotator product usage. Plannotator-specific behavior belongs in the extension's own repository when it is moved out. + +## Recommended implementation file/module breakdown + +- `src/rootcell/extensions/registry.ts` — static built-in registry, future-shaped for third-party definitions. +- `src/rootcell/extensions/config.ts` — `extensions.txt` seeding, parsing, preserving, enable/disable rewrites, enabled-state queries. +- `src/rootcell/extensions/commands.ts` — `rootcell extension ...` nested command dispatcher and completion helpers. +- `src/rootcell/extensions/nix.ts` — generated Nix aggregator rendering for agent NixOS, firewall NixOS, and agent Home Manager hooks. +- `extensions/pi-subagents/home-manager.nix` — Home Manager hook module preserving current subagent behavior behind opt-in. +- `extensions/pi-plannotator/home-manager.nix` — Home Manager hook module for Plannotator package/env setup. +- `agent-vm.nix`, `firewall-vm.nix`, `home.nix` — master configs gain stable guarded imports of generated hook aggregators only. +- `src/rootcell/instance.ts` / types — add `extensionsPath`. +- `src/rootcell/args.ts` / metadata — add top-level `extension` command capture and completion routing. +- `src/rootcell/rootcell.ts` — call extension config generation/copy helpers and dispatch extension commands without hard-coding individual extension behavior. + +## Risks / implementation unknowns + +- Exact Nix packaging method for `@plannotator/pi-extension` while preserving a source-like npm package layout and including its runtime dependencies/assets. +- Exact Pi auto-discovery/package-compatible filesystem layout for Plannotator under Rootcell-managed paths; implementation should validate against Pi's resource loader behavior. +- Home Manager migration behavior when moving current subagent symlinks out of unconditional `home.nix` and behind the `pi-subagents` extension. +- Availability/API shape of the generic `forwardLocalPort` implementation from the spy browser branch at implementation time. +- Future third-party Rootcell extension extraction: keep built-in registry/path assumptions contained so external extension definitions can be loaded later. + +## Implementation phases + +### Phase 1: Core extension model + +Implementation update: the first Phase 1 slice added a Zod-validated built-in extension registry, per-instance `extensions.txt`, management CLI commands, dynamic completions, generated Nix hook aggregators, guarded master imports, explicit generated-file VM copy wiring, and moved the Pi `subagent` extension/example agents behind `pi-subagents=true`. Verification passed with `bun run typecheck`, `bun run lint`, `bun run test:unit:vitest`, and lightweight Nix evals for the agent, firewall, and Home Manager entrypoints. + +Implementation update: the host-command registry slice added `RootcellExtensionDefinition.hostCommands`, validated command metadata, async extension-owned command dispatch under `rootcell extension `, metadata-driven enable/disable guidance through `requiresProvision`, enabled-state gating for operational commands, dynamic completions for enabled command groups, and a narrow `ExtensionHostCommandContext` exposing only config, logging, VM status, and local-port-forward helpers. The slice intentionally did not add `pi-plannotator tunnel`; that remains a later tunnel/Plannotator task. Verification passed with `bun run typecheck`, `bun run lint`, `bun run test:unit:vitest`, and `git diff --check`. + +- [X] Define extension ids and metadata in TypeScript. +- [X] Do not model per-extension defaults in the registry; seed all known extension keys as `false` in `extensions.txt`. +- [X] Include `requiresProvision` in extension metadata so the CLI can print accurate next steps. +- [X] Include a minimal extension host command registry interface even in the first implementation: an extension can define commands with `name`, `description`, `complete`, and `run`. +- [X] Pass extension host commands a narrow V1 context rather than the entire `RootcellApp`: include only what is needed, such as config, providers/tunnel helper, logging, enabled-state helpers, and VM-running checks. +- [X] Model guest-side extension contributions as declarative hook modules for each master config: `agent-vm.nix`, `firewall-vm.nix`, and `home.nix`. +- [X] Store first-party built-in guest/Nix payload files under a top-level `extensions/` directory, e.g. `extensions/pi-subagents/home-manager.nix` and `extensions/pi-plannotator/home-manager.nix`. +- [X] Store TypeScript registry/host command implementation under `src/rootcell/extensions/`. +- [X] Treat these built-in locations as the current first-party source layout, not as a permanent coupling: design paths/metadata so these built-ins can later move out of the repository when third-party Rootcell extensions are supported. +- [X] Copy the top-level `extensions/` directory to both VMs with the Rootcell repo inputs in the first implementation. Nix only imports enabled fragments through generated aggregators; selective copying can come later if needed. +- [X] Add stable hook imports in the master files, e.g. generated aggregator modules under `generated/extensions-*.nix`. +- [X] Write generated aggregator files to the existing per-instance generated directory (`instances//generated/`) and copy them into the VM as part of the generated inputs; do not write per-instance generated files into the Git working tree. +- [X] Copy only the expected generated hook files explicitly (`extensions-agent-vm.nix`, `extensions-firewall-vm.nix`, `extensions-home-manager.nix`) rather than copying the entire generated directory, to avoid stale/extra artifacts. +- [X] Rootcell should always write valid empty aggregators before provisioning/evaluation, and master configs should also guard imports with `builtins.pathExists` so direct evaluation/tests on a clean checkout do not fail. +- [X] Generated aggregators should use repo-relative import paths, not absolute host paths, because the repo is copied into and evaluated inside the VM. +- [X] Each extension owns its own NixOS/Home Manager module fragment file(s). Multiple extensions compose because the generated aggregator contains an `imports = [ ... ]` list of enabled extension module paths; extensions never modify the aggregator themselves and never modify master config files. +- [X] Extensions provide separate NixOS/Home Manager module files or Nix package paths referenced by those hooks; extensions do not directly modify master config files. +- [X] Do not add new extension-specific `specialArgs` in the first iteration. Extension modules receive the same args as existing NixOS/Home Manager modules (`username` today) and should keep constants in their own module/sibling Nix files. +- [X] Avoid host-side imperative copying into final guest paths for extension resources. +- [X] Add parsing/storage for enabled extension ids. +- [X] Add generated Nix file(s) consumed by the master hook imports to conditionally include enabled extension modules. +- [X] Write/update generated extension aggregators before any command path that may evaluate guest Nix (provision and normal ensure paths), even though extension changes still require explicit provision to take effect. +- [X] During `provision`, log the enabled extension set concisely (including `none`). +- [X] Change `home.nix` so the Pi subagent package is no longer unconditional and is provided by the `pi-subagents` Home Manager hook module. +- [X] The `pi-subagents` Rootcell extension should preserve current behavior behind opt-in: install the Pi subagent extension and the bundled example agents (`planner.md`, `reviewer.md`, `scout.md`, `worker.md`). +- [X] Add tests around the Rootcell extension framework itself: parsing, boolean handling, comment preservation, unknown-key preservation, config generation, explicit-provision workflow checks, dynamic completions based on `extensions.txt` plus selected `--instance`, and extension-owned command dispatch. +- [X] Do not add integration tests for actual Plannotator product usage in Rootcell; that belongs with the Plannotator extension when it moves out. +- [X] Extension management commands, including `extension list`, seed/create instance config files when needed, so users can discover/enable extensions before first VM boot. +- [X] Add `extensions` to the existing `rootcell edit` targets so users can run `rootcell edit extensions`. +- [X] Keep `extensions.txt` host-side only. Do not copy it into the VM; Rootcell reads it and generates Nix hook aggregators/env behavior from it. +- [X] Extension management commands only edit `extensions.txt`; they do not automatically provision. After enable/disable, print a clear instance-qualified provision message, e.g. `run ./rootcell --instance provision to apply VM changes`. + +### Phase 2: Host tunnel primitive + +Implementation update: the Phase 2 slice added shared tunnel helpers for local-port selection, role-target tunnel specs, and foreground tunnel lifecycle/close behavior; refactored `rootcell spy` to use those helpers while preserving its URL/output behavior; and added unit coverage for port fallback/exhaustion, role-target forwarding, SSH local-forward command construction/failure, and spy fallback/lifecycle wiring. Verification passed with `bun run typecheck`, `bun run lint`, `bun run test:unit:vitest`, and `git diff --check`. + +- [X] Reuse the generic `VmProvider.forwardLocalPort` / SSH local-forwarding components from the spy browser branch, assuming they are likely merged before implementation. +- [X] Model tunnel metadata with target VM role (`agent` or `firewall`) plus remote host/port, so Plannotator can target the agent and future spy-like extensions can target the firewall. +- [X] Add generic tunnel primitive tests for SSH config/local-forward command construction, target role mapping, bind/remote host-port wiring, and tunnel lifecycle/close behavior. + +### Phase 3: Plannotator extension package/install + +Implementation update: the Phase 3 slice added a Nix package for `@plannotator/pi-extension@0.19.16`, pinned by npm tarball hash plus committed runtime dependency lock; installs a source-like package root with manifest, TypeScript files, browser HTML assets, skills, and `node_modules`; links it into Pi's extension auto-discovery tree through the enabled Home Manager hook; and sets/wraps Pi with `PLANNOTATOR_REMOTE=true` and `PLANNOTATOR_PORT=19432` without managing Pi settings. Verification passed with `bun run typecheck`, `bun run lint`, `bun run test:unit:vitest`, host-system Nix package build, and Home Manager module evals. + +- [X] Package Plannotator through Nix/Home Manager, not runtime `pi install` inside the agent VM. +- [X] Fetch the published npm package `@plannotator/pi-extension`, pinned by version/hash, if it contains the needed source-like files and built HTML/generated assets. +- [X] Follow the existing Pi/subagent provisioning pattern: pinned Nix fetch/build inside VM provisioning, through the firewall-controlled network path, rather than a Rootcell host-side source cache. +- [X] Preserve a source-like package layout in the VM so Pi and the agent can inspect JS/TS extension code, similar to an npm-installed package, rather than only seeing an opaque bundled output. +- [X] Install/configure Plannotator using Pi's normal package model and package identity (`@plannotator/pi-extension`) rather than renaming it to the Rootcell extension id. +- [X] Let Nix control the pinned package content, while Pi loads it through normal package mechanisms. +- [X] Do not have Home Manager own/clobber `~/.pi/agent/settings.json`, because that is a user-editable Pi settings file. +- [X] Based on Pi code inspection, Pi loads settings only from `~/.pi/agent/settings.json` and `.pi/settings.json`; no separate Rootcell-managed settings fragment/include was found. +- [X] Use Pi auto-discovery/package-compatible filesystem locations instead. Pi auto-discovers `~/.pi/agent/extensions`, `skills`, `prompts`, and `themes`; for an extension directory, `package.json` with a `pi` manifest is honored before falling back to `index.ts`/`index.js`. +- [X] It is acceptable for Home Manager to manage specific Rootcell-owned files/subdirectories under these auto-discovery roots while leaving the parent directories user-writable. +- [X] Leave the exact package-compatible filesystem layout for Plannotator to the implementing agent, subject to the constraints above: do not own `settings.json`, preserve Pi's normal loading semantics, and keep package/source files inspectable. +- [X] Ensure Pi sessions receive `PLANNOTATOR_REMOTE=true` and `PLANNOTATOR_PORT=19432` when the Rootcell Plannotator extension is enabled by setting them in the Plannotator Home Manager module/user environment, not via one-off host session injection. + +### Phase 4: Plannotator host command + +Implementation update: the Phase 4 slice added `rootcell extension pi-plannotator tunnel` as an extension-owned host command in `src/rootcell/extensions/pi-plannotator.ts` and registered it from the built-in extension registry. The command requires `pi-plannotator=true` and a running agent VM, forwards host `127.0.0.1:` to agent `127.0.0.1:19432` with local port fallback, prints the URL, and keeps the SSH tunnel in the foreground until Ctrl-C. It intentionally does not start/provision VMs, health-check Plannotator, launch a browser, or add background supervision. Tests in `src/rootcell/extensions/pi-plannotator.test.ts` cover enabled-state gating, completions, argument validation, VM-state failures, tunnel wiring, URL output, and foreground close behavior. Verification passed with `bun run typecheck`, `bun run lint`, `bun run test:unit:vitest`, and `git diff --check`. + +- [X] Add a host command to open/hold the SSH tunnel through the firewall ProxyJump to the agent VM, forwarding host `127.0.0.1:` to the Plannotator service on the agent (`127.0.0.1:19432` or agent private IP if binding requires it). +- [X] Require `pi-plannotator=true` before `rootcell extension pi-plannotator tunnel` runs; if disabled, fail with guidance to enable and provision. Dynamic completions should not offer this command path for instances where Plannotator is disabled. +- [X] Require an existing instance and the agent VM to already be running; do not seed, start, or provision from the tunnel command. Fail with guidance to enable/provision/start as appropriate. +- [X] Do not require or perform a Plannotator service health check before opening the tunnel; the expected workflow often starts the tunnel before Pi opens a Plannotator review server. +- [X] Keep the tunnel in the foreground until Ctrl-C; do not add background mode until Rootcell has an intentional process supervision/story for stopping background tunnels. +- [X] Print the host URL; do not automatically open a browser and do not add `--open` in the first iteration. +- [X] Print a concise message that the command is forwarding a localhost URL to the Plannotator server in the agent VM and that Ctrl-C stops the tunnel. +- [X] Prefer local port `19432`, but if it is busy choose another free localhost port and print the actual URL. The remote agent-side port remains `19432`. +- [X] Provide clear readiness/error messages. +- [X] Add Plannotator host-command tests for enabled-state gating, existing/running agent VM requirements, local port selection, `forwardLocalPort("agent", ...)` wiring, URL output, no browser launch, and no service health check. + +### Phase 5: Documentation and migration + +Implementation update: the Phase 5 documentation slice added a README Extensions section explaining per-instance opt-ins in `instances//extensions.txt`, management commands, explicit provision requirements, the Plannotator tunnel workflow, and the subagent migration. Related README examples were refreshed in Daily Workflow, Common Changes, Customize Pi, and Project Layout. Verification passed with a docs-focused `rg` check for the new extension terms and `git diff --check`. + +- [X] Document the extension concept, commands, and Plannotator workflow. +- [X] Document the subagent migration clearly: existing VMs keep current files until explicit provision, but after provisioning with `pi-subagents=false`, Home Manager removes the previously managed subagent extension/example agents. Users who rely on it must run `./rootcell extension enable pi-subagents && ./rootcell provision`. +- [X] Add README examples. diff --git a/README.md b/README.md index 0beedc5..23516b9 100644 --- a/README.md +++ b/README.md @@ -89,9 +89,11 @@ The two VMs have the same roles in either provider: | `firewall` VM | Owns the public egress path. It runs `dnsmasq` for DNS allowlisting and `mitmproxy` for HTTPS interception and SSH CONNECT policy. | | `./rootcell` | Host-side wrapper that creates, provisions, updates, and enters the VMs. It also syncs allowlists and injects configured provider secrets for each session. | -Rootcell supports named instances. Plain `./rootcell` uses the `default` -instance and creates VMs named `agent` and `firewall`. `./rootcell --instance -dev` creates `agent-dev` and `firewall-dev`, with separate CA material, +Rootcell supports named instances. Plain `./rootcell` uses the selected default +instance, initially `default`, and creates VMs named `agent` and `firewall` for +that default instance. `./rootcell select dev` makes `dev` the default target. +`./rootcell --instance dev` overrides the selected default for one invocation +and creates `agent-dev` and `firewall-dev`, with separate CA material, allowlists, secret mappings, provider state, and private network configuration. HTTPS egress is transparent from inside the agent VM. A normal command like @@ -210,12 +212,14 @@ state root. ```bash ./rootcell # open a bash shell inside the agent VM -./rootcell pi # run pi directly +./rootcell select dev # use the dev instance by default +./rootcell -- pi # run pi directly ./rootcell -- nix flake update # run any command inside the agent VM ./rootcell edit env # edit the instance .env in $EDITOR ./rootcell edit http # edit the HTTPS allowlist in $EDITOR ./rootcell edit dns # edit the DNS allowlist in $EDITOR ./rootcell edit ssh # edit the SSH allowlist in $EDITOR +./rootcell edit extensions # edit instance extension opt-ins in $EDITOR ./rootcell allow # reload network allowlists after editing them ./rootcell provision # rebuild/re-provision after VM Nix or pi config edits ./rootcell pubkey # print the agent VM's SSH public key @@ -223,18 +227,88 @@ state root. ./rootcell stop --instance dev # stop the dev instance VMs ./rootcell remove --instance dev # stop dev and delete its provider VM state ./rootcell spy # open the browser spy through a local SSH tunnel +./rootcell extension list # show optional extensions for this instance +./rootcell extension enable pi-plannotator # enable an extension for next provision ./rootcell -i aws-dev --init-env aws-ec2 # initialize a provider-specific instance .env ./rootcell -i local --init-env macos-lima # initialize an explicit local Lima .env -./rootcell --instance dev # open the dev instance shell -./rootcell --instance dev edit env # edit the dev instance environment -./rootcell --instance dev edit dns # edit the dev instance DNS allowlist -./rootcell --instance dev allow # reload only the dev instance allowlists +./rootcell --instance dev # open the dev instance shell once +./rootcell --instance dev edit env # edit the dev instance environment once +./rootcell --instance dev edit dns # edit the dev instance DNS allowlist once +./rootcell --instance dev allow # reload only the dev instance allowlists once ``` Detailed browser spy operator and developer notes live in [src/spy/README.md](src/spy/README.md). +## Extensions + +Rootcell extensions are per-instance opt-ins for optional Rootcell capabilities. +They are rootcell concepts, not Pi concepts. Some extensions install Pi +resources, but others could add VM packages, host commands, local tunnels, +firewall services, protocol support, allowlists, or guest NixOS and Home Manager +modules without involving Pi at all. +For example, a future `lazyvim` extension could install editor tooling in the +agent VM, while an `ftp` extension could add firewall protocol support and +allowlist entries. + +The first built-ins are Pi-related, so their IDs carry a `pi-` prefix: +`pi-plannotator` and `pi-subagents`. +Older `plannotator` and `subagent` keys in `extensions.txt` are migrated to +those names when rootcell rewrites the file. + +Each instance stores its enabled extensions in +`instances//extensions.txt`, or under the configured +`ROOTCELL_STATE_DIR`. The file is seeded with all known extensions disabled. +Enabling or disabling an extension only edits that file; run +`./rootcell provision` afterward to apply VM changes. + +```bash +./rootcell extension list +./rootcell extension enable pi-plannotator +./rootcell extension disable pi-plannotator +./rootcell edit extensions + +./rootcell --instance dev extension list +./rootcell --instance dev extension enable pi-subagents +./rootcell --instance dev provision +``` + +### Plannotator + +The `pi-plannotator` extension installs the Plannotator Pi package in the agent +VM and configures Pi sessions for remote browser access. A typical workflow is: + +```bash +./rootcell extension enable pi-plannotator +./rootcell provision + +# Terminal 1: keep the tunnel open. +./rootcell extension pi-plannotator tunnel + +# Terminal 2: start Pi normally. +./rootcell -- pi +``` + +The tunnel command requires `pi-plannotator=true` and a running agent VM. It +prints the localhost URL to open, prefers port `19432`, chooses another local +port if needed, and stays in the foreground until Ctrl-C. It does not start or +provision VMs, health-check the Plannotator server, or open a browser. + +### Pi Subagents + +The `pi-subagents` extension installs the Pi subagent extension and bundled +example agents. It is disabled by default for new provisions. + +Existing VMs can keep the previously managed subagent files until the next +explicit provision. After provisioning with `pi-subagents=false`, Home Manager +removes Rootcell-managed subagent resources. If you rely on them, opt back in +before provisioning: + +```bash +./rootcell extension enable pi-subagents && ./rootcell provision +``` + ## Allowing Network Access Network policy is per instance. On first run, `./rootcell` copies each tracked @@ -268,17 +342,20 @@ repositories by HTTPS request regexes because the firewall only sees Reloading allowlists takes about a second and does not rebuild either VM. To reset a live allowlist to project defaults, delete the live file and run -`./rootcell`; it will be re-seeded from its `.defaults` sibling. For a named -instance, use the same paths under that instance's state directory and run -`./rootcell --instance allow`. +`./rootcell`; it will be re-seeded from its `.defaults` sibling for the selected +instance. For a one-off named instance, use the same paths under that instance's +state directory and run `./rootcell --instance allow`. ## Common Changes After editing these files, run `./rootcell provision`: - `flake.nix`, `common.nix`, `agent-vm.nix`, `firewall-vm.nix`, or `home.nix` +- Anything under `extensions/` - Anything under `pi/` - The checked-in allowlist defaults +- Instance extension opt-ins changed by `./rootcell extension enable `, + `./rootcell extension disable `, or `./rootcell edit extensions` For live allowlist edits only, use `./rootcell allow`. @@ -301,6 +378,11 @@ the agent VM. - `pi/agent/AGENTS.md` becomes the global instruction file. - `pi/agent/skills//SKILL.md` becomes a global pi skill. +Optional Rootcell-managed extension payloads live under the top-level +`extensions/` directory and are installed only when their Rootcell extension is +enabled and provisioned. The current built-ins happen to install Pi resources; +future extensions do not need to. + Add or edit files there, then run `./rootcell provision`. Per-project rules still belong in an `AGENTS.md` or `CLAUDE.md` at the root of @@ -382,7 +464,8 @@ instances/ proxy/ allowlists and mitmproxy/dnsmasq firewall code agent_spy.py Bedrock Runtime spool shim for the browser spy src/spy/ browser spy service, Bedrock adapter, React UI, and docs -pi/agent/ global pi instructions, skills, and extensions +pi/agent/ global pi instructions and skills +extensions/ optional Rootcell extension guest modules and packages ``` ## VM Lifecycle @@ -409,9 +492,10 @@ same instance settings. ### Environment -Use `./rootcell -i --init-env ` to create the selected -instance directory, seed allowlists and secret mappings, and write a -provider-specific `/.env`: +Use `./rootcell --init-env ` to create the selected instance +directory, seed allowlists and secret mappings, and write a provider-specific +`/.env`. Use `-i ` to initialize a different instance for +one invocation: ```bash ./rootcell -i local --init-env macos-lima @@ -424,10 +508,11 @@ plus `ROOTCELL_AWS_PROFILE`, `ROOTCELL_AWS_REGION`, and `ROOTCELL_AWS_CONTROL_CIDR`. The AWS profile and region default from your current host environment when available, otherwise to `default` and `us-east-1`. -Normal `./rootcell` entry also seeds `/.env` from `.env.defaults` -on first run if it does not already exist. Edit that file for instance-local -settings such as these, or run `./rootcell -i edit env` to open it in -`$EDITOR`: +Normal `./rootcell` entry also seeds the selected instance's `.env` from +`.env.defaults` on first run if it does not already exist. Edit that file for +instance-local settings such as these, or run `./rootcell edit env` for the +selected instance. Use `./rootcell -i edit env` to override the selected +default for one edit: ```sh ROOTCELL_VM_PROVIDER=lima @@ -512,6 +597,8 @@ rootcell completion >> ~/.bashrc Named instances are isolated from each other: ```bash +./rootcell select dev +./rootcell ./rootcell --instance dev ./rootcell --instance review ``` @@ -519,6 +606,10 @@ Named instances are isolated from each other: Each instance gets its own VMs, state directory, CA, allowlists, secret mapping file, control SSH key, private network state, and `/24`. +`./rootcell select ` changes the default target without creating the +instance files or starting VMs. `./rootcell select default` returns the default +target to the built-in `default` instance. + The `default` instance still seeds from legacy repo-local `.env`, `secrets.env`, `proxy/allowed-*.txt`, and `pki/` files when present. Named instances seed from the checked-in defaults. @@ -534,7 +625,9 @@ Open the browser spy for captured Bedrock Runtime requests and responses: Check that firewall services are listening: ```bash -INSTANCE_DIR="${ROOTCELL_STATE_DIR:-$PWD/instances}/default" +ROOTCELL_INSTANCES_DIR="${ROOTCELL_STATE_DIR:-$PWD/instances}" +ROOTCELL_INSTANCE="$(cat "$ROOTCELL_INSTANCES_DIR/.selected-instance" 2>/dev/null || printf default)" +INSTANCE_DIR="$ROOTCELL_INSTANCES_DIR/$ROOTCELL_INSTANCE" ssh -F "$INSTANCE_DIR/ssh/config" rootcell-firewall -- \ "ss -tln '( sport = :8080 or sport = :8081 )' && ss -uln '( sport = :53 )'" ``` diff --git a/agent-vm.nix b/agent-vm.nix index a052449..0cde7b5 100644 --- a/agent-vm.nix +++ b/agent-vm.nix @@ -10,7 +10,10 @@ let privateMatch = { Name = net.agentPrivateInterface; }; in { - imports = [ ./common.nix ]; + imports = + [ ./common.nix ] + ++ lib.optional (builtins.pathExists ./generated/extensions-agent-vm.nix) + ./generated/extensions-agent-vm.nix; networking.hostName = "agent-vm"; diff --git a/extensions/pi-plannotator/home-manager.nix b/extensions/pi-plannotator/home-manager.nix new file mode 100644 index 0000000..fbb36f8 --- /dev/null +++ b/extensions/pi-plannotator/home-manager.nix @@ -0,0 +1,29 @@ +{ pkgs, lib, ... }: + +let + pi-coding-agent = import ../../pi/pi-coding-agent.nix { inherit pkgs; }; + plannotator = import ./package.nix { inherit pkgs; }; + plannotatorPi = pkgs.writeShellScriptBin "pi" '' + export PLANNOTATOR_REMOTE=true + export PLANNOTATOR_PORT=19432 + exec ${pi-coding-agent}/bin/pi "$@" + ''; +in +{ + home.packages = [ + (lib.hiPrio plannotatorPi) + ]; + + home.sessionVariables = { + PLANNOTATOR_REMOTE = "true"; + PLANNOTATOR_PORT = "19432"; + }; + + # Pi discovers package-style extension directories under ~/.pi/agent/extensions. + # The package root keeps its npm identity in package.json while Home Manager + # manages only this Rootcell-owned leaf. + home.file.".pi/agent/extensions/@plannotator-pi-extension" = { + source = "${plannotator}/share/pi-packages/@plannotator/pi-extension"; + recursive = true; + }; +} diff --git a/extensions/pi-plannotator/package.nix b/extensions/pi-plannotator/package.nix new file mode 100644 index 0000000..7afd2fc --- /dev/null +++ b/extensions/pi-plannotator/package.nix @@ -0,0 +1,43 @@ +{ pkgs }: + +let + runtimeDeps = pkgs.importNpmLock.buildNodeModules { + npmRoot = ./runtime-deps; + nodejs = pkgs.nodejs; + derivationArgs = { + npmFlags = [ "--legacy-peer-deps" ]; + }; + }; +in +pkgs.stdenvNoCC.mkDerivation rec { + pname = "plannotator-pi-extension"; + version = "0.19.22"; + + src = pkgs.fetchurl { + url = "https://registry.npmjs.org/@plannotator/pi-extension/-/pi-extension-${version}.tgz"; + hash = "sha256-X9JB3e5mgvWylLTtaFgysOnUy7QoCJ7t1MDog23SAoo="; + }; + + nativeBuildInputs = [ + pkgs.gnutar + pkgs.gzip + ]; + + unpackPhase = '' + runHook preUnpack + mkdir source + tar -xzf "$src" -C source --strip-components=1 + cd source + runHook postUnpack + ''; + + installPhase = '' + runHook preInstall + packageRoot="$out/share/pi-packages/@plannotator/pi-extension" + mkdir -p "$packageRoot" + cp -R . "$packageRoot"/ + chmod -R u+w "$packageRoot" + cp -R ${runtimeDeps}/node_modules "$packageRoot/node_modules" + runHook postInstall + ''; +} diff --git a/extensions/pi-plannotator/runtime-deps/package-lock.json b/extensions/pi-plannotator/runtime-deps/package-lock.json new file mode 100644 index 0000000..ca06aa8 --- /dev/null +++ b/extensions/pi-plannotator/runtime-deps/package-lock.json @@ -0,0 +1,616 @@ +{ + "name": "plannotator-runtime-deps", + "version": "0.19.22", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "plannotator-runtime-deps", + "version": "0.19.22", + "dependencies": { + "@joplin/turndown-plugin-gfm": "^1.0.64", + "@pierre/diffs": "^1.1.12", + "turndown": "^7.2.4" + } + }, + "node_modules/@joplin/turndown-plugin-gfm": { + "version": "1.0.67", + "resolved": "https://registry.npmjs.org/@joplin/turndown-plugin-gfm/-/turndown-plugin-gfm-1.0.67.tgz", + "integrity": "sha512-FZfW5EZfidhzd1IaY1uxHnIZPTVOxAdleMZ4/1U6Nt5b7+Qj5JThDnaIomuJtetnUBzuRNbe9FWMuqD4B3dlWA==", + "license": "MIT" + }, + "node_modules/@mixmark-io/domino": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz", + "integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==", + "license": "BSD-2-Clause" + }, + "node_modules/@pierre/diffs": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@pierre/diffs/-/diffs-1.2.3.tgz", + "integrity": "sha512-ul83DHH1yqgGxJAw2tqQm2gDO+oQsaF82ZVocwJYfXAm2FhZyyKPTdtv6jswR4A5eF/ILPjiQxyfScMhQcofbA==", + "license": "apache-2.0", + "dependencies": { + "@pierre/theme": "1.0.3", + "@shikijs/transformers": "^3.0.0", + "diff": "8.0.3", + "hast-util-to-html": "9.0.5", + "lru_map": "0.4.1", + "shiki": "^3.0.0" + }, + "peerDependencies": { + "react": "^18.3.1 || ^19.0.0", + "react-dom": "^18.3.1 || ^19.0.0" + } + }, + "node_modules/@pierre/theme": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@pierre/theme/-/theme-1.0.3.tgz", + "integrity": "sha512-sWHv11TMoqKxKDgTIk5VbhQjdPhs8DCcBxbjh3mRlS3YOM/OcrWoGX6MM8eBGn9cUu3M46Py0JnxsG2nJaFTuA==", + "license": "MIT", + "engines": { + "vscode": "^1.0.0" + } + }, + "node_modules/@shikijs/core": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-3.23.0.tgz", + "integrity": "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4", + "hast-util-to-html": "^9.0.5" + } + }, + "node_modules/@shikijs/engine-javascript": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-3.23.0.tgz", + "integrity": "sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2", + "oniguruma-to-es": "^4.3.4" + } + }, + "node_modules/@shikijs/engine-oniguruma": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.23.0.tgz", + "integrity": "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2" + } + }, + "node_modules/@shikijs/langs": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.23.0.tgz", + "integrity": "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0" + } + }, + "node_modules/@shikijs/themes": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.23.0.tgz", + "integrity": "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0" + } + }, + "node_modules/@shikijs/transformers": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/transformers/-/transformers-3.23.0.tgz", + "integrity": "sha512-F9msZVxdF+krQNSdQ4V+Ja5QemeAoTQ2jxt7nJCwhDsdF1JWS3KxIQXA3lQbyKwS3J61oHRUSv4jYWv3CkaKTQ==", + "license": "MIT", + "dependencies": { + "@shikijs/core": "3.23.0", + "@shikijs/types": "3.23.0" + } + }, + "node_modules/@shikijs/types": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.23.0.tgz", + "integrity": "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==", + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "license": "MIT" + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", + "integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==", + "license": "ISC" + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/diff": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.3.tgz", + "integrity": "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/hast-util-to-html": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", + "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^3.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/lru_map": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/lru_map/-/lru_map-0.4.1.tgz", + "integrity": "sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg==", + "license": "MIT" + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/oniguruma-parser": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.2.tgz", + "integrity": "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==", + "license": "MIT" + }, + "node_modules/oniguruma-to-es": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.6.tgz", + "integrity": "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==", + "license": "MIT", + "dependencies": { + "oniguruma-parser": "^0.12.2", + "regex": "^6.1.0", + "regex-recursion": "^6.0.2" + } + }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", + "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-recursion": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz", + "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-utilities": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", + "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", + "license": "MIT" + }, + "node_modules/shiki": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-3.23.0.tgz", + "integrity": "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA==", + "license": "MIT", + "dependencies": { + "@shikijs/core": "3.23.0", + "@shikijs/engine-javascript": "3.23.0", + "@shikijs/engine-oniguruma": "3.23.0", + "@shikijs/langs": "3.23.0", + "@shikijs/themes": "3.23.0", + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/turndown": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.4.tgz", + "integrity": "sha512-I8yFsfRzmzK0WV1pNNOA4A7y4RDfFxPRxb3t+e3ui14qSGOxGtiSP6GjeX+Y6CHb7HYaFj7ECUD7VE5kQMZWGQ==", + "license": "MIT", + "dependencies": { + "@mixmark-io/domino": "^2.2.0" + }, + "engines": { + "node": ">=18", + "npm": ">=9" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/extensions/pi-plannotator/runtime-deps/package.json b/extensions/pi-plannotator/runtime-deps/package.json new file mode 100644 index 0000000..9938ede --- /dev/null +++ b/extensions/pi-plannotator/runtime-deps/package.json @@ -0,0 +1,11 @@ +{ + "name": "plannotator-runtime-deps", + "version": "0.19.22", + "private": true, + "type": "module", + "dependencies": { + "@joplin/turndown-plugin-gfm": "^1.0.64", + "@pierre/diffs": "^1.1.12", + "turndown": "^7.2.4" + } +} diff --git a/extensions/pi-subagents/home-manager.nix b/extensions/pi-subagents/home-manager.nix new file mode 100644 index 0000000..a7714de --- /dev/null +++ b/extensions/pi-subagents/home-manager.nix @@ -0,0 +1,23 @@ +{ pkgs, ... }: + +let + pi-coding-agent = import ../../pi/pi-coding-agent.nix { inherit pkgs; }; +in +{ + # Pi extensions live under ~/.pi/agent/extensions//. `recursive = true` + # keeps the parent directories writable while Home Manager manages each leaf. + home.file.".pi/agent/extensions/subagent" = { + source = "${pi-coding-agent}/share/pi-coding-agent/examples/extensions/subagent"; + recursive = true; + }; + + # The subagent extension loads agent definitions from ~/.pi/agent/agents/. + home.file.".pi/agent/agents/planner.md".source = + "${pi-coding-agent}/share/pi-coding-agent/examples/extensions/subagent/agents/planner.md"; + home.file.".pi/agent/agents/reviewer.md".source = + "${pi-coding-agent}/share/pi-coding-agent/examples/extensions/subagent/agents/reviewer.md"; + home.file.".pi/agent/agents/scout.md".source = + "${pi-coding-agent}/share/pi-coding-agent/examples/extensions/subagent/agents/scout.md"; + home.file.".pi/agent/agents/worker.md".source = + "${pi-coding-agent}/share/pi-coding-agent/examples/extensions/subagent/agents/worker.md"; +} diff --git a/firewall-vm.nix b/firewall-vm.nix index 2df0c52..3abeefe 100644 --- a/firewall-vm.nix +++ b/firewall-vm.nix @@ -88,7 +88,10 @@ in # can hot-reload them without a guest rebuild. { - imports = [ ./common.nix ]; + imports = + [ ./common.nix ] + ++ lib.optional (builtins.pathExists ./generated/extensions-firewall-vm.nix) + ./generated/extensions-firewall-vm.nix; networking.hostName = "firewall-vm"; environment.systemPackages = [ diff --git a/home.nix b/home.nix index b810e79..0300051 100644 --- a/home.nix +++ b/home.nix @@ -10,48 +10,15 @@ let net = import ./network.nix; - - # Pi (pi.dev) ships a Bun-compiled standalone binary on each release, so - # we don't need Node.js in the VM at all. Pinned by version + sha256 — - # bump both fields together. Latest at: - # https://github.com/badlogic/pi-mono/releases - # - # The release tarball contains the binary plus runtime resources it - # reads at startup (themes, export-html template, a wasm blob, and - # package.json for the version string). Copy the whole unpacked tree - # under $out/share so we don't have to track which files are needed, - # and symlink the binary into $out/bin so `pi` is on PATH. - pi-coding-agent = pkgs.stdenv.mkDerivation rec { - pname = "pi-coding-agent"; - version = "0.74.0"; - - src = pkgs.fetchurl { - url = "https://github.com/badlogic/pi-mono/releases/download/v${version}/pi-linux-arm64.tar.gz"; - sha256 = "261aa912878ca983c903d9c4a0408310dd8637b583085651d9b5ddb70c9df572"; - }; - - # Patch the bundled ELF interpreter to point at glibc inside the Nix - # store; otherwise the binary fails immediately on NixOS, which has no - # /lib64/ld-linux-aarch64.so.1. - nativeBuildInputs = [ pkgs.autoPatchelfHook ]; - buildInputs = [ pkgs.stdenv.cc.cc.lib ]; - - installPhase = '' - runHook preInstall - mkdir -p $out/share/pi-coding-agent $out/bin - cp -r . $out/share/pi-coding-agent/ - ln -s $out/share/pi-coding-agent/pi $out/bin/pi - runHook postInstall - ''; - - # Bun-compiled binaries embed a custom section that strip(1) corrupts. - dontStrip = true; - }; + pi-coding-agent = import ./pi/pi-coding-agent.nix { inherit pkgs; }; in { # Per-user git identity, generated by `rootcell` from the host's `git config`. # Absent on a clean checkout — the mkDefault values below apply in that case. - imports = lib.optional (builtins.pathExists ./git-local.nix) ./git-local.nix; + imports = + lib.optional (builtins.pathExists ./git-local.nix) ./git-local.nix + ++ lib.optional (builtins.pathExists ./generated/extensions-home-manager.nix) + ./generated/extensions-home-manager.nix; home.username = username; home.homeDirectory = "/home/${username}"; @@ -107,8 +74,7 @@ in # to a nix-store path; the next `home-manager switch` then can't replace # it cleanly when content changes — `cmp` errors on directory targets # and `mv` fails because the nix-store parent is read-only — leaving - # activation in a half-broken state. (Likewise for the `subagent` - # extension below.) + # activation in a half-broken state. home.file.".pi/agent/skills/add-flake-dep" = { source = ./pi/agent/skills/add-flake-dep; recursive = true; @@ -118,30 +84,6 @@ in recursive = true; }; - # Pi extensions live under ~/.pi/agent/extensions//. Same - # `recursive = true` rationale as skills above. The `subagent` example - # ships inside the pi-coding-agent release tarball, so point at the - # copy already present in the derivation's $out/share tree rather than - # vendoring it. - home.file.".pi/agent/extensions/subagent" = { - source = "${pi-coding-agent}/share/pi-coding-agent/examples/extensions/subagent"; - recursive = true; - }; - - # The subagent extension loads agent definitions from ~/.pi/agent/agents/ - # (user-level) by default — it does NOT look inside its own examples/agents/ - # subdir. Symlink each example agent .md individually so the parent dir - # stays writable and any agents the user authors at runtime survive - # `home-manager switch`. - home.file.".pi/agent/agents/planner.md".source = - "${pi-coding-agent}/share/pi-coding-agent/examples/extensions/subagent/agents/planner.md"; - home.file.".pi/agent/agents/reviewer.md".source = - "${pi-coding-agent}/share/pi-coding-agent/examples/extensions/subagent/agents/reviewer.md"; - home.file.".pi/agent/agents/scout.md".source = - "${pi-coding-agent}/share/pi-coding-agent/examples/extensions/subagent/agents/scout.md"; - home.file.".pi/agent/agents/worker.md".source = - "${pi-coding-agent}/share/pi-coding-agent/examples/extensions/subagent/agents/worker.md"; - programs.bash = { enable = true; enableCompletion = true; diff --git a/pi/pi-coding-agent.nix b/pi/pi-coding-agent.nix new file mode 100644 index 0000000..0112c1d --- /dev/null +++ b/pi/pi-coding-agent.nix @@ -0,0 +1,38 @@ +{ pkgs }: + +# Pi (pi.dev) ships a Bun-compiled standalone binary on each release, so +# we don't need Node.js in the VM at all. Pinned by version + sha256 — +# bump both fields together. Latest at: +# https://github.com/badlogic/pi-mono/releases +# +# The release tarball contains the binary plus runtime resources it +# reads at startup (themes, export-html template, a wasm blob, and +# package.json for the version string). Copy the whole unpacked tree +# under $out/share so we don't have to track which files are needed, +# and symlink the binary into $out/bin so `pi` is on PATH. +pkgs.stdenv.mkDerivation rec { + pname = "pi-coding-agent"; + version = "0.74.0"; + + src = pkgs.fetchurl { + url = "https://github.com/badlogic/pi-mono/releases/download/v${version}/pi-linux-arm64.tar.gz"; + sha256 = "261aa912878ca983c903d9c4a0408310dd8637b583085651d9b5ddb70c9df572"; + }; + + # Patch the bundled ELF interpreter to point at glibc inside the Nix + # store; otherwise the binary fails immediately on NixOS, which has no + # /lib64/ld-linux-aarch64.so.1. + nativeBuildInputs = [ pkgs.autoPatchelfHook ]; + buildInputs = [ pkgs.stdenv.cc.cc.lib ]; + + installPhase = '' + runHook preInstall + mkdir -p $out/share/pi-coding-agent $out/bin + cp -r . $out/share/pi-coding-agent/ + ln -s $out/share/pi-coding-agent/pi $out/bin/pi + runHook postInstall + ''; + + # Bun-compiled binaries embed a custom section that strip(1) corrupts. + dontStrip = true; +} diff --git a/src/rootcell/args.ts b/src/rootcell/args.ts index 937a698..81f66dd 100644 --- a/src/rootcell/args.ts +++ b/src/rootcell/args.ts @@ -1,12 +1,14 @@ import yargs from "yargs/yargs"; import type { Argv, ArgumentsCamelCase } from "yargs"; +import { completeExtensionCommand } from "./extensions/commands.ts"; import { isRootcellSubcommand, ROOTCELL_SUBCOMMANDS, type RootcellSubcommand } from "./metadata.ts"; -import { listRootcellInstanceNames, validateInstanceName } from "./instance.ts"; +import { DEFAULT_INSTANCE, listRootcellInstanceNames, readSelectedRootcellInstance, validateInstanceName } from "./instance.ts"; import { parseSchema } from "./schema.ts"; import { ParsedRootcellInitEnvArgsSchema, ParsedRootcellHandledArgsSchema, ParsedRootcellRunArgsSchema, + ParsedRootcellSelectArgsSchema, ROOTCELL_INIT_ENV_PROVIDER_TYPES, RootcellInitEnvProviderTypeSchema, SpyOptionsSchema, @@ -34,7 +36,15 @@ interface EditArgs extends GlobalArgs { readonly target?: string; } -type ParserArgv = Argv; +interface ExtensionArgs extends GlobalArgs { + readonly extensionArgs?: readonly string[]; +} + +interface SelectArgs extends GlobalArgs { + readonly selectedInstance?: string; +} + +type ParserArgv = Argv; function subcommandDescription(name: RootcellSubcommand): string { return ROOTCELL_SUBCOMMANDS.find((subcommand) => subcommand.name === name)?.description ?? ""; @@ -48,7 +58,7 @@ function lastString(value: string | readonly string[] | undefined): string | und } function instanceName(argv: GlobalArgs): string { - return validateInstanceName(lastString(argv.instance) ?? "default"); + return validateInstanceName(lastString(argv.instance) ?? DEFAULT_INSTANCE); } function stringArray(value: unknown): readonly string[] { @@ -73,11 +83,11 @@ function argString(value: unknown): string { function rootcellSubcommand( name: RootcellSubcommand, - builder?: (argv: ParserArgv) => ParserArgv, + builder?: (argv: ParserArgv) => ParserArgv, ): readonly [ string, string, - (argv: ParserArgv) => ParserArgv, + (argv: ParserArgv) => ParserArgv, ] { return [ name, @@ -97,21 +107,69 @@ function completion( completionFilter: (done: (error: Error | null, completions: string[] | undefined) => void) => void, done: (completions: string[]) => void, ): void { + const currentInstance = lastString(argv.instance); + if (currentInstance === current) { + done([...completeInstances(current)]); + return; + } + + const selectCompletions = completeSelectCompletion(current, argv); + if (selectCompletions !== undefined) { + done([...selectCompletions]); + return; + } + + const extensionCompletions = completeExtensionCompletion(current, argv); + if (extensionCompletions !== undefined) { + done([...extensionCompletions]); + return; + } + completionFilter((error, completions) => { if (error !== null) { throw error; } const defaults = (completions ?? []).filter((completion) => !completion.startsWith("$0")); - const currentInstance = lastString(argv.instance); - if (currentInstance === current) { - done([...completeInstances(current), ...defaults]); - return; - } done(defaults); }); } -function createParser(args: readonly string[]): Argv { +function completeExtensionCompletion( + current: string, + argv: ArgumentsCamelCase, +): readonly string[] | undefined { + const words = rootcellWords(argv); + try { + const instance = lastString(argv.instance) ?? readSelectedRootcellInstance(process.cwd(), process.env); + return completeExtensionCommand({ + repoDir: process.cwd(), + env: process.env, + instanceName: instance, + words, + current, + }); + } catch { + return []; + } +} + +function completeSelectCompletion( + current: string, + argv: ArgumentsCamelCase, +): readonly string[] | undefined { + const words = rootcellWords(argv); + if (words[0] !== "select" || words.length > 2) { + return undefined; + } + return completeInstances(current); +} + +function rootcellWords(argv: ArgumentsCamelCase): readonly string[] { + const rawWords = argv._.map((value) => String(value)); + return rawWords[0] === "rootcell" ? rawWords.slice(1) : rawWords; +} + +function createParser(args: readonly string[]): Argv { return yargs([...args]) .scriptName("rootcell") .exitProcess(false) @@ -121,12 +179,11 @@ function createParser(args: readonly string[]): Argv { "populate--": true, "unknown-options-as-args": true, }) - .usage("$0 [command..]\n\nStart the rootcell agent VM and run a command.") + .usage("$0 [command]\n\nStart the rootcell agent VM and run a command.") .option("instance", { alias: "i", - describe: "select rootcell instance", + describe: "override the selected default rootcell instance", type: "string", - default: "default", normalize: false, }) .option("init-env", { @@ -141,6 +198,17 @@ function createParser(args: readonly string[]): Argv { type: "string", hidden: true, }) + .command( + "select ", + subcommandDescription("select"), + (argv: ParserArgv) => argv + .positional("selectedInstance", { + describe: "rootcell instance to use by default", + type: "string", + }) + .demandCommand(0, 0) + .strictOptions(), + ) .command(...rootcellSubcommand("provision")) .command(...rootcellSubcommand("allow")) .command(...rootcellSubcommand("pubkey")) @@ -150,19 +218,31 @@ function createParser(args: readonly string[]): Argv { .command( "edit ", subcommandDescription("edit"), - (argv: ParserArgv) => argv + (argv: ParserArgv) => argv .positional("target", { - choices: ["env", "http", "https", "dns", "ssh"], + choices: ["env", "http", "https", "dns", "ssh", "extensions"], describe: "instance file to edit", type: "string", }) .demandCommand(0, 0) .strictOptions(), ) + .command( + "extension [extensionArgs..]", + subcommandDescription("extension"), + (argv: ParserArgv) => argv + .positional("extensionArgs", { + array: true, + describe: "extension command and arguments", + type: "string", + }) + .demandCommand(0, 0) + .strictOptions(), + ) .command( "spy", subcommandDescription("spy"), - (argv: ParserArgv) => argv + (argv: ParserArgv) => argv .parserConfiguration({ "unknown-options-as-args": false }) .option("open", { describe: "open the browser after starting the local tunnel; use --no-open to disable", @@ -173,19 +253,16 @@ function createParser(args: readonly string[]): Argv { .strictOptions(), ) .command( - "$0 [command..]", - "run a command inside the agent VM; defaults to an interactive shell", - (argv: ParserArgv) => argv.positional("command", { - array: true, - describe: "command and arguments to run inside the agent VM", - type: "string", - }), + "$0", + "open an interactive shell; use -- for guest commands", + (argv: ParserArgv) => argv, ) .example("$0", "open an interactive shell inside the agent VM") - .example("$0 pi", "run pi inside the agent VM") + .example("$0 select dev", "use the dev instance by default") + .example("$0 -- pi", "run pi inside the agent VM") .example("$0 -- nix flake update", "run any command inside the agent VM") - .example("$0 edit env", "edit the default instance environment in $EDITOR") - .example("$0 edit http", "edit the HTTPS allowlist for the default instance") + .example("$0 edit env", "edit the selected instance environment in $EDITOR") + .example("$0 edit http", "edit the HTTPS allowlist for the selected instance") .example("$0 --instance dev edit dns", "edit the DNS allowlist for the dev instance") .example("$0 --instance dev allow", "reload allowlists for the dev instance") .example("$0 --instance aws-dev --init-env aws-ec2", "initialize an AWS EC2 instance environment") @@ -200,7 +277,7 @@ function createParser(args: readonly string[]): Argv { export function parseRootcellArgs(args: readonly string[]): ParsedRootcellArgs { rejectUnknownSpyHelpOptions(args); - const argv = createParser(args).parseSync(); + const argv = createParser(args).parseSync() as ArgumentsCamelCase; const firstToken = firstRootcellToken(args); if ( argv.help === true @@ -224,8 +301,25 @@ export function parseRootcellArgs(args: readonly string[]): ParsedRootcellArgs { }, "invalid parsed rootcell args"); } + if (subcommand === "select") { + if (hasInstanceFlag(args)) { + throw new Error("--instance cannot be used with select"); + } + if (stringArray(argv["--"]).length > 0) { + throw new Error("select does not accept arguments after --"); + } + return parseSchema(ParsedRootcellSelectArgsSchema, { + kind: "select", + selectedInstanceName: validateInstanceName(argString((argv as ArgumentsCamelCase).selectedInstance)), + }, "invalid parsed rootcell args"); + } + if (subcommand !== undefined) { - const rest = subcommand === "edit" ? [argString((argv as ArgumentsCamelCase).target)] : []; + const rest = subcommand === "edit" + ? [argString((argv as ArgumentsCamelCase).target)] + : subcommand === "extension" + ? stringArray((argv as ArgumentsCamelCase).extensionArgs) + : []; return parseSchema(ParsedRootcellRunArgsSchema, { kind: "run", instanceName: instanceName(argv), @@ -240,16 +334,22 @@ export function parseRootcellArgs(args: readonly string[]): ParsedRootcellArgs { } const afterTerminator = stringArray(argv["--"]); - const rest = [...stringArray(argv.command), ...afterTerminator]; - const first = rest[0]; - if (first?.startsWith("-") === true && afterTerminator.length === 0) { + const commandPositionals = stringArray(argv.command); + const implicitGuestCommand = commandPositionals.length > 0 + ? commandPositionals + : argv._.map((value) => String(value)); + const first = implicitGuestCommand[0]; + if (first?.startsWith("-") === true) { throw new Error(`Unknown argument: ${first.replace(/^-+/, "")}`); } + if (first !== undefined) { + throw new Error(unknownRootcellCommandMessage(first, argv)); + } return parseSchema(ParsedRootcellRunArgsSchema, { kind: "run", instanceName: instanceName(argv), subcommand: "", - rest, + rest: afterTerminator, spyOptions: DEFAULT_SPY_OPTIONS, }, "invalid parsed rootcell args"); } @@ -258,11 +358,29 @@ function fail(message: string, error: Error): never { throw error instanceof Error ? error : new Error(message); } -function parsedSubcommand(argv: ArgumentsCamelCase): RootcellSubcommand | undefined { +function parsedSubcommand(argv: ArgumentsCamelCase): RootcellSubcommand | undefined { const command = argv._[0]; return typeof command === "string" && isRootcellSubcommand(command) ? command : undefined; } +function unknownRootcellCommandMessage(command: string, argv: GlobalArgs): string { + const selected = lastString(argv.instance); + const instancePrefix = selected === undefined ? "" : `--instance ${validateInstanceName(selected)} `; + return `unknown rootcell command '${command}' (use 'rootcell ${instancePrefix}-- ${command}' to run a guest command)`; +} + +function hasInstanceFlag(args: readonly string[]): boolean { + for (const arg of args) { + if (arg === "--") { + return false; + } + if (arg === "--instance" || arg === "-i" || arg.startsWith("--instance=") || (arg.startsWith("-i") && arg.length > 2)) { + return true; + } + } + return false; +} + function rejectUnknownSpyHelpOptions(args: readonly string[]): void { const firstToken = firstRootcellTokenWithIndex(args); if (firstToken?.token !== "spy" || !args.includes("--help")) { diff --git a/src/rootcell/extensions/commands.ts b/src/rootcell/extensions/commands.ts new file mode 100644 index 0000000..c1a43e2 --- /dev/null +++ b/src/rootcell/extensions/commands.ts @@ -0,0 +1,229 @@ +import { instancePaths, seedRootcellInstanceFiles } from "../instance.ts"; +import { + enabledExtensionIds, + disabledExtensionIds, + ensureExtensionsConfig, + formatExtensionsList, + type ParsedExtensionsConfig, + parseExtensionsConfig, + readExtensionsConfig, + setExtensionEnabled, +} from "./config.ts"; +import { + ROOTCELL_EXTENSIONS, + ROOTCELL_EXTENSION_IDS, + type ExtensionHostCommandContext, + type RootcellExtensionDefinition, + type RootcellExtensionHostCommand, + isRootcellExtensionId, +} from "./registry.ts"; + +const MANAGEMENT_COMMANDS = ["list", "enable", "disable"] as const; + +export interface ExtensionHostCommandContextFactoryInput { + readonly extension: RootcellExtensionDefinition; + readonly command: RootcellExtensionHostCommand; + readonly extensionConfig: ParsedExtensionsConfig; +} + +export type ExtensionHostCommandContextFactory = ( + input: ExtensionHostCommandContextFactoryInput, +) => Promise | ExtensionHostCommandContext; + +export async function runExtensionCommand(input: { + readonly repoDir: string; + readonly env: NodeJS.ProcessEnv; + readonly instanceName: string; + readonly rest: readonly string[]; + readonly log: (message: string) => void; + readonly createContext: ExtensionHostCommandContextFactory; + readonly extensions?: readonly RootcellExtensionDefinition[]; +}): Promise { + const extensions = input.extensions ?? ROOTCELL_EXTENSIONS; + const [commandOrId, idOrCommand, ...extra] = input.rest; + if (commandOrId === undefined) { + input.log("usage: rootcell extension list | enable | disable | "); + return 2; + } + + if (commandOrId === "list") { + if (idOrCommand !== undefined) { + input.log("usage: rootcell extension list"); + return 2; + } + seedRootcellInstanceFiles(input.repoDir, input.instanceName, input.log, input.env); + const path = instancePaths(input.repoDir, input.instanceName, input.env).extensionsPath; + process.stdout.write(formatExtensionsList(ensureExtensionsConfig(path, input.log))); + return 0; + } + + if (commandOrId === "enable" || commandOrId === "disable") { + if (idOrCommand === undefined || extra.length > 0) { + input.log(`usage: rootcell extension ${commandOrId} `); + return 2; + } + const extension = findExtension(extensions, idOrCommand); + if (!isRootcellExtensionId(idOrCommand) || extension === undefined) { + input.log(`unknown extension id '${idOrCommand}' (known: ${knownExtensionIds(extensions).join(", ")})`); + return 2; + } + + seedRootcellInstanceFiles(input.repoDir, input.instanceName, input.log, input.env); + const path = instancePaths(input.repoDir, input.instanceName, input.env).extensionsPath; + const enabled = commandOrId === "enable"; + const result = setExtensionEnabled(path, idOrCommand, enabled); + const state = enabled ? "enabled" : "disabled"; + const already = result.changed ? "" : " already"; + process.stdout.write(`${idOrCommand}${already} ${state} for instance '${input.instanceName}'.\n`); + printApplyGuidance(input.instanceName, extension); + return 0; + } + + return await runOperationalExtensionCommand({ ...input, extensions, commandOrId, idOrCommand, extra }); +} + +export function completeExtensionCommand(input: { + readonly repoDir: string; + readonly env: NodeJS.ProcessEnv; + readonly instanceName: string; + readonly words: readonly string[]; + readonly current: string; + readonly extensions?: readonly RootcellExtensionDefinition[]; +}): readonly string[] | undefined { + const extensions = input.extensions ?? ROOTCELL_EXTENSIONS; + const extensionAt = input.words.indexOf("extension"); + if (extensionAt === -1) { + return undefined; + } + const after = input.words.slice(extensionAt + 1); + const first = after[0]; + if (after.length <= 1) { + const config = safeReadExtensionsConfig(input.repoDir, input.env, input.instanceName); + return startsWith([ + ...MANAGEMENT_COMMANDS, + ...enabledOperationalExtensionIds(extensions, config), + ], input.current); + } + if ((first === "enable" || first === "disable") && after.length <= 2) { + const config = safeReadExtensionsConfig(input.repoDir, input.env, input.instanceName); + const completions = first === "enable" + ? disabledExtensionIds(config) + : ROOTCELL_EXTENSION_IDS.filter((id) => config.enabled.has(id)); + return startsWith(completions, input.current); + } + if (first !== undefined && after.length <= 2) { + const config = safeReadExtensionsConfig(input.repoDir, input.env, input.instanceName); + const extension = findExtension(extensions, first); + if (extension === undefined || !config.enabled.has(extension.id)) { + return []; + } + return startsWith(extension.hostCommands.map((command) => command.name), input.current); + } + if (first !== undefined && after.length > 2) { + const config = safeReadExtensionsConfig(input.repoDir, input.env, input.instanceName); + const extension = findExtension(extensions, first); + if (extension === undefined || !config.enabled.has(extension.id)) { + return []; + } + const command = extension.hostCommands.find((candidate) => candidate.name === after[1]); + return command?.complete({ args: after.slice(2), current: input.current }) ?? []; + } + return []; +} + +async function runOperationalExtensionCommand(input: { + readonly repoDir: string; + readonly env: NodeJS.ProcessEnv; + readonly instanceName: string; + readonly log: (message: string) => void; + readonly createContext: ExtensionHostCommandContextFactory; + readonly extensions: readonly RootcellExtensionDefinition[]; + readonly commandOrId: string; + readonly idOrCommand: string | undefined; + readonly extra: readonly string[]; +}): Promise { + const extension = findExtension(input.extensions, input.commandOrId); + if (extension === undefined) { + input.log(`unknown extension command or id '${input.commandOrId}' (expected ${MANAGEMENT_COMMANDS.join(", ")}, or one of: ${knownExtensionIds(input.extensions).join(", ")})`); + return 2; + } + + const extensionConfig = readExtensionsConfig(instancePaths(input.repoDir, input.instanceName, input.env).extensionsPath); + if (!extensionConfig.enabled.has(extension.id)) { + input.log(`extension '${extension.id}' is disabled for instance '${input.instanceName}'.`); + input.log(`run ./rootcell --instance ${input.instanceName} extension enable ${extension.id}, then ./rootcell --instance ${input.instanceName} provision.`); + return 1; + } + + if (input.idOrCommand === undefined) { + input.log(`usage: rootcell extension ${extension.id} `); + logKnownHostCommands(input.log, extension); + return 2; + } + const command = extension.hostCommands.find((candidate) => candidate.name === input.idOrCommand); + if (command === undefined) { + input.log(`unknown command for extension '${extension.id}': '${input.idOrCommand}'`); + logKnownHostCommands(input.log, extension); + return 2; + } + + const context = await input.createContext({ extension, command, extensionConfig }); + return await command.run(context, input.extra); +} + +function printApplyGuidance(instanceName: string, extension: RootcellExtensionDefinition): void { + if (extension.requiresProvision) { + process.stdout.write(`run ./rootcell --instance ${instanceName} provision to apply VM changes.\n`); + return; + } + process.stdout.write("no provision is needed for this extension change.\n"); +} + +function logKnownHostCommands( + log: (message: string) => void, + extension: RootcellExtensionDefinition, +): void { + const known = extension.hostCommands.map((command) => command.name); + if (known.length === 0) { + log(`extension '${extension.id}' has no host commands in this Rootcell version.`); + return; + } + log(`known commands for '${extension.id}': ${known.join(", ")}`); +} + +function findExtension( + extensions: readonly RootcellExtensionDefinition[], + id: string, +): RootcellExtensionDefinition | undefined { + return extensions.find((extension) => extension.id === id); +} + +function knownExtensionIds(extensions: readonly RootcellExtensionDefinition[]): readonly string[] { + return extensions.map((extension) => extension.id); +} + +function enabledOperationalExtensionIds( + extensions: readonly RootcellExtensionDefinition[], + config: ParsedExtensionsConfig, +): readonly string[] { + const enabled = new Set(enabledExtensionIds(config)); + return extensions + .filter((extension) => enabled.has(extension.id) && extension.hostCommands.length > 0) + .map((extension) => extension.id); +} + +function safeReadExtensionsConfig( + repoDir: string, + env: NodeJS.ProcessEnv, + instanceName: string, +): ReturnType { + try { + return readExtensionsConfig(instancePaths(repoDir, instanceName, env).extensionsPath); + } catch { + return parseExtensionsConfig(""); + } +} + +function startsWith(values: readonly T[], current: string): readonly T[] { + return values.filter((value) => value.startsWith(current)); +} diff --git a/src/rootcell/extensions/config.ts b/src/rootcell/extensions/config.ts new file mode 100644 index 0000000..377231e --- /dev/null +++ b/src/rootcell/extensions/config.ts @@ -0,0 +1,248 @@ +import { + existsSync, + mkdirSync, + readFileSync, + statSync, + writeFileSync, +} from "node:fs"; +import { dirname } from "node:path"; +import { z } from "zod"; +import { parseSchema } from "../schema.ts"; +import { + ROOTCELL_EXTENSION_IDS, + RootcellExtensionIdSchema, + isRootcellExtensionId, + type RootcellExtensionId, +} from "./registry.ts"; + +const LEGACY_EXTENSION_IDS = new Map([ + ["plannotator", "pi-plannotator"], + ["subagent", "pi-subagents"], +]); + +export const ExtensionConfigKeySchema = z.string() + .regex(/^[a-z](?:[a-z0-9-]*[a-z0-9])?$/, "must be lowercase kebab-case"); + +const ExtensionCommentLineSchema = z.object({ + kind: z.literal("comment"), + raw: z.string(), +}).strict(); + +const ExtensionBlankLineSchema = z.object({ + kind: z.literal("blank"), + raw: z.literal(""), +}).strict(); + +const ExtensionEntryLineSchema = z.object({ + kind: z.literal("entry"), + raw: z.string(), + key: ExtensionConfigKeySchema, + enabled: z.boolean(), + known: z.boolean(), +}).strict(); + +export const ExtensionConfigLineSchema = z.discriminatedUnion("kind", [ + ExtensionCommentLineSchema, + ExtensionBlankLineSchema, + ExtensionEntryLineSchema, +]); + +const RootcellExtensionIdSetSchema = z.custom>( + (value) => value instanceof Set && [...value].every((id) => RootcellExtensionIdSchema.safeParse(id).success), + { message: "must be a set of rootcell extension ids" }, +); + +export const ParsedExtensionsConfigSchema = z.object({ + lines: z.array(ExtensionConfigLineSchema), + enabled: RootcellExtensionIdSetSchema, + unknownKeys: z.array(ExtensionConfigKeySchema), +}).strict(); + +export const ExtensionSetResultSchema = z.object({ + config: ParsedExtensionsConfigSchema, + changed: z.boolean(), +}).strict(); + +export type ExtensionConfigLine = Readonly>; + +type ParsedExtensionsConfigOutput = z.infer; + +export type ParsedExtensionsConfig = Readonly< + Omit & { + readonly lines: readonly ExtensionConfigLine[]; + readonly unknownKeys: readonly string[]; + } +>; + +type ExtensionSetResultOutput = z.infer; + +export type ExtensionSetResult = Readonly< + Omit & { + readonly config: ParsedExtensionsConfig; + } +>; + +export function parseExtensionsConfig(text: string): ParsedExtensionsConfig { + const lines: ExtensionConfigLine[] = []; + const enabled = new Set(); + const unknownKeys: string[] = []; + const seen = new Set(); + const rawLines = text.split(/\r?\n/); + const lineCount = text.length === 0 ? 0 : text.endsWith("\n") ? rawLines.length - 1 : rawLines.length; + + for (let index = 0; index < lineCount; index += 1) { + const raw = rawLines[index] ?? ""; + if (raw.length === 0) { + lines.push({ kind: "blank", raw: "" }); + continue; + } + if (raw.startsWith("#")) { + lines.push({ kind: "comment", raw }); + continue; + } + + const equalsAt = raw.indexOf("="); + const key = equalsAt === -1 ? raw : raw.slice(0, equalsAt); + const value = equalsAt === -1 ? "" : raw.slice(equalsAt + 1); + if (!ExtensionConfigKeySchema.safeParse(key).success) { + throw new Error(`invalid extension key in extensions.txt on line ${String(index + 1)}: ${key}`); + } + const canonicalId = canonicalRootcellExtensionId(key); + const canonicalKey = canonicalId ?? key; + if (seen.has(canonicalKey)) { + throw new Error(`duplicate extension key in extensions.txt on line ${String(index + 1)}: ${key}`); + } + seen.add(canonicalKey); + + const valueEnabled = parseExtensionBoolean(value, key, index + 1); + const known = canonicalId !== undefined; + if (known && valueEnabled) { + enabled.add(canonicalId); + } + if (!known) { + unknownKeys.push(key); + } + lines.push({ kind: "entry", raw, key, enabled: valueEnabled, known }); + } + + return parseSchema(ParsedExtensionsConfigSchema, { lines, enabled, unknownKeys }, "invalid parsed extensions config"); +} + +export function readExtensionsConfig(path: string): ParsedExtensionsConfig { + if (!existsSync(path)) { + return parseExtensionsConfig(""); + } + return parseExtensionsConfig(readFileSync(path, "utf8")); +} + +export function ensureExtensionsConfig( + path: string, + log?: (message: string) => void, +): ParsedExtensionsConfig { + const existed = existsSync(path); + const config = readExtensionsConfig(path); + const rendered = renderExtensionsConfig(config); + const existingText = existed ? readFileSync(path, "utf8") : ""; + if (!existed || rendered !== existingText) { + writeExtensionsConfig(path, rendered); + if (!existed) { + log?.(`seeded extensions at ${path}`); + } + } + return parseExtensionsConfig(rendered); +} + +export function setExtensionEnabled( + path: string, + id: RootcellExtensionId, + enabled: boolean, +): ExtensionSetResult { + const config = ensureExtensionsConfig(path); + const before = config.enabled.has(id); + const rendered = renderExtensionsConfig(config, new Map([[id, enabled]])); + const changed = before !== enabled; + if (changed) { + writeExtensionsConfig(path, rendered); + } + return parseSchema(ExtensionSetResultSchema, { + config: parseExtensionsConfig(rendered), + changed, + }, "invalid extension set result"); +} + +export function enabledExtensionIds(config: ParsedExtensionsConfig): readonly RootcellExtensionId[] { + return ROOTCELL_EXTENSION_IDS.filter((id) => config.enabled.has(id)); +} + +export function disabledExtensionIds(config: ParsedExtensionsConfig): readonly RootcellExtensionId[] { + return ROOTCELL_EXTENSION_IDS.filter((id) => !config.enabled.has(id)); +} + +export function renderExtensionsConfig( + config: ParsedExtensionsConfig, + overrides: ReadonlyMap = new Map(), +): string { + const present = new Set(); + const lines: string[] = []; + + for (const line of config.lines) { + if (line.kind !== "entry") { + lines.push(line.raw); + continue; + } + const canonicalId = canonicalRootcellExtensionId(line.key); + if (canonicalId !== undefined) { + present.add(canonicalId); + const enabled = overrides.has(canonicalId) ? overrides.get(canonicalId) === true : line.enabled; + lines.push(`${canonicalId}=${enabled ? "true" : "false"}`); + continue; + } + present.add(line.key); + lines.push(line.raw); + } + + for (const id of ROOTCELL_EXTENSION_IDS) { + if (!present.has(id)) { + lines.push(`${id}=false`); + } + } + + return `${lines.join("\n")}\n`; +} + +export function formatExtensionsList(config: ParsedExtensionsConfig): string { + const rows = [ + ["ID", "STATUS"], + ...ROOTCELL_EXTENSION_IDS.map((id) => [id, config.enabled.has(id) ? "enabled" : "disabled"]), + ]; + const widths = rows[0]?.map((_, column) => Math.max(...rows.map((row) => row[column]?.length ?? 0))) ?? []; + const table = rows.map((row) => row.map((cell, column) => cell.padEnd(widths[column] ?? 0)).join(" ").trimEnd()).join("\n"); + const unknown = config.unknownKeys.length === 0 + ? "" + : `\n\nUnknown extension keys preserved in extensions.txt:\n${config.unknownKeys.join("\n")}`; + return `${table}${unknown}\n`; +} + +function writeExtensionsConfig(path: string, text: string): void { + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); + const mode = existsSync(path) ? statSync(path).mode : 0o644; + writeFileSync(path, text, { encoding: "utf8", mode }); +} + +function canonicalRootcellExtensionId(key: string): RootcellExtensionId | undefined { + if (isRootcellExtensionId(key)) { + return key; + } + return LEGACY_EXTENSION_IDS.get(key); +} + +function parseExtensionBoolean(value: string, key: string, line: number): boolean { + const normalized = value.trim().toLowerCase(); + if (["true", "1", "yes", "on"].includes(normalized)) { + return true; + } + if (["false", "0", "no", "off", ""].includes(normalized)) { + return false; + } + throw new Error(`invalid boolean value for extension '${key}' in extensions.txt on line ${String(line)}: ${value}`); +} diff --git a/src/rootcell/extensions/nix.ts b/src/rootcell/extensions/nix.ts new file mode 100644 index 0000000..fce1269 --- /dev/null +++ b/src/rootcell/extensions/nix.ts @@ -0,0 +1,54 @@ +import { writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { parseSchema } from "../schema.ts"; +import { enabledExtensionIds, type ParsedExtensionsConfig } from "./config.ts"; +import { ExtensionGuestModulePathSchema, ROOTCELL_EXTENSIONS, type ExtensionGuestHook } from "./registry.ts"; + +export const GENERATED_EXTENSION_HOOK_FILES = [ + "extensions-agent-vm.nix", + "extensions-firewall-vm.nix", + "extensions-home-manager.nix", +] as const; + +const HOOK_FILE_NAMES: Readonly> = { + agentNixos: "extensions-agent-vm.nix", + firewallNixos: "extensions-firewall-vm.nix", + homeManager: "extensions-home-manager.nix", +}; + +export function writeExtensionNixAggregators( + generatedDir: string, + config: ParsedExtensionsConfig, +): void { + for (const hook of ["agentNixos", "firewallNixos", "homeManager"] as const) { + writeFileSync( + join(generatedDir, HOOK_FILE_NAMES[hook]), + renderExtensionNixAggregator(config, hook), + "utf8", + ); + } +} + +export function renderExtensionNixAggregator( + config: ParsedExtensionsConfig, + hook: ExtensionGuestHook, +): string { + const imports = enabledExtensionIds(config).flatMap((id) => { + const definition = ROOTCELL_EXTENSIONS.find((extension) => extension.id === id); + return definition?.guestHooks[hook] ?? []; + }); + return [ + "# Generated by ./rootcell from this instance's extensions.txt. DO NOT EDIT.", + "{ ... }:", + "{", + " imports = [", + ...imports.map((path) => ` ../${nixPath(path)}`), + " ];", + "}", + "", + ].join("\n"); +} + +function nixPath(path: string): string { + return parseSchema(ExtensionGuestModulePathSchema, path, "unsafe extension Nix import path"); +} diff --git a/src/rootcell/extensions/pi-plannotator.test.ts b/src/rootcell/extensions/pi-plannotator.test.ts new file mode 100644 index 0000000..18b48f9 --- /dev/null +++ b/src/rootcell/extensions/pi-plannotator.test.ts @@ -0,0 +1,232 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, test, vi } from "vitest"; +import type { LocalPortForwardOptions, LocalPortForwardHandle, VmRole, VmStatus } from "../providers/types.ts"; +import type { RootcellConfig } from "../types.ts"; +import { completeExtensionCommand, runExtensionCommand } from "./commands.ts"; +import { parseExtensionsConfig } from "./config.ts"; +import { createPlannotatorTunnelCommand } from "./pi-plannotator.ts"; +import type { ExtensionHostCommandContext } from "./registry.ts"; + +describe("pi-plannotator extension host command", () => { + test("disabled extension gating prevents context creation", async () => { + const repo = makeRepo(); + try { + const stateDir = join(repo, ".state"); + const instanceDir = join(stateDir, "dev"); + const logs: string[] = []; + let contexts = 0; + mkdirSync(instanceDir, { recursive: true }); + writeFileSync(join(instanceDir, "extensions.txt"), "pi-plannotator=false\npi-subagents=false\n", "utf8"); + + const status = await runExtensionCommand({ + repoDir: repo, + env: { ...process.env, ROOTCELL_STATE_DIR: stateDir }, + instanceName: "dev", + rest: ["pi-plannotator", "tunnel"], + log: (message) => logs.push(message), + createContext: () => { + contexts += 1; + return testContext(); + }, + }); + + expect(status).toBe(1); + expect(contexts).toBe(0); + expect(logs.join("\n")).toContain("extension 'pi-plannotator' is disabled"); + } finally { + rmSync(repo, { recursive: true, force: true }); + } + }); + + test("completions expose tunnel only when Plannotator is enabled", () => { + const repo = makeRepo(); + try { + const stateDir = join(repo, ".state"); + const instanceDir = join(stateDir, "dev"); + const env = { ...process.env, ROOTCELL_STATE_DIR: stateDir }; + mkdirSync(instanceDir, { recursive: true }); + writeFileSync(join(instanceDir, "extensions.txt"), "pi-plannotator=true\npi-subagents=false\n", "utf8"); + + expect(completeExtensionCommand({ + repoDir: repo, + env, + instanceName: "dev", + words: ["extension", ""], + current: "", + })).toContain("pi-plannotator"); + expect(completeExtensionCommand({ + repoDir: repo, + env, + instanceName: "dev", + words: ["extension", "pi-plannotator", ""], + current: "", + })).toEqual(["tunnel"]); + expect(completeExtensionCommand({ + repoDir: repo, + env, + instanceName: "dev", + words: ["extension", "pi-plannotator", "tunnel", ""], + current: "", + })).toEqual([]); + + writeFileSync(join(instanceDir, "extensions.txt"), "pi-plannotator=false\npi-subagents=false\n", "utf8"); + expect(completeExtensionCommand({ + repoDir: repo, + env, + instanceName: "dev", + words: ["extension", ""], + current: "", + })).not.toContain("pi-plannotator"); + } finally { + rmSync(repo, { recursive: true, force: true }); + } + }); + + test("rejects extra tunnel arguments before checking VM state", async () => { + const logs: string[] = []; + let statusChecks = 0; + const command = createPlannotatorTunnelCommand(); + + const status = await command.run(testContext({ + logs, + vmStatus: () => { + statusChecks += 1; + return Promise.resolve({ state: "running" }); + }, + }), ["extra"]); + + expect(status).toBe(2); + expect(statusChecks).toBe(0); + expect(logs).toEqual(["usage: rootcell extension pi-plannotator tunnel"]); + }); + + test.each([ + { + label: "missing", + vmStatus: { state: "missing" } satisfies VmStatus, + expected: "agent VM for instance 'dev' is missing", + }, + { + label: "stopped", + vmStatus: { state: "stopped" } satisfies VmStatus, + expected: "agent VM for instance 'dev' is stopped", + }, + { + label: "unexpected", + vmStatus: { state: "unexpected", detail: "provider error" } satisfies VmStatus, + expected: "agent VM for instance 'dev' is not ready: provider error", + }, + ])("requires a running agent VM when it is $label", async ({ vmStatus, expected }) => { + const logs: string[] = []; + let forwards = 0; + const command = createPlannotatorTunnelCommand(); + + const status = await command.run(testContext({ + logs, + vmStatus: () => Promise.resolve(vmStatus), + forwardLocalPort: () => { + forwards += 1; + return Promise.resolve(testTunnelHandle()); + }, + }), []); + + expect(status).toBe(1); + expect(forwards).toBe(0); + expect(logs.join("\n")).toContain(expected); + }); + + test("opens a foreground agent tunnel, prints the URL, and closes the handle", async () => { + const logs: string[] = []; + const calls: string[] = []; + const stdout = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + let forwarded: { role: VmRole; options: LocalPortForwardOptions } | undefined; + let closeCalls = 0; + const command = createPlannotatorTunnelCommand({ + portAvailable: (port, host) => { + calls.push(`port:${host}:${String(port)}`); + return Promise.resolve(port === 19_433); + }, + }); + + try { + const status = await command.run(testContext({ + logs, + vmStatus: (role) => { + calls.push(`status:${role}`); + return Promise.resolve({ state: "running" }); + }, + forwardLocalPort: (role, options) => { + calls.push(`forward:${role}`); + forwarded = { role, options }; + return Promise.resolve({ + ...options, + closed: Promise.resolve(0), + close: () => { + closeCalls += 1; + calls.push("close"); + return Promise.resolve(); + }, + }); + }, + }), []); + + expect(status).toBe(0); + expect(forwarded).toEqual({ + role: "agent", + options: { + localHost: "127.0.0.1", + localPort: 19_433, + remoteHost: "127.0.0.1", + remotePort: 19_432, + }, + }); + expect(stdout.mock.calls.map((call) => String(call[0])).join("")).toBe("http://127.0.0.1:19433\n"); + expect(logs).toContain("forwarding http://127.0.0.1:19433 to Plannotator in the agent VM (Ctrl-C stops the tunnel)."); + expect(closeCalls).toBe(1); + expect(calls).toEqual([ + "status:agent", + "port:127.0.0.1:19432", + "port:127.0.0.1:19433", + "forward:agent", + "close", + ]); + } finally { + stdout.mockRestore(); + } + }); +}); + +function makeRepo(): string { + return mkdtempSync(join(tmpdir(), "rootcell-pi-plannotator-")); +} + +function testContext(input: { + readonly logs?: string[]; + readonly vmStatus?: (role: VmRole) => Promise; + readonly forwardLocalPort?: (role: VmRole, options: LocalPortForwardOptions) => Promise; +} = {}): ExtensionHostCommandContext { + return { + repoDir: "/repo", + instanceName: "dev", + extensionConfig: parseExtensionsConfig("pi-plannotator=true\npi-subagents=false\n"), + config: {} as RootcellConfig, + log: (message) => input.logs?.push(message), + vmStatus: input.vmStatus ?? (() => Promise.resolve({ state: "running" })), + forwardLocalPort: input.forwardLocalPort ?? ((_role, options) => Promise.resolve(testTunnelHandle(options))), + }; +} + +function testTunnelHandle(options: LocalPortForwardOptions = { + localHost: "127.0.0.1", + localPort: 19_432, + remoteHost: "127.0.0.1", + remotePort: 19_432, +}): LocalPortForwardHandle { + return { + ...options, + closed: Promise.resolve(0), + close: () => Promise.resolve(), + }; +} diff --git a/src/rootcell/extensions/pi-plannotator.ts b/src/rootcell/extensions/pi-plannotator.ts new file mode 100644 index 0000000..e6e5702 --- /dev/null +++ b/src/rootcell/extensions/pi-plannotator.ts @@ -0,0 +1,70 @@ +import { + DEFAULT_TUNNEL_LOCAL_HOST, + openRoleTargetTunnel, + waitForForegroundTunnel, + type PortAvailabilityCheck, +} from "../tunnels.ts"; +import type { VmStatus } from "../providers/types.ts"; +import type { ExtensionHostCommandContext, RootcellExtensionHostCommand } from "./registry.ts"; + +const PLANNOTATOR_HOST = "127.0.0.1"; +const PLANNOTATOR_PORT = 19_432; + +export interface PlannotatorTunnelCommandOptions { + readonly portAvailable?: PortAvailabilityCheck; +} + +export function createPlannotatorTunnelCommand( + options: PlannotatorTunnelCommandOptions = {}, +): RootcellExtensionHostCommand { + return { + name: "tunnel", + description: "open a local SSH tunnel to the Plannotator server in the agent VM", + complete: () => [], + run: async (context, args) => { + if (args.length > 0) { + context.log("usage: rootcell extension pi-plannotator tunnel"); + return 2; + } + + const status = await context.vmStatus("agent"); + if (status.state !== "running") { + logAgentVmNotRunning(context, status); + return 1; + } + + const tunnel = await openRoleTargetTunnel( + (role, forwardOptions) => context.forwardLocalPort(role, forwardOptions), + { + role: "agent", + localHost: DEFAULT_TUNNEL_LOCAL_HOST, + remoteHost: PLANNOTATOR_HOST, + remotePort: PLANNOTATOR_PORT, + preferredLocalPort: PLANNOTATOR_PORT, + }, + options.portAvailable === undefined ? {} : { portAvailable: options.portAvailable }, + ); + const url = `http://${tunnel.localHost}:${String(tunnel.localPort)}`; + process.stdout.write(`${url}\n`); + context.log(`forwarding ${url} to Plannotator in the agent VM (Ctrl-C stops the tunnel).`); + return await waitForForegroundTunnel(tunnel, { log: context.log }); + }, + }; +} + +export const PLANNOTATOR_TUNNEL_COMMAND = createPlannotatorTunnelCommand(); + +function logAgentVmNotRunning(context: ExtensionHostCommandContext, status: Exclude): void { + if (status.state === "missing") { + context.log(`agent VM for instance '${context.instanceName}' is missing.`); + context.log(`run ./rootcell --instance ${context.instanceName} provision, then ./rootcell --instance ${context.instanceName} to start it.`); + return; + } + if (status.state === "stopped") { + context.log(`agent VM for instance '${context.instanceName}' is stopped.`); + context.log(`run ./rootcell --instance ${context.instanceName} to start it, then try again.`); + return; + } + context.log(`agent VM for instance '${context.instanceName}' is not ready: ${status.detail}`); + context.log(`resolve the VM state, then try ./rootcell --instance ${context.instanceName} extension pi-plannotator tunnel again.`); +} diff --git a/src/rootcell/extensions/registry.ts b/src/rootcell/extensions/registry.ts new file mode 100644 index 0000000..f9bf3a9 --- /dev/null +++ b/src/rootcell/extensions/registry.ts @@ -0,0 +1,145 @@ +import { z } from "zod"; +import type { LocalPortForwardHandle, LocalPortForwardOptions, VmRole, VmStatus } from "../providers/types.ts"; +import { NonEmptyStringSchema, parseSchema } from "../schema.ts"; +import type { RootcellConfig } from "../types.ts"; +import type { ParsedExtensionsConfig } from "./config.ts"; +import { PLANNOTATOR_TUNNEL_COMMAND } from "./pi-plannotator.ts"; + +export const RootcellExtensionIdSchema = z.enum(["pi-plannotator", "pi-subagents"]); + +export type RootcellExtensionId = z.infer; + +export const ExtensionGuestHookSchema = z.enum(["agentNixos", "firewallNixos", "homeManager"]); + +export type ExtensionGuestHook = z.infer; + +export interface ExtensionHostCommandContext { + readonly repoDir: string; + readonly instanceName: string; + readonly extensionConfig: ParsedExtensionsConfig; + readonly config: RootcellConfig; + readonly log: (message: string) => void; + vmStatus(role: VmRole): Promise; + forwardLocalPort(role: VmRole, options: LocalPortForwardOptions): Promise; +} + +export interface ExtensionHostCommandCompletionInput { + readonly args: readonly string[]; + readonly current: string; +} + +export type ExtensionHostCommandComplete = ( + input: ExtensionHostCommandCompletionInput, +) => readonly string[] | undefined; + +export type ExtensionHostCommandRun = ( + context: ExtensionHostCommandContext, + args: readonly string[], +) => Promise | number; + +export const ExtensionHostCommandNameSchema = z.string() + .regex(/^[a-z](?:[a-z0-9-]*[a-z0-9])?$/, "must be lowercase kebab-case"); + +const ExtensionHostCommandCompleteSchema = z.custom( + (value) => typeof value === "function", + { message: "must be a completion function" }, +); + +const ExtensionHostCommandRunSchema = z.custom( + (value) => typeof value === "function", + { message: "must be a run function" }, +); + +export const RootcellExtensionHostCommandSchema = z.object({ + name: ExtensionHostCommandNameSchema, + description: NonEmptyStringSchema, + complete: ExtensionHostCommandCompleteSchema, + run: ExtensionHostCommandRunSchema, +}).strict(); + +export type RootcellExtensionHostCommand = Readonly>; + +const RootcellExtensionHostCommandsSchema = z.array(RootcellExtensionHostCommandSchema) + .refine((commands) => new Set(commands.map((command) => command.name)).size === commands.length, { + message: "extension host command names must be unique", + }); + +export const ExtensionGuestModulePathSchema = z.string() + .regex(/^[A-Za-z0-9_./+-]+$/, "must be a repo-relative Nix module path") + .refine((path) => !path.includes(".."), "must not traverse parent directories"); + +const RootcellExtensionGuestHooksSchema = z.object({ + agentNixos: z.array(ExtensionGuestModulePathSchema), + firewallNixos: z.array(ExtensionGuestModulePathSchema), + homeManager: z.array(ExtensionGuestModulePathSchema), +}).strict(); + +export const RootcellExtensionDefinitionSchema = z.object({ + id: RootcellExtensionIdSchema, + description: NonEmptyStringSchema, + requiresProvision: z.boolean(), + guestHooks: RootcellExtensionGuestHooksSchema, + hostCommands: RootcellExtensionHostCommandsSchema, +}).strict(); + +type RootcellExtensionGuestHooksOutput = z.infer; + +type RootcellExtensionDefinitionOutput = z.infer; + +type RootcellExtensionGuestHooks = Readonly<{ + [K in keyof RootcellExtensionGuestHooksOutput]: readonly RootcellExtensionGuestHooksOutput[K][number][]; +}>; + +export type RootcellExtensionDefinition = Readonly< + Omit & { + readonly guestHooks: RootcellExtensionGuestHooks; + readonly hostCommands: readonly RootcellExtensionHostCommand[]; + } +>; + +const RootcellExtensionDefinitionsSchema = z.array(RootcellExtensionDefinitionSchema); + +const NO_HOST_COMMANDS: readonly RootcellExtensionHostCommand[] = parseSchema( + RootcellExtensionHostCommandsSchema, + [], + "invalid empty rootcell extension host commands", +); + +export const ROOTCELL_EXTENSIONS: readonly RootcellExtensionDefinition[] = parseSchema(RootcellExtensionDefinitionsSchema, [ + { + id: "pi-plannotator", + description: "Pi Plannotator integration package and remote-session configuration", + requiresProvision: true, + guestHooks: { + agentNixos: [], + firewallNixos: [], + homeManager: ["extensions/pi-plannotator/home-manager.nix"], + }, + hostCommands: [PLANNOTATOR_TUNNEL_COMMAND], + }, + { + id: "pi-subagents", + description: "Pi subagent extension and bundled example agents", + requiresProvision: true, + guestHooks: { + agentNixos: [], + firewallNixos: [], + homeManager: ["extensions/pi-subagents/home-manager.nix"], + }, + hostCommands: NO_HOST_COMMANDS, + }, +] as const, "invalid built-in rootcell extension definitions"); + +export const ROOTCELL_EXTENSION_IDS = RootcellExtensionIdSchema.options; + +export function isRootcellExtensionId(value: string): value is RootcellExtensionId { + return RootcellExtensionIdSchema.safeParse(value).success; +} + +export function rootcellExtensionById(id: RootcellExtensionId): RootcellExtensionDefinition { + const extension = ROOTCELL_EXTENSIONS.find((candidate) => candidate.id === id); + if (extension === undefined) { + throw new Error(`unknown rootcell extension id: ${id}`); + } + return extension; +} diff --git a/src/rootcell/instance.ts b/src/rootcell/instance.ts index ba6b9b6..8fe6f73 100644 --- a/src/rootcell/instance.ts +++ b/src/rootcell/instance.ts @@ -9,6 +9,7 @@ import { writeFileSync, } from "node:fs"; import { join } from "node:path"; +import { ensureExtensionsConfig } from "./extensions/config.ts"; import { parseSchema } from "./schema.ts"; import { InstanceStateSchema, @@ -19,7 +20,8 @@ import { const STATE_SCHEMA_VERSION = 1; const INSTANCE_METADATA_SCHEMA_VERSION = 1; -const DEFAULT_INSTANCE = "default"; +export const DEFAULT_INSTANCE = "default"; +const SELECTED_INSTANCE_FILE = ".selected-instance"; const DEFAULT_POOL_START = "192.168.100.0"; const DEFAULT_POOL_END = "192.168.254.0"; const INSTANCE_NAME_RE = /^[a-z](?:[a-z0-9-]{0,30}[a-z0-9])?$/; @@ -29,6 +31,7 @@ export interface InstancePaths { readonly dir: string; readonly envPath: string; readonly secretsPath: string; + readonly extensionsPath: string; readonly proxyDir: string; readonly pkiDir: string; readonly generatedDir: string; @@ -40,6 +43,10 @@ interface StateEntry { readonly state: InstanceState; } +export class SelectedInstanceStateError extends Error { + readonly status = 2; +} + export function validateInstanceName(name: string): string { if (!INSTANCE_NAME_RE.test(name)) { throw new Error(`invalid instance name '${name}' (use lowercase letters, digits, and dashes; no trailing dash; max 32 chars)`); @@ -75,6 +82,9 @@ export function seedRootcellInstanceFiles(repoDir: string, instanceName: string, log, `${instanceName} secrets.env`, ); + ensureExtensionsConfig(paths.extensionsPath, (message) => { + log(`${instanceName} ${message}`); + }); for (const file of ["allowed-https.txt", "allowed-ssh.txt", "allowed-dns.txt"]) { const legacyLive = join(repoDir, "proxy", file); @@ -127,6 +137,34 @@ export function listRootcellInstanceNames(repoDir: string, env: NodeJS.ProcessEn .sort(); } +export function selectedRootcellInstancePath(repoDir: string, env: NodeJS.ProcessEnv = process.env): string { + return join(rootcellInstancesRoot(repoDir, env), SELECTED_INSTANCE_FILE); +} + +export function readSelectedRootcellInstance(repoDir: string, env: NodeJS.ProcessEnv = process.env): string { + const path = selectedRootcellInstancePath(repoDir, env); + if (!existsSync(path)) { + return DEFAULT_INSTANCE; + } + const raw = readFileSync(path, "utf8"); + try { + return parseSelectedInstanceFile(raw); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + throw new SelectedInstanceStateError(`invalid selected rootcell instance in ${path}: ${reason}`); + } +} + +export function writeSelectedRootcellInstance(repoDir: string, instanceName: string, env: NodeJS.ProcessEnv = process.env): void { + const name = validateInstanceName(instanceName); + const root = rootcellInstancesRoot(repoDir, env); + mkdirSync(root, { recursive: true, mode: 0o700 }); + chmodSync(root, 0o700); + const path = selectedRootcellInstancePath(repoDir, env); + writeFileSync(path, `${name}\n`, { encoding: "utf8", mode: 0o600 }); + chmodSync(path, 0o600); +} + function instanceHasVmState(repoDir: string, instanceName: string, env: NodeJS.ProcessEnv): boolean { const paths = instancePaths(repoDir, instanceName, env); return existsSync(join(paths.dir, "v", "a")) @@ -141,6 +179,7 @@ function rootcellInstanceFromPaths(paths: InstancePaths, state: InstanceState): dir: paths.dir, envPath: paths.envPath, secretsPath: paths.secretsPath, + extensionsPath: paths.extensionsPath, proxyDir: paths.proxyDir, pkiDir: paths.pkiDir, generatedDir: paths.generatedDir, @@ -160,6 +199,7 @@ function pathsFromDir(name: string, dir: string): InstancePaths { dir, envPath: join(dir, ".env"), secretsPath: join(dir, "secrets.env"), + extensionsPath: join(dir, "extensions.txt"), proxyDir: join(dir, "proxy"), pkiDir: join(dir, "pki"), generatedDir: join(dir, "generated"), @@ -180,6 +220,34 @@ export function rootcellInstancesRoot(repoDir: string, env: NodeJS.ProcessEnv = return join(repoDir, "instances"); } +function parseSelectedInstanceFile(raw: string): string { + const content = stripOneTrailingNewline(raw); + if (content.length === 0) { + throw new Error("empty content"); + } + if (/\r|\n/.test(content)) { + const nonEmptyLines = content.split(/\r?\n/).filter((line) => line.length > 0); + if (nonEmptyLines.length > 1) { + throw new Error("multiple non-empty lines"); + } + throw new Error("embedded whitespace"); + } + if (/\s/.test(content)) { + throw new Error("embedded whitespace"); + } + return validateInstanceName(content); +} + +function stripOneTrailingNewline(raw: string): string { + if (raw.endsWith("\r\n")) { + return raw.slice(0, -2); + } + if (raw.endsWith("\n")) { + return raw.slice(0, -1); + } + return raw; +} + function ensureInstanceState(repoDir: string, paths: InstancePaths, env: NodeJS.ProcessEnv): InstanceState { const existingEntries = readAllInstanceStates(repoDir, env); assertNoSubnetCollisions(existingEntries); diff --git a/src/rootcell/integration/providers/macos-lima-user-v2/provider.ts b/src/rootcell/integration/providers/macos-lima-user-v2/provider.ts index de7a455..541024e 100644 --- a/src/rootcell/integration/providers/macos-lima-user-v2/provider.ts +++ b/src/rootcell/integration/providers/macos-lima-user-v2/provider.ts @@ -133,6 +133,7 @@ function limaCleanupConfig(repoDir: string, instance: string, env: NodeJS.Proces instanceDir: paths.dir, envPath: paths.envPath, secretsPath: paths.secretsPath, + extensionsPath: paths.extensionsPath, proxyDir: paths.proxyDir, pkiDir: join(paths.dir, "pki"), generatedDir: join(paths.dir, "generated"), diff --git a/src/rootcell/metadata.ts b/src/rootcell/metadata.ts index 81c2dcb..fcd14f5 100644 --- a/src/rootcell/metadata.ts +++ b/src/rootcell/metadata.ts @@ -1,13 +1,15 @@ export interface SubcommandMetadata { - readonly name: "provision" | "allow" | "pubkey" | "spy" | "list" | "stop" | "remove" | "edit"; + readonly name: "provision" | "allow" | "pubkey" | "spy" | "list" | "stop" | "remove" | "edit" | "extension" | "select"; readonly description: string; } export const ROOTCELL_SUBCOMMANDS: readonly SubcommandMetadata[] = [ + { name: "select", description: "persist the selected default rootcell instance" }, { name: "list", description: "list rootcell VMs and their current state" }, { name: "stop", description: "stop the selected rootcell instance VMs" }, { name: "remove", description: "stop the selected instance and delete VM state" }, { name: "edit", description: "open an instance config file in $EDITOR" }, + { name: "extension", description: "manage opt-in rootcell extensions" }, { name: "provision", description: "re-copy files and rebuild both VMs" }, { name: "allow", description: "hot-reload allowlists into the firewall VM" }, { name: "pubkey", description: "print the agent VM SSH public key" }, diff --git a/src/rootcell/rootcell.test.ts b/src/rootcell/rootcell.test.ts index 20e7554..76cad98 100644 --- a/src/rootcell/rootcell.test.ts +++ b/src/rootcell/rootcell.test.ts @@ -1,12 +1,39 @@ -import { describe, expect, test } from "vitest"; +import { describe, expect, test, vi } from "vitest"; import { z } from "zod"; import { parseRootcellArgs } from "./args.ts"; +import { completeExtensionCommand, runExtensionCommand } from "./extensions/commands.ts"; +import { + ensureExtensionsConfig, + formatExtensionsList, + parseExtensionsConfig, + renderExtensionsConfig, + setExtensionEnabled, +} from "./extensions/config.ts"; +import { + GENERATED_EXTENSION_HOOK_FILES, + renderExtensionNixAggregator, + writeExtensionNixAggregators, +} from "./extensions/nix.ts"; +import { + ROOTCELL_EXTENSIONS, + RootcellExtensionDefinitionSchema, + type RootcellExtensionDefinition, +} from "./extensions/registry.ts"; import { ROOTCELL_SUBCOMMANDS } from "./metadata.ts"; import { loadDotEnv, parseSecretMappings } from "./env.ts"; import { resolveHostTool } from "./host-tools.ts"; import { initRootcellInstanceEnv } from "./init-env.ts"; -import { buildConfig, chooseSpyLocalPort, formatVmList, renderSpyEnv, rootcellMain, RootcellApp } from "./rootcell.ts"; -import { deriveVmNames, instancePaths, listRootcellVmInstanceNames, loadRootcellInstance, seedRootcellInstanceFiles } from "./instance.ts"; +import { buildConfig, formatVmList, renderSpyEnv, rootcellMain, RootcellApp } from "./rootcell.ts"; +import { + deriveVmNames, + instancePaths, + listRootcellVmInstanceNames, + loadRootcellInstance, + readSelectedRootcellInstance, + seedRootcellInstanceFiles, + selectedRootcellInstancePath, + writeSelectedRootcellInstance, +} from "./instance.ts"; import { runCapture } from "./process.ts"; import { parseAwsEc2Config } from "./providers/aws-ec2-config.ts"; import { AwsEc2NetworkProvider, awsVpcRouterIp } from "./providers/aws-ec2-network.ts"; @@ -17,7 +44,7 @@ import { type TerraformRunner, } from "./providers/aws-ec2-terraform.ts"; import type { AwsEc2Api, AwsS3ObjectRef } from "./providers/aws-ec2-aws.ts"; -import type { ProviderBundle, VmNetworkAttachment } from "./providers/types.ts"; +import type { LocalPortForwardOptions, ProviderBundle, VmNetworkAttachment, VmRole } from "./providers/types.ts"; import { createProviderBundle } from "./providers/factory.ts"; import { limaNetworkListIncludes, @@ -40,9 +67,10 @@ import { ROOTCELL_IMAGE_SCHEMA_VERSION, RootcellImageManifestSchema, } from "./images.ts"; -import { forgetKnownHost, sshConfig } from "./transports/proxyjump-ssh.ts"; +import { chooseLocalPort } from "./tunnels.ts"; +import { forgetKnownHost, ProxyJumpSshTransport, sshConfig } from "./transports/proxyjump-ssh.ts"; import { dnsmasqAllowlistConfig, generatedLineCount } from "../bin/reload.ts"; -import { chmodSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { @@ -73,6 +101,44 @@ function expectRunArgs(value: ParsedRootcellRunArgs): void { expect(value).toEqual(expect.schemaMatching(ParsedRootcellRunArgsSchema)); } +describe("rootcell extension registry", () => { + test("validates host command definitions", () => { + const valid = { + id: "pi-plannotator", + description: "test extension", + requiresProvision: true, + guestHooks: { + agentNixos: [], + firewallNixos: [], + homeManager: [], + }, + hostCommands: [ + { + name: "check-status", + description: "check status", + complete: () => [], + run: () => 0, + }, + ], + }; + + expect(RootcellExtensionDefinitionSchema.safeParse(valid).success).toBe(true); + expect(RootcellExtensionDefinitionSchema.safeParse({ + ...valid, + hostCommands: [{ ...valid.hostCommands[0], name: "CheckStatus" }], + }).success).toBe(false); + }); + + test("registers Plannotator package install hook and tunnel command", () => { + const plannotator = ROOTCELL_EXTENSIONS.find((extension) => extension.id === "pi-plannotator"); + + expect(plannotator?.guestHooks.homeManager).toEqual(["extensions/pi-plannotator/home-manager.nix"]); + expect(plannotator?.guestHooks.agentNixos).toEqual(expect.schemaMatching(EmptyStringArraySchema)); + expect(plannotator?.guestHooks.firewallNixos).toEqual(expect.schemaMatching(EmptyStringArraySchema)); + expect(plannotator?.hostCommands.map((command) => command.name)).toEqual(["tunnel"]); + }); +}); + describe("rootcell argument parsing", () => { test("parses known subcommands", () => { const parsed = runArgs(["provision"]); @@ -160,10 +226,52 @@ describe("rootcell argument parsing", () => { spyOptions: { open: true }, }); + const extensions = runArgs(["edit", "extensions"]); + expectRunArgs(extensions); + expect(extensions).toEqual({ + kind: "run", + instanceName: "default", + subcommand: "edit", + rest: ["extensions"], + spyOptions: { open: true }, + }); + expect(() => parseRootcellArgs(["edit"])).toThrow(); expect(() => parseRootcellArgs(["edit", "smtp"])).toThrow(); }); + test("parses extension subcommands", () => { + const list = runArgs(["extension", "list"]); + expectRunArgs(list); + expect(list).toEqual({ + kind: "run", + instanceName: "default", + subcommand: "extension", + rest: ["list"], + spyOptions: { open: true }, + }); + + const enable = runArgs(["--instance", "dev", "extension", "enable", "pi-subagents"]); + expectRunArgs(enable); + expect(enable).toEqual({ + kind: "run", + instanceName: "dev", + subcommand: "extension", + rest: ["enable", "pi-subagents"], + spyOptions: { open: true }, + }); + + const missingNestedCommand = runArgs(["extension"]); + expectRunArgs(missingNestedCommand); + expect(missingNestedCommand).toEqual({ + kind: "run", + instanceName: "default", + subcommand: "extension", + rest: [], + spyOptions: { open: true }, + }); + }); + test("parses pass-through guest commands", () => { const explicit = runArgs(["--", "nix", "flake", "update"]); expectRunArgs(explicit); @@ -174,18 +282,12 @@ describe("rootcell argument parsing", () => { rest: ["nix", "flake", "update"], spyOptions: { open: true }, }); - const implicit = runArgs(["pi", "--model", "sonnet"]); - expectRunArgs(implicit); - expect(implicit).toEqual({ - kind: "run", - instanceName: "default", - subcommand: "", - rest: ["pi", "--model", "sonnet"], - spyOptions: { open: true }, - }); const numericOptions = runArgs(["--", "curl", "--connect-timeout", "5", "--max-time", "20", "https://github.com"]); expectRunArgs(numericOptions); expect(numericOptions.rest).toEqual(["curl", "--connect-timeout", "5", "--max-time", "20", "https://github.com"]); + + expect(() => parseRootcellArgs(["pi", "--model", "sonnet"])).toThrow("unknown rootcell command 'pi'"); + expect(() => parseRootcellArgs(["nix", "flake", "update"])).toThrow("use 'rootcell -- nix'"); }); test("parses instance flags in any command position", () => { @@ -207,7 +309,7 @@ describe("rootcell argument parsing", () => { rest: [], spyOptions: { open: true }, }); - const passThrough = runArgs(["pi", "--instance", "dev", "--model", "sonnet"]); + const passThrough = runArgs(["--instance", "dev", "--", "pi", "--model", "sonnet"]); expectRunArgs(passThrough); expect(passThrough).toEqual({ kind: "run", @@ -216,6 +318,23 @@ describe("rootcell argument parsing", () => { rest: ["pi", "--model", "sonnet"], spyOptions: { open: true }, }); + expect(() => parseRootcellArgs(["--instance", "dev", "pi"])).toThrow("use 'rootcell --instance dev -- pi'"); + }); + + test("parses select as a rootcell subcommand", () => { + expect(parseRootcellArgs(["select", "dev"])).toEqual({ + kind: "select", + selectedInstanceName: "dev", + }); + expect(parseRootcellArgs(["select", "default"])).toEqual({ + kind: "select", + selectedInstanceName: "default", + }); + expect(() => parseRootcellArgs(["select"])).toThrow(); + expect(() => parseRootcellArgs(["select", "dev", "extra"])).toThrow(); + expect(() => parseRootcellArgs(["select", "dev", "--init-env", "macos-lima"])).toThrow("--init-env cannot be combined"); + expect(() => parseRootcellArgs(["select", "dev", "--instance", "other"])).toThrow("--instance cannot be used with select"); + expect(() => parseRootcellArgs(["select", "dev", "--", "pi"])).toThrow(); }); test("rejects invalid instance names", () => { @@ -269,8 +388,8 @@ describe("rootcell argument parsing", () => { expect(() => renderSpyEnv({}, ["bad-name=value"])).toThrow("invalid spy environment variable name"); }); - test("chooses a fallback spy port when the preferred port is occupied", async () => { - const chosen = await chooseSpyLocalPort(6174, "127.0.0.1", (port) => Promise.resolve(port !== 6174)); + test("chooses a fallback local tunnel port when the preferred port is occupied", async () => { + const chosen = await chooseLocalPort(6174, "127.0.0.1", 100, (port) => Promise.resolve(port !== 6174)); expect(chosen).toBe(6175); }); @@ -441,6 +560,111 @@ describe("environment parsing", () => { }); }); +describe("rootcell extension config", () => { + test("parses extension booleans and unknown valid keys", () => { + const config = parseExtensionsConfig([ + "# local extension choices", + "pi-subagents=yes", + "pi-plannotator", + "future-extension=off", + "", + ].join("\n")); + + expect(config.enabled.has("pi-subagents")).toBe(true); + expect(config.enabled.has("pi-plannotator")).toBe(false); + expect(config.unknownKeys).toEqual(["future-extension"]); + }); + + test("rejects invalid extension keys, values, and duplicates", () => { + expect(() => parseExtensionsConfig("Bad=true\n")).toThrow("invalid extension key"); + expect(() => parseExtensionsConfig("pi-subagents=maybe\n")).toThrow("invalid boolean value"); + expect(() => parseExtensionsConfig("pi-subagents=true\npi-subagents=false\n")).toThrow("duplicate extension key"); + }); + + test("preserves comments, ordering, and unknown keys while appending missing known keys", () => { + const rendered = renderExtensionsConfig(parseExtensionsConfig([ + "# keep this", + "future-extension=true", + "", + "pi-subagents=off", + "", + ].join("\n")), new Map([["pi-subagents", true]])); + + expect(rendered).toBe([ + "# keep this", + "future-extension=true", + "", + "pi-subagents=true", + "pi-plannotator=false", + "", + ].join("\n")); + }); + + test("seeds and rewrites extensions.txt idempotently", () => { + const repo = makeInstanceRepo(); + try { + const path = join(repo, "extensions.txt"); + const seeded = ensureExtensionsConfig(path); + expect(formatExtensionsList(seeded)).toContain("pi-plannotator disabled"); + expect(readFileSync(path, "utf8")).toBe("pi-plannotator=false\npi-subagents=false\n"); + + const enabled = setExtensionEnabled(path, "pi-subagents", true); + expect(enabled.changed).toBe(true); + expect(readFileSync(path, "utf8")).toBe("pi-plannotator=false\npi-subagents=true\n"); + + const enabledAgain = setExtensionEnabled(path, "pi-subagents", true); + expect(enabledAgain.changed).toBe(false); + expect(readFileSync(path, "utf8")).toBe("pi-plannotator=false\npi-subagents=true\n"); + } finally { + rmSync(repo, { recursive: true, force: true }); + } + }); + + test("migrates legacy Pi extension ids in extensions.txt", () => { + const rendered = renderExtensionsConfig(parseExtensionsConfig("plannotator=true\nsubagent=false\n")); + expect(rendered).toBe("pi-plannotator=true\npi-subagents=false\n"); + expect(() => parseExtensionsConfig("plannotator=true\npi-plannotator=false\n")).toThrow("duplicate extension key"); + }); +}); + +describe("rootcell extension Nix hooks", () => { + test("renders empty aggregators when no extensions are enabled", () => { + expect(renderExtensionNixAggregator(parseExtensionsConfig("pi-subagents=false\n"), "homeManager")).toBe([ + "# Generated by ./rootcell from this instance's extensions.txt. DO NOT EDIT.", + "{ ... }:", + "{", + " imports = [", + " ];", + "}", + "", + ].join("\n")); + }); + + test("renders enabled Home Manager extension imports", () => { + const rendered = renderExtensionNixAggregator(parseExtensionsConfig("pi-subagents=true\npi-plannotator=true\n"), "homeManager"); + expect(rendered).toContain("../extensions/pi-subagents/home-manager.nix"); + expect(rendered).toContain("../extensions/pi-plannotator/home-manager.nix"); + expect(renderExtensionNixAggregator(parseExtensionsConfig("pi-subagents=true\n"), "agentNixos")).not.toContain("../extensions/pi-subagents"); + expect(renderExtensionNixAggregator(parseExtensionsConfig("pi-plannotator=false\n"), "homeManager")).not.toContain("../extensions/pi-plannotator/home-manager.nix"); + }); + + test("writes the explicit generated hook files", () => { + const repo = makeInstanceRepo(); + try { + const generatedDir = join(repo, "generated"); + mkdirSync(generatedDir, { recursive: true }); + writeExtensionNixAggregators(generatedDir, parseExtensionsConfig("pi-subagents=true\n")); + + for (const file of GENERATED_EXTENSION_HOOK_FILES) { + expect(readFileSync(join(generatedDir, file), "utf8")).toContain("Generated by ./rootcell"); + } + expect(readFileSync(join(generatedDir, "extensions-home-manager.nix"), "utf8")).toContain("../extensions/pi-subagents/home-manager.nix"); + } finally { + rmSync(repo, { recursive: true, force: true }); + } + }); +}); + describe("host tool resolution", () => { const limaSpec = { name: "limactl", @@ -867,6 +1091,111 @@ describe("VM and network providers", () => { ]); }); + test("spy uses the shared tunnel fallback and foreground close path", async () => { + const repo = makeInstanceRepo(); + const oldSpyEnabled = process.env.ROOTCELL_SPY_ENABLED; + const stdout = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + try { + process.env.ROOTCELL_SPY_ENABLED = "true"; + const env = instanceEnv(repo); + const config = buildConfig(repo, env, fakeInstance("dev", repo, env)); + mkdirSync(config.instanceDir, { recursive: true }); + mkdirSync(config.generatedDir, { recursive: true }); + mkdirSync(config.proxyDir, { recursive: true }); + mkdirSync(config.pkiDir, { recursive: true, mode: 0o700 }); + for (const file of ["agent-vm-ca.key", "agent-vm-ca-cert.pem", "agent-vm-ca.pem"]) { + writeFileSync(join(config.pkiDir, file), "test\n", "utf8"); + } + for (const file of ["allowed-https.txt", "allowed-ssh.txt", "allowed-dns.txt"]) { + writeFileSync(join(config.proxyDir, file), "\n", "utf8"); + } + + const attachment: VmNetworkAttachment = { kind: "fake" }; + const calls: string[] = []; + let forwarded: { name: string; options: LocalPortForwardOptions } | undefined; + let closeCalls = 0; + const providers: ProviderBundle = { + network: { + id: "fake-network", + plan: () => ({ + provider: "fake-network", + guest: { + firewallIp: config.firewallIp, + agentIp: config.agentIp, + networkPrefix: 24, + agentPrivateInterface: "agent0", + firewallPrivateInterface: "firewall0", + firewallEgressInterface: "egress0", + }, + vms: { + agent: attachment, + firewall: attachment, + }, + }), + preflight: () => Promise.resolve(), + stop: () => Promise.resolve(), + remove: () => Promise.resolve(), + ensureReady: () => Promise.resolve(), + }, + vm: { + id: "fake-vm", + status: (name) => { + calls.push(`status:${name}`); + return Promise.resolve({ state: "running" }); + }, + stopIfRunning: () => Promise.resolve(), + forceStopIfRunning: () => Promise.resolve(), + remove: () => Promise.resolve(), + assertCompatible: (name) => { + calls.push(`assert:${name}`); + return Promise.resolve(); + }, + ensureRunning: (input) => { + calls.push(`ensure:${input.role}:${input.name}`); + return Promise.resolve({ created: false }); + }, + exec: () => Promise.resolve({ status: 0 }), + execCapture: () => Promise.resolve({ status: 0, stdout: "", stderr: "" }), + execInteractive: () => Promise.resolve(0), + copyToGuest: () => Promise.resolve(), + forwardLocalPort: (name, options) => { + forwarded = { name, options }; + return Promise.resolve({ + ...options, + closed: Promise.resolve(0), + close: () => { + closeCalls += 1; + return Promise.resolve(); + }, + }); + }, + }, + secrets: new StaticSecretProviderRegistry([]), + }; + + const status = await new RootcellApp(config, providers, { + tunnelPortAvailable: (port) => Promise.resolve(port !== 6174), + }).runAfterEnvironment("spy", [], { open: false }); + + expect(status).toBe(0); + expect(forwarded?.name).toBe(config.firewallVm); + expect(forwarded?.options.localHost).toBe("127.0.0.1"); + expect(typeof forwarded?.options.localPort).toBe("number"); + expect(forwarded?.options.remoteHost).toBe("127.0.0.1"); + expect(forwarded?.options.remotePort).toBe(6174); + expect(forwarded?.options.localPort).toBe(6175); + expect(closeCalls).toBe(1); + expect(stdout.mock.calls.map((call) => String(call[0])).join("")).toContain( + `http://127.0.0.1:${String(forwarded?.options.localPort)}/?since=`, + ); + expect(calls).toContain(`ensure:firewall:${config.firewallVm}`); + } finally { + stdout.mockRestore(); + restoreEnv("ROOTCELL_SPY_ENABLED", oldSpyEnabled); + rmSync(repo, { recursive: true, force: true }); + } + }); + test("macOS Lima user-v2 provider exposes egress firewall and private-only agent attachments", () => { const config = buildConfig("/repo", {}, fakeInstance("dev")); const plan = new MacOsLimaUserV2NetworkProvider(config, ignoreLog).plan(); @@ -1072,6 +1401,7 @@ describe("VM and network providers", () => { expect(commonModule).toContain("networking.nat.enable = lib.mkForce false;"); const firewallModule = readFileSync("firewall-vm.nix", "utf8"); + expect(firewallModule).toContain("extensions-firewall-vm.nix"); expect(firewallModule).toContain("systemd.network.wait-online.enable = false;"); expect(firewallModule).toContain("linkConfig.RequiredForOnline = false;"); expect(firewallModule).toContain("Rootcell keeps DHCP routes and DNS disabled"); @@ -1099,16 +1429,23 @@ describe("VM and network providers", () => { const rootcellSource = readFileSync("src/rootcell/rootcell.ts", "utf8"); expect(rootcellSource).toContain("\"dist/spy-service.js\""); expect(rootcellSource).toContain("\"dist/spy-ui\""); + expect(rootcellSource).toContain("GENERATED_EXTENSION_HOOK_FILES"); + expect(rootcellSource).toContain("copyGeneratedExtensionsIntoVm"); expect(rootcellSource).toContain("private guestFlakeRef"); expect(rootcellSource).toContain("path:${this.config.guestRepoDir}#"); expect(rootcellSource).toContain("sudo grep -Eq '^ROOTCELL_SPY_ENABLED="); expect(rootcellSource).not.toContain("private async installFirewallSpyAssets"); const agentModule = readFileSync("agent-vm.nix", "utf8"); + expect(agentModule).toContain("extensions-agent-vm.nix"); expect(agentModule).toContain('DHCP = "ipv4";'); expect(agentModule).toContain("UseDNS = false;"); expect(agentModule).toContain("UseRoutes = false;"); expect(agentModule).toContain("PreferredSource = net.agentIp;"); + + const homeModule = readFileSync("home.nix", "utf8"); + expect(homeModule).toContain("extensions-home-manager.nix"); + expect(homeModule).not.toContain(".pi/agent/extensions/subagent"); }); test("user-v2 proof gate rejects extra agent interfaces and default-route bypasses", () => { @@ -1451,6 +1788,88 @@ describe("VM and network providers", () => { } }); + test("proxyjump local port forwarding builds SSH local-forward args and closes the process", async () => { + const dir = mkdtempSync(join(tmpdir(), "rootcell-forward-")); + const oldPath = process.env.PATH; + const oldArgsPath = process.env.ROOTCELL_FAKE_SSH_ARGS; + try { + const bin = join(dir, "bin"); + mkdirSync(bin, { recursive: true }); + const ssh = join(bin, "ssh"); + writeFileSync(ssh, fakeForwardingSshScript(), "utf8"); + chmodSync(ssh, 0o755); + const argsPath = join(dir, "ssh-args.txt"); + process.env.PATH = `${bin}:${oldPath ?? ""}`; + process.env.ROOTCELL_FAKE_SSH_ARGS = argsPath; + + const config = buildConfig(dir, {}, fakeInstance("dev", dir)); + const transport = new ProxyJumpSshTransport(config, () => ({ + firewallHost: "127.0.0.1", + firewallPort: 60_022, + agentHost: "192.168.109.11", + identityPath: join(dir, "rootcell_control_ed25519"), + knownHostsPath: join(dir, "known_hosts"), + })); + + const tunnel = await transport.forwardLocalPort(config.agentVm, { + localHost: "127.0.0.1", + localPort: 19_432, + remoteHost: "127.0.0.1", + remotePort: 6174, + }); + await tunnel.close(); + + const args = readLines(argsPath); + const forwardArgIndex = args.indexOf("-L"); + expect(forwardArgIndex).toBeGreaterThanOrEqual(0); + expect(args[forwardArgIndex + 1]).toBe("127.0.0.1:19432:127.0.0.1:6174"); + expect(args).toContain("ExitOnForwardFailure=yes"); + expect(args.at(-1)).toBe("rootcell-agent"); + } finally { + process.env.PATH = oldPath; + restoreEnv("ROOTCELL_FAKE_SSH_ARGS", oldArgsPath); + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("proxyjump local port forwarding reports SSH startup failure", async () => { + const dir = mkdtempSync(join(tmpdir(), "rootcell-forward-failure-")); + const oldPath = process.env.PATH; + const oldArgsPath = process.env.ROOTCELL_FAKE_SSH_ARGS; + const oldFail = process.env.ROOTCELL_FAKE_SSH_FAIL; + try { + const bin = join(dir, "bin"); + mkdirSync(bin, { recursive: true }); + const ssh = join(bin, "ssh"); + writeFileSync(ssh, fakeForwardingSshScript(), "utf8"); + chmodSync(ssh, 0o755); + process.env.PATH = `${bin}:${oldPath ?? ""}`; + process.env.ROOTCELL_FAKE_SSH_ARGS = join(dir, "ssh-args.txt"); + process.env.ROOTCELL_FAKE_SSH_FAIL = "1"; + + const config = buildConfig(dir, {}, fakeInstance("dev", dir)); + const transport = new ProxyJumpSshTransport(config, () => ({ + firewallHost: "127.0.0.1", + firewallPort: 60_022, + agentHost: "192.168.109.11", + identityPath: join(dir, "rootcell_control_ed25519"), + knownHostsPath: join(dir, "known_hosts"), + })); + + await expect(transport.forwardLocalPort(config.firewallVm, { + localHost: "127.0.0.1", + localPort: 19_432, + remoteHost: "127.0.0.1", + remotePort: 6174, + })).rejects.toThrow("SSH local port forward failed with exit 255: fake ssh failed"); + } finally { + process.env.PATH = oldPath; + restoreEnv("ROOTCELL_FAKE_SSH_ARGS", oldArgsPath); + restoreEnv("ROOTCELL_FAKE_SSH_FAIL", oldFail); + rmSync(dir, { recursive: true, force: true }); + } + }); + test("Lima state parser validates running state shape", () => { const state = parseLimaVmState({ provider: "lima", @@ -1626,6 +2045,21 @@ describe("VM and network providers", () => { "dev firewall-dev stopped", "", ].join("\n")); + expect(formatVmList([ + { instance: "dev", vm: "agent-dev", state: "missing" }, + { instance: "dev", vm: "firewall-dev", state: "missing" }, + ], { selectedInstance: "dev" })).toBe([ + "INSTANCE VM STATE", + "dev (selected) agent-dev missing", + "dev (selected) firewall-dev missing", + "", + ].join("\n")); + expect(formatVmList([ + { instance: "dev", vm: "agent-dev", state: "running" }, + ], { selectedInstance: "dev", color: true })).toContain("\u001b[1;32mdev (selected)"); + expect(formatVmList([ + { instance: "dev", vm: "agent-dev", state: "running" }, + ], { selectedInstance: "dev", stdoutIsTty: true, env: { NO_COLOR: "1" } })).not.toContain("\u001b["); expect(formatVmList([])).toBe("No rootcell VMs found.\n"); }); }); @@ -1691,6 +2125,51 @@ describe("instance state", () => { expect(instancePaths("/repo", "dev", {}).dir).toBe("/repo/instances/dev"); }); + test("stores and reads the selected default instance", () => { + const repo = makeInstanceRepo(); + try { + const env = instanceEnv(repo); + expect(readSelectedRootcellInstance(repo, env)).toBe("default"); + expect(existsSync(selectedRootcellInstancePath(repo, env))).toBe(false); + + writeSelectedRootcellInstance(repo, "dev", env); + + const selectedPath = selectedRootcellInstancePath(repo, env); + expect(readFileSync(selectedPath, "utf8")).toBe("dev\n"); + expect(readSelectedRootcellInstance(repo, env)).toBe("dev"); + expect(statSync(env.ROOTCELL_STATE_DIR ?? "").mode & 0o777).toBe(0o700); + expect(statSync(selectedPath).mode & 0o777).toBe(0o600); + + writeSelectedRootcellInstance(repo, "default", env); + expect(readSelectedRootcellInstance(repo, env)).toBe("default"); + } finally { + rmSync(repo, { recursive: true, force: true }); + } + }); + + test("rejects corrupted selected instance state", () => { + const repo = makeInstanceRepo(); + try { + const env = instanceEnv(repo); + mkdirSync(env.ROOTCELL_STATE_DIR ?? "", { recursive: true }); + const path = selectedRootcellInstancePath(repo, env); + + writeFileSync(path, "", "utf8"); + expect(() => readSelectedRootcellInstance(repo, env)).toThrow("empty content"); + + writeFileSync(path, "dev\nother\n", "utf8"); + expect(() => readSelectedRootcellInstance(repo, env)).toThrow("multiple non-empty lines"); + + writeFileSync(path, "dev other\n", "utf8"); + expect(() => readSelectedRootcellInstance(repo, env)).toThrow("embedded whitespace"); + + writeFileSync(path, "Dev\n", "utf8"); + expect(() => readSelectedRootcellInstance(repo, env)).toThrow("invalid instance name"); + } finally { + rmSync(repo, { recursive: true, force: true }); + } + }); + test("allocates stable unique /24 networks", () => { const repo = makeInstanceRepo(); try { @@ -1839,6 +2318,37 @@ describe("instance state", () => { }); describe("rootcell edit command", () => { + test("uses the selected default instance when --instance is omitted", async () => { + const repo = makeInstanceRepo(); + const oldRootcellStateDir = process.env.ROOTCELL_STATE_DIR; + const oldEditor = process.env.EDITOR; + const oldRecord = process.env.ROOTCELL_EDITOR_RECORD; + try { + mkdirSync(join(repo, "src", "bin"), { recursive: true }); + writeFileSync(join(repo, "flake.nix"), "{}\n", "utf8"); + + const editor = join(repo, "editor.sh"); + const record = join(repo, "opened.txt"); + writeFileSync(editor, "#!/bin/sh\nprintf '%s\\n' \"$1\" > \"$ROOTCELL_EDITOR_RECORD\"\n", "utf8"); + chmodSync(editor, 0o700); + + process.env.ROOTCELL_STATE_DIR = join(repo, ".state"); + process.env.EDITOR = editor; + process.env.ROOTCELL_EDITOR_RECORD = record; + writeSelectedRootcellInstance(repo, "dev", process.env); + + const status = await rootcellMain(["edit", "dns"], join(repo, "src", "bin", "rootcell.ts")); + + expect(status).toBe(0); + expect(readFileSync(record, "utf8").trim()).toBe(join(repo, ".state", "dev", "proxy", "allowed-dns.txt")); + } finally { + restoreEnv("ROOTCELL_STATE_DIR", oldRootcellStateDir); + restoreEnv("EDITOR", oldEditor); + restoreEnv("ROOTCELL_EDITOR_RECORD", oldRecord); + rmSync(repo, { recursive: true, force: true }); + } + }); + test("opens the selected instance allowlist in EDITOR", async () => { const repo = makeInstanceRepo(); const oldRootcellStateDir = process.env.ROOTCELL_STATE_DIR; @@ -1871,6 +2381,196 @@ describe("rootcell edit command", () => { } }); + test("opens the selected instance extensions file in EDITOR", async () => { + const repo = makeInstanceRepo(); + const oldRootcellStateDir = process.env.ROOTCELL_STATE_DIR; + const oldEditor = process.env.EDITOR; + const oldRecord = process.env.ROOTCELL_EDITOR_RECORD; + try { + mkdirSync(join(repo, "src", "bin"), { recursive: true }); + writeFileSync(join(repo, "flake.nix"), "{}\n", "utf8"); + + const editor = join(repo, "editor.sh"); + const record = join(repo, "opened.txt"); + writeFileSync(editor, "#!/bin/sh\nprintf '%s\\n' \"$1\" > \"$ROOTCELL_EDITOR_RECORD\"\n", "utf8"); + chmodSync(editor, 0o700); + + process.env.ROOTCELL_STATE_DIR = join(repo, ".state"); + process.env.EDITOR = editor; + process.env.ROOTCELL_EDITOR_RECORD = record; + + const status = await rootcellMain(["--instance", "dev", "edit", "extensions"], join(repo, "src", "bin", "rootcell.ts")); + + expect(status).toBe(0); + expect(readFileSync(record, "utf8").trim()).toBe(join(repo, ".state", "dev", "extensions.txt")); + expect(readFileSync(join(repo, ".state", "dev", "extensions.txt"), "utf8")).toBe("pi-plannotator=false\npi-subagents=false\n"); + } finally { + restoreEnv("ROOTCELL_STATE_DIR", oldRootcellStateDir); + restoreEnv("EDITOR", oldEditor); + restoreEnv("ROOTCELL_EDITOR_RECORD", oldRecord); + rmSync(repo, { recursive: true, force: true }); + } + }); +}); + +describe("rootcell extension command", () => { + test("lists, enables, disables, and rejects unknown extensions", () => { + const repo = makeInstanceRepo(); + try { + const env = { ...process.env, ROOTCELL_STATE_DIR: join(repo, ".state") }; + const list = runCapture("./rootcell", ["--instance", "dev", "extension", "list"], { env }); + expect(list.stdout).toContain("pi-plannotator disabled"); + expect(list.stdout).toContain("pi-subagents disabled"); + expect(readFileSync(join(repo, ".state", "dev", "extensions.txt"), "utf8")).toBe("pi-plannotator=false\npi-subagents=false\n"); + + const enable = runCapture("./rootcell", ["--instance", "dev", "extension", "enable", "pi-subagents"], { env }); + expect(enable.stdout).toContain("pi-subagents enabled for instance 'dev'."); + expect(enable.stdout).toContain("run ./rootcell --instance dev provision to apply VM changes."); + expect(readFileSync(join(repo, ".state", "dev", "extensions.txt"), "utf8")).toBe("pi-plannotator=false\npi-subagents=true\n"); + + const enableAgain = runCapture("./rootcell", ["--instance", "dev", "extension", "enable", "pi-subagents"], { env }); + expect(enableAgain.stdout).toContain("pi-subagents already enabled for instance 'dev'."); + + const disable = runCapture("./rootcell", ["--instance", "dev", "extension", "disable", "pi-subagents"], { env }); + expect(disable.stdout).toContain("pi-subagents disabled for instance 'dev'."); + expect(readFileSync(join(repo, ".state", "dev", "extensions.txt"), "utf8")).toBe("pi-plannotator=false\npi-subagents=false\n"); + + const invalid = runCapture("./rootcell", ["--instance", "dev", "extension", "enable", "missing"], { env, allowFailure: true }); + expect(invalid.status).toBe(2); + expect(invalid.stderr).toContain("unknown extension id 'missing'"); + } finally { + rmSync(repo, { recursive: true, force: true }); + } + }); + + test("dispatches enabled extension host commands through a narrow context", async () => { + const repo = makeInstanceRepo(); + try { + const env = { ...process.env, ROOTCELL_STATE_DIR: join(repo, ".state") }; + mkdirSync(join(repo, ".state", "dev"), { recursive: true }); + writeFileSync(join(repo, ".state", "dev", "extensions.txt"), "pi-plannotator=true\npi-subagents=false\n", "utf8"); + const calls: string[] = []; + const extensions = testHostCommandExtensions(async (context, args) => { + calls.push(`run:${context.instanceName}:${args.join(",")}:${context.extensionConfig.enabled.has("pi-plannotator") ? "enabled" : "disabled"}`); + const status = await context.vmStatus("agent"); + calls.push(`status:${status.state}`); + const tunnel = await context.forwardLocalPort("firewall", { + localHost: "127.0.0.1", + localPort: 1234, + remoteHost: "127.0.0.1", + remotePort: 5678, + }); + calls.push(`forward:${tunnel.localHost}:${String(tunnel.localPort)}:${String(tunnel.remotePort)}`); + return 0; + }); + + const status = await runExtensionCommand({ + repoDir: repo, + env, + instanceName: "dev", + rest: ["pi-plannotator", "check", "one", "two"], + log: (message) => calls.push(`log:${message}`), + extensions, + createContext: ({ extension, command, extensionConfig }) => { + calls.push(`context:${extension.id}:${command.name}`); + return { + repoDir: repo, + instanceName: "dev", + extensionConfig, + config: buildConfig(repo, env, fakeInstance("dev", repo, env)), + log: (message) => calls.push(`ctx-log:${message}`), + vmStatus: (role) => { + calls.push(`vmStatus:${role}`); + return Promise.resolve({ state: "running" }); + }, + forwardLocalPort: (role, options) => { + calls.push(`forwardLocalPort:${role}`); + return Promise.resolve({ + ...options, + closed: Promise.resolve(0), + close: () => Promise.resolve(), + }); + }, + }; + }, + }); + + expect(status).toBe(0); + expect(calls).toEqual([ + "context:pi-plannotator:check", + "run:dev:one,two:enabled", + "vmStatus:agent", + "status:running", + "forwardLocalPort:firewall", + "forward:127.0.0.1:1234:5678", + ]); + } finally { + rmSync(repo, { recursive: true, force: true }); + } + }); + + test("rejects disabled, unknown, and missing extension host command paths", async () => { + const repo = makeInstanceRepo(); + try { + const env = { ...process.env, ROOTCELL_STATE_DIR: join(repo, ".state") }; + const logs: string[] = []; + let contexts = 0; + const input = { + repoDir: repo, + env, + instanceName: "dev", + log: (message: string) => logs.push(message), + extensions: testHostCommandExtensions(), + createContext: () => { + contexts += 1; + return { + repoDir: repo, + instanceName: "dev", + extensionConfig: parseExtensionsConfig("pi-plannotator=true\n"), + config: buildConfig(repo, env, fakeInstance("dev", repo, env)), + log: ignoreLog, + vmStatus: (role: VmRole) => { + void role; + return Promise.resolve({ state: "running" as const }); + }, + forwardLocalPort: (role: VmRole, options: LocalPortForwardOptions) => { + void role; + return Promise.resolve({ + ...options, + closed: Promise.resolve(0), + close: () => Promise.resolve(), + }); + }, + }; + }, + }; + + expect(await runExtensionCommand({ ...input, rest: ["pi-plannotator", "check"] })).toBe(1); + expect(logs.join("\n")).toContain("extension 'pi-plannotator' is disabled"); + expect(existsSync(join(repo, ".state", "dev", "extensions.txt"))).toBe(false); + expect(contexts).toBe(0); + + mkdirSync(join(repo, ".state", "dev"), { recursive: true }); + writeFileSync(join(repo, ".state", "dev", "extensions.txt"), "pi-plannotator=true\npi-subagents=false\n", "utf8"); + logs.length = 0; + expect(await runExtensionCommand({ ...input, rest: ["missing", "check"] })).toBe(2); + expect(logs.join("\n")).toContain("unknown extension command or id 'missing'"); + + logs.length = 0; + expect(await runExtensionCommand({ ...input, rest: ["pi-plannotator"] })).toBe(2); + expect(logs.join("\n")).toContain("usage: rootcell extension pi-plannotator "); + + logs.length = 0; + expect(await runExtensionCommand({ ...input, rest: ["pi-plannotator", "missing"] })).toBe(2); + expect(logs.join("\n")).toContain("unknown command for extension 'pi-plannotator': 'missing'"); + expect(contexts).toBe(0); + } finally { + rmSync(repo, { recursive: true, force: true }); + } + }); +}); + +describe("rootcell edit env command", () => { test("opens the selected instance environment in EDITOR", async () => { const repo = makeInstanceRepo(); const oldRootcellStateDir = process.env.ROOTCELL_STATE_DIR; @@ -1903,6 +2603,49 @@ describe("rootcell edit command", () => { }); }); +describe("rootcell select command", () => { + test("persists the selected default without creating instance files", () => { + const repo = makeInstanceRepo(); + try { + const env = { ...process.env, ROOTCELL_STATE_DIR: join(repo, ".state") }; + const result = runCapture("./rootcell", ["select", "dev"], { env }); + + expect(result.status).toBe(0); + expect(result.stdout).toBe("selected rootcell instance 'dev'\n"); + expect(readFileSync(join(repo, ".state", ".selected-instance"), "utf8")).toBe("dev\n"); + expect(existsSync(join(repo, ".state", "dev"))).toBe(false); + } finally { + rmSync(repo, { recursive: true, force: true }); + } + }); + + test("list marks the selected instance and tolerates corrupted selection when explicit", () => { + const repo = makeInstanceRepo(); + try { + const env = { ...process.env, ROOTCELL_STATE_DIR: join(repo, ".state") }; + writeSelectedRootcellInstance(repo, "jmp", env); + + const selectedList = runCapture("./rootcell", ["list"], { env }); + expect(selectedList.status).toBe(0); + expect(selectedList.stdout).toContain("jmp (selected) agent-jmp missing"); + expect(selectedList.stdout).toContain("jmp (selected) firewall-jmp missing"); + + writeFileSync(join(repo, ".state", ".selected-instance"), "bad instance\n", "utf8"); + const invalidList = runCapture("./rootcell", ["list"], { env, allowFailure: true }); + expect(invalidList.status).toBe(2); + expect(invalidList.stderr).toContain("invalid selected rootcell instance in"); + expect(invalidList.stderr).toContain("embedded whitespace"); + + const explicitList = runCapture("./rootcell", ["list", "--instance", "dev"], { env }); + expect(explicitList.status).toBe(0); + expect(explicitList.stdout).toContain("dev agent-dev missing"); + expect(explicitList.stdout).not.toContain("(selected)"); + } finally { + rmSync(repo, { recursive: true, force: true }); + } + }); +}); + describe("reload helper", () => { test("generates dnsmasq server entries from non-comment lines", () => { const config = dnsmasqAllowlistConfig("# comment\n\nexample.com\n*.example.org\n"); @@ -1935,6 +2678,131 @@ describe("shell completions", () => { expect(choices).toContain(subcommand.name); } }); + + test("extension completions use selected instance state without seeding", () => { + const repo = makeInstanceRepo(); + try { + const stateDir = join(repo, ".state"); + const instanceDir = join(stateDir, "dev"); + mkdirSync(instanceDir, { recursive: true }); + writeFileSync(join(instanceDir, "extensions.txt"), "pi-plannotator=false\npi-subagents=true\n", "utf8"); + const env = completionEnv("/bin/bash"); + env.ROOTCELL_STATE_DIR = stateDir; + writeSelectedRootcellInstance(repo, "dev", env); + + const root = runCapture("./rootcell", ["--get-yargs-completions", "rootcell", "--instance", "dev", "extension", ""], { env }).stdout; + expect(root).toContain("list\n"); + expect(root).toContain("enable\n"); + expect(root).toContain("disable\n"); + + const selectedRoot = runCapture("./rootcell", ["--get-yargs-completions", "rootcell", "extension", ""], { env }).stdout; + expect(selectedRoot).toContain("list\n"); + expect(selectedRoot).toContain("enable\n"); + expect(selectedRoot).toContain("disable\n"); + + const enable = runCapture("./rootcell", ["--get-yargs-completions", "rootcell", "--instance", "dev", "extension", "enable", ""], { env }).stdout; + expect(enable).toContain("pi-plannotator\n"); + expect(enable).not.toContain("pi-subagents\n"); + + const disable = runCapture("./rootcell", ["--get-yargs-completions", "rootcell", "--instance", "dev", "extension", "disable", ""], { env }).stdout; + expect(disable).toContain("pi-subagents\n"); + expect(disable).not.toContain("pi-plannotator\n"); + + const missing = runCapture("./rootcell", ["--get-yargs-completions", "rootcell", "--instance", "new", "extension", "disable", ""], { env }).stdout; + expect(missing).toBe(""); + expect(existsSync(join(stateDir, "new", "extensions.txt"))).toBe(false); + + writeFileSync(join(stateDir, ".selected-instance"), "bad instance\n", "utf8"); + const corruptedSelected = runCapture("./rootcell", ["--get-yargs-completions", "rootcell", "extension", "disable", ""], { env }).stdout; + expect(corruptedSelected).toBe(""); + } finally { + rmSync(repo, { recursive: true, force: true }); + } + }); + + test("select completions include known instance names", () => { + const repo = makeInstanceRepo(); + try { + const env = completionEnv("/bin/bash"); + env.ROOTCELL_STATE_DIR = join(repo, ".state"); + seedRootcellInstanceFiles(repo, "dev", ignoreLog, env); + seedRootcellInstanceFiles(repo, "review", ignoreLog, env); + + const choices = runCapture("./rootcell", ["--get-yargs-completions", "rootcell", "select", ""], { env }).stdout; + + expect(choices).toContain("dev\n"); + expect(choices).toContain("review\n"); + } finally { + rmSync(repo, { recursive: true, force: true }); + } + }); + + test("extension completions expose enabled host command groups", () => { + const repo = makeInstanceRepo(); + try { + const stateDir = join(repo, ".state"); + const instanceDir = join(stateDir, "dev"); + const env = { ...completionEnv("/bin/bash"), ROOTCELL_STATE_DIR: stateDir }; + const extensions = testHostCommandExtensions(); + mkdirSync(instanceDir, { recursive: true }); + writeFileSync(join(instanceDir, "extensions.txt"), "pi-plannotator=true\npi-subagents=false\n", "utf8"); + + const root = completeExtensionCommand({ + repoDir: repo, + env, + instanceName: "dev", + words: ["extension", ""], + current: "", + extensions, + }); + expect(root).toContain("list"); + expect(root).toContain("pi-plannotator"); + + const commands = completeExtensionCommand({ + repoDir: repo, + env, + instanceName: "dev", + words: ["extension", "pi-plannotator", ""], + current: "", + extensions, + }); + expect(commands).toEqual(["check"]); + + const commandArgs = completeExtensionCommand({ + repoDir: repo, + env, + instanceName: "dev", + words: ["extension", "pi-plannotator", "check", ""], + current: "", + extensions, + }); + expect(commandArgs).toEqual(["alpha"]); + + writeFileSync(join(instanceDir, "extensions.txt"), "pi-plannotator=false\npi-subagents=false\n", "utf8"); + const disabledRoot = completeExtensionCommand({ + repoDir: repo, + env, + instanceName: "dev", + words: ["extension", ""], + current: "", + extensions, + }); + expect(disabledRoot).not.toContain("pi-plannotator"); + + const missing = completeExtensionCommand({ + repoDir: repo, + env, + instanceName: "new", + words: ["extension", ""], + current: "", + extensions, + }); + expect(missing).not.toContain("pi-plannotator"); + expect(existsSync(join(stateDir, "new", "extensions.txt"))).toBe(false); + } finally { + rmSync(repo, { recursive: true, force: true }); + } + }); }); function runArgs(args: readonly string[]): ParsedRootcellRunArgs { @@ -1949,6 +2817,42 @@ function generatedCompletion(shell: string): string { return stripTrailingBlankLine(runCapture("./rootcell", ["completion"], { env: completionEnv(shell) }).stdout); } +function testHostCommandExtensions( + run: RootcellExtensionDefinition["hostCommands"][number]["run"] = () => 0, +): readonly RootcellExtensionDefinition[] { + return [ + { + id: "pi-plannotator", + description: "test host command extension", + requiresProvision: true, + guestHooks: { + agentNixos: [], + firewallNixos: [], + homeManager: [], + }, + hostCommands: [ + { + name: "check", + description: "test host command", + complete: ({ current }) => ["alpha"].filter((value) => value.startsWith(current)), + run, + }, + ], + }, + { + id: "pi-subagents", + description: "test extension without host commands", + requiresProvision: true, + guestHooks: { + agentNixos: [], + firewallNixos: [], + homeManager: [], + }, + hostCommands: [], + }, + ]; +} + function completionEnv(shell: string): NodeJS.ProcessEnv { const env: NodeJS.ProcessEnv = { ...process.env, SHELL: shell }; delete env.ZSH_NAME; @@ -1987,6 +2891,20 @@ function readLines(path: string): readonly string[] { return readFileSync(path, "utf8").trim().split("\n"); } +function fakeForwardingSshScript(): string { + return [ + "#!/bin/sh", + "printf '%s\\n' \"$@\" > \"$ROOTCELL_FAKE_SSH_ARGS\"", + "if [ \"${ROOTCELL_FAKE_SSH_FAIL:-}\" = \"1\" ]; then", + " echo fake ssh failed >&2", + " exit 255", + "fi", + "trap 'exit 0' TERM INT", + "while true; do sleep 1; done", + "", + ].join("\n"); +} + function fakeInstance(name: string, repo = "/repo", env: NodeJS.ProcessEnv = {}): RootcellInstance { const paths = instancePaths(repo, name, env); return { @@ -1994,6 +2912,7 @@ function fakeInstance(name: string, repo = "/repo", env: NodeJS.ProcessEnv = {}) dir: paths.dir, envPath: paths.envPath, secretsPath: paths.secretsPath, + extensionsPath: paths.extensionsPath, proxyDir: paths.proxyDir, pkiDir: paths.pkiDir, generatedDir: paths.generatedDir, diff --git a/src/rootcell/rootcell.ts b/src/rootcell/rootcell.ts index 9cbeb79..fb012fb 100644 --- a/src/rootcell/rootcell.ts +++ b/src/rootcell/rootcell.ts @@ -5,10 +5,13 @@ import { readFileSync, writeFileSync, } from "node:fs"; -import { createServer } from "node:net"; import { dirname, join, resolve } from "node:path"; import { parseRootcellArgs } from "./args.ts"; import { loadDotEnv, nixString, parseSecretMappings } from "./env.ts"; +import { runExtensionCommand } from "./extensions/commands.ts"; +import type { ExtensionHostCommandContext } from "./extensions/registry.ts"; +import { enabledExtensionIds, ensureExtensionsConfig } from "./extensions/config.ts"; +import { GENERATED_EXTENSION_HOOK_FILES, writeExtensionNixAggregators } from "./extensions/nix.ts"; import { DEFAULT_IMAGE_MANIFEST_URL } from "./images.ts"; import { initRootcellInstanceEnv } from "./init-env.ts"; import { @@ -17,14 +20,18 @@ import { listRootcellVmInstanceNames, loadExistingRootcellInstance, loadRootcellInstance, + readSelectedRootcellInstance, seedRootcellInstanceFiles, + SelectedInstanceStateError, + writeSelectedRootcellInstance, } from "./instance.ts"; import { runCapture, runInherited } from "./process.ts"; import { parseAwsEc2Config, parseRootcellVmProvider } from "./providers/aws-ec2-config.ts"; import { createProviderBundle } from "./providers/factory.ts"; -import type { NetworkPlan, ProviderBundle, VmNetworkAttachment, VmStatus } from "./providers/types.ts"; +import type { NetworkPlan, ProviderBundle, VmNetworkAttachment, VmRole, VmStatus } from "./providers/types.ts"; import { parseSchema } from "./schema.ts"; import { parseAwsSecretsManagerProviderConfigs } from "./secrets/aws-secrets-manager-config.ts"; +import { openRoleTargetTunnel, waitForForegroundTunnel, type PortAvailabilityCheck } from "./tunnels.ts"; import { RootcellConfigSchema, type RootcellConfig, type RootcellInstance, type SpyOptions, type VmFileSet } from "./types.ts"; const GUEST_USER = "luser"; @@ -36,7 +43,7 @@ const EDIT_PROXY_FILES = { ssh: "allowed-ssh.txt", } as const; -const EDIT_TARGETS = ["env", "http", "https", "dns", "ssh"] as const; +const EDIT_TARGETS = ["env", "http", "https", "dns", "ssh", "extensions"] as const; const VM_FILES: VmFileSet = { agent: [ @@ -47,6 +54,7 @@ const VM_FILES: VmFileSet = { "home.nix", "network.nix", "pi", + "extensions", ], firewall: [ "flake.nix", @@ -54,6 +62,7 @@ const VM_FILES: VmFileSet = { "common.nix", "firewall-vm.nix", "network.nix", + "extensions", "proxy", "src/bin/reload.ts", "dist/spy-service.js", @@ -88,20 +97,55 @@ export interface VmListEntry { readonly state: string; } +export interface VmListFormatOptions { + readonly selectedInstance?: string | undefined; + readonly env?: NodeJS.ProcessEnv; + readonly stdoutIsTty?: boolean; + readonly color?: boolean; +} + +export interface RootcellAppOptions { + readonly tunnelPortAvailable?: PortAvailabilityCheck; +} + function log(message: string): void { console.error(`rootcell: ${message}`); } -export function formatVmList(entries: readonly VmListEntry[]): string { +export function formatVmList(entries: readonly VmListEntry[], options: VmListFormatOptions = {}): string { if (entries.length === 0) { return "No rootcell VMs found.\n"; } const rows = [ ["INSTANCE", "VM", "STATE"], - ...entries.map((entry) => [entry.instance, entry.vm, entry.state]), + ...entries.map((entry) => [formatInstanceCell(entry.instance, options.selectedInstance), entry.vm, entry.state]), ]; const widths = rows[0]?.map((_, column) => Math.max(...rows.map((row) => row[column]?.length ?? 0))) ?? []; - return `${rows.map((row) => row.map((cell, column) => cell.padEnd(widths[column] ?? 0)).join(" ").trimEnd()).join("\n")}\n`; + return `${rows.map((row, index) => { + const line = row.map((cell, column) => cell.padEnd(widths[column] ?? 0)).join(" ").trimEnd(); + const entry = entries[index - 1]; + if (entry !== undefined && entry.instance === options.selectedInstance && shouldStyleSelectedRows(options)) { + return ansiBoldGreen(line); + } + return line; + }).join("\n")}\n`; +} + +function formatInstanceCell(instance: string, selectedInstance: string | undefined): string { + return instance === selectedInstance ? `${instance} (selected)` : instance; +} + +function shouldStyleSelectedRows(options: VmListFormatOptions): boolean { + if (options.color !== undefined) { + return options.color; + } + const env = options.env ?? process.env; + const stdoutIsTty = options.stdoutIsTty ?? process.stdout.isTTY; + return stdoutIsTty && env.NO_COLOR === undefined; +} + +function ansiBoldGreen(value: string): string { + return `\u001b[1;32m${value}\u001b[0m`; } function statusText(status: VmStatus): string { @@ -142,6 +186,7 @@ export function buildConfig(repoDir: string, env: NodeJS.ProcessEnv, instance: R instanceDir: instance.dir, envPath: instance.envPath, secretsPath: instance.secretsPath, + extensionsPath: instance.extensionsPath, proxyDir: instance.proxyDir, pkiDir: instance.pkiDir, generatedDir: instance.generatedDir, @@ -166,6 +211,7 @@ export class RootcellApp { constructor( private readonly config: RootcellConfig, private readonly providers: ProviderBundle, + private readonly options: RootcellAppOptions = {}, ) { this.networkPlan = this.providers.network.plan(); } @@ -187,6 +233,10 @@ export class RootcellApp { } this.writeNetworkLocalNix(); + const extensions = this.writeExtensionAggregators(); + if (subcommand === "provision") { + log(`enabled extensions: ${enabledExtensionIds(extensions).join(", ") || "none"}`); + } if (subcommand === "pubkey") { return await this.printPubkey(); @@ -327,6 +377,12 @@ export class RootcellApp { writeFileSync(join(this.config.generatedDir, "network-local.nix"), content, "utf8"); } + private writeExtensionAggregators(): ReturnType { + const extensions = ensureExtensionsConfig(this.config.extensionsPath, log); + writeExtensionNixAggregators(this.config.generatedDir, extensions); + return extensions; + } + private async printPubkey(): Promise { const status = await this.providers.vm.status(this.config.agentVm); if (status.state !== "running") { @@ -434,6 +490,16 @@ chmod 0644 "$bundle" ); } + private async copyGeneratedExtensionsIntoVm(vm: string): Promise { + for (const file of GENERATED_EXTENSION_HOOK_FILES) { + await this.copyHostFileIntoVm( + vm, + join(this.config.generatedDir, file), + join(this.config.guestRepoDir, "generated", file), + ); + } + } + private async copyAgentCaIntoVm(vm: string): Promise { await this.copyHostFileIntoVm( vm, @@ -602,15 +668,23 @@ exit 1 } const remotePort = this.spyRemotePort(); - const localPort = await chooseSpyLocalPort(SPY_DEFAULT_PORT); const launchTs = Math.floor(Date.now() / 1000); - const tunnel = await this.providers.vm.forwardLocalPort(this.config.firewallVm, { - localHost: "127.0.0.1", - localPort, - remoteHost: SPY_REMOTE_HOST, - remotePort, - }); + const tunnelOptions = this.options.tunnelPortAvailable === undefined + ? {} + : { portAvailable: this.options.tunnelPortAvailable }; + const tunnel = await openRoleTargetTunnel( + (role, forwardOptions) => this.providers.vm.forwardLocalPort(vmNameForRole(this.config, role), forwardOptions), + { + role: "firewall", + preferredLocalPort: SPY_DEFAULT_PORT, + localHost: "127.0.0.1", + remoteHost: SPY_REMOTE_HOST, + remotePort, + }, + tunnelOptions, + ); + const localPort = tunnel.localPort; const url = `http://127.0.0.1:${String(localPort)}/?since=${String(launchTs)}`; process.stdout.write(`${url}\n`); log(`rootcell spy available at ${url} (Ctrl-C closes the tunnel)`); @@ -618,11 +692,7 @@ exit 1 this.openBrowser(url); } - try { - return await this.waitForSpyTunnel(tunnel.closed); - } finally { - await tunnel.close(); - } + return await waitForForegroundTunnel(tunnel, { log }); } private async checkFirewallSpyReadiness(): Promise<"ready" | "disabled" | "missing" | "inactive" | "unhealthy"> { @@ -693,29 +763,6 @@ fi log("run ./rootcell provision, then try ./rootcell spy again."); } - private async waitForSpyTunnel(tunnelClosed: Promise): Promise { - let signalStatus: number | undefined; - const signalPromise = new Promise((resolve) => { - const onSignal = (signal: NodeJS.Signals): void => { - signalStatus = signal === "SIGINT" ? 130 : 143; - resolve(signalStatus); - }; - process.once("SIGINT", onSignal); - process.once("SIGTERM", onSignal); - void tunnelClosed.finally(() => { - process.removeListener("SIGINT", onSignal); - process.removeListener("SIGTERM", onSignal); - }); - }); - const tunnelPromise = tunnelClosed.then((status) => { - if (signalStatus === undefined) { - log("SSH tunnel closed."); - } - return status === 0 ? 0 : 1; - }); - return await Promise.race([signalPromise, tunnelPromise]); - } - private openBrowser(url: string): void { const command = process.platform === "darwin" ? "open" @@ -867,6 +914,7 @@ systemctl is-active mitmproxy-explicit >/dev/null 2>&1 \\ this.ensureCa(); await this.copyRepoIntoVm(this.config.firewallVm, VM_FILES.firewall); await this.copyGeneratedNetworkIntoVm(this.config.firewallVm); + await this.copyGeneratedExtensionsIntoVm(this.config.firewallVm); await this.bootstrapFirewallDns(); await this.runNixosSwitch("firewall", ` set -e @@ -917,6 +965,7 @@ sudo nixos-rebuild switch --flake ${shellQuote(this.guestFlakeRef(this.nixosConf await this.copyRepoIntoVm(this.config.agentVm, VM_FILES.agent); await this.copyGeneratedNetworkIntoVm(this.config.agentVm); await this.copyGeneratedGitIntoVm(this.config.agentVm); + await this.copyGeneratedExtensionsIntoVm(this.config.agentVm); await this.copyAgentCaIntoVm(this.config.agentVm); await this.bootstrapAgentFirewallRoute(); await this.bootstrapAgentFirewallTrust(); @@ -1065,6 +1114,34 @@ function appForInstance(repoDir: string, env: NodeJS.ProcessEnv, instance: Rootc return new RootcellApp(config, createProviderBundle(config, log)); } +function extensionHostCommandContext( + repoDir: string, + env: NodeJS.ProcessEnv, + instanceName: string, + extensionConfig: ExtensionHostCommandContext["extensionConfig"], +): ExtensionHostCommandContext { + const instanceEnv = envForExistingInstance(repoDir, env, instanceName); + const instance = loadExistingRootcellInstance(repoDir, instanceName, instanceEnv); + if (instance === null) { + throw new Error(`rootcell instance '${instanceName}' not found; run ./rootcell --instance ${instanceName} first.`); + } + const config = buildConfig(repoDir, instanceEnv, instance); + const providers = createProviderBundle(config, log); + return { + repoDir, + instanceName, + extensionConfig, + config, + log, + vmStatus: (role) => providers.vm.status(vmNameForRole(config, role)), + forwardLocalPort: (role, options) => providers.vm.forwardLocalPort(vmNameForRole(config, role), options), + }; +} + +function vmNameForRole(config: RootcellConfig, role: VmRole): string { + return role === "agent" ? config.agentVm : config.firewallVm; +} + function envForExistingInstance(repoDir: string, baseEnv: NodeJS.ProcessEnv, instanceName: string): NodeJS.ProcessEnv { const env = { ...baseEnv }; loadDotEnv(instancePaths(repoDir, instanceName, env).envPath, env); @@ -1076,15 +1153,16 @@ async function runListCommand( env: NodeJS.ProcessEnv, instanceName: string, explicitInstance: boolean, + selectedInstanceName: string | undefined, ): Promise { if (explicitInstance) { const instanceEnv = envForExistingInstance(repoDir, env, instanceName); const instance = loadExistingRootcellInstance(repoDir, instanceName, instanceEnv); if (instance === null) { - process.stdout.write(formatVmList(missingVmEntries(instanceName))); + process.stdout.write(formatVmList(missingVmEntries(instanceName), { selectedInstance: selectedInstanceName })); return 0; } - process.stdout.write(formatVmList(await appForInstance(repoDir, instanceEnv, instance).listVms())); + process.stdout.write(formatVmList(await appForInstance(repoDir, instanceEnv, instance).listVms(), { selectedInstance: selectedInstanceName })); return 0; } @@ -1096,7 +1174,10 @@ async function runListCommand( entries.push(...await appForInstance(repoDir, instanceEnv, instance).listVms()); } } - process.stdout.write(formatVmList(entries)); + if (selectedInstanceName !== undefined && !entries.some((entry) => entry.instance === selectedInstanceName)) { + entries.push(...missingVmEntries(selectedInstanceName)); + } + process.stdout.write(formatVmList(entries, { selectedInstance: selectedInstanceName })); return 0; } @@ -1156,6 +1237,9 @@ function editPath(paths: ReturnType, target: (typeof EDIT_ if (target === "env") { return paths.envPath; } + if (target === "extensions") { + return paths.extensionsPath; + } return join(paths.proxyDir, EDIT_PROXY_FILES[target]); } @@ -1185,20 +1269,6 @@ export function renderSpyEnv(env: NodeJS.ProcessEnv = process.env, extraEnv: rea ].join("\n"); } -export async function chooseSpyLocalPort( - preferredPort = SPY_DEFAULT_PORT, - host = "127.0.0.1", - portAvailable: (port: number, host: string) => Promise = isLocalPortAvailable, -): Promise { - for (let offset = 0; offset < 100; offset += 1) { - const candidate = preferredPort + offset; - if (await portAvailable(candidate, host)) { - return candidate; - } - } - throw new Error(`no available local port found starting at ${String(preferredPort)}`); -} - function envFileValue(value: string): string { if (/[\r\n]/.test(value)) { throw new Error("spy environment values must not contain newlines"); @@ -1224,21 +1294,6 @@ function envBoolean(value: string | undefined, fallback: boolean): boolean { return ["1", "true", "yes", "on"].includes(value.trim().toLowerCase()); } -async function isLocalPortAvailable(port: number, host: string): Promise { - return await new Promise((resolveAvailable) => { - const server = createServer(); - server.unref(); - server.once("error", () => { - resolveAvailable(false); - }); - server.listen({ host, port }, () => { - server.close(() => { - resolveAvailable(true); - }); - }); - }); -} - function missingVmEntries(instanceName: string): readonly VmListEntry[] { const vmNames = deriveVmNames(instanceName); return [ @@ -1249,6 +1304,7 @@ function missingVmEntries(instanceName: string): readonly VmListEntry[] { export async function rootcellMain(args: readonly string[], importMetaPath: string): Promise { const repoDir = repoDirFromImportMeta(importMetaPath); + const explicitInstance = hasInstanceFlag(args); let parsed; try { parsed = parseRootcellArgs(args); @@ -1262,28 +1318,74 @@ export async function rootcellMain(args: readonly string[], importMetaPath: stri } try { + if (parsed.kind === "select") { + writeSelectedRootcellInstance(repoDir, parsed.selectedInstanceName, process.env); + process.stdout.write(`selected rootcell instance '${parsed.selectedInstanceName}'\n`); + return 0; + } + if (parsed.kind === "init-env") { - initRootcellInstanceEnv(repoDir, parsed.instanceName, parsed.providerType, log, process.env); + const instanceName = resolveRootcellInstanceName(repoDir, process.env, parsed.instanceName, explicitInstance); + initRootcellInstanceEnv(repoDir, instanceName, parsed.providerType, log, process.env); return 0; } + const instanceName = resolveRootcellInstanceName(repoDir, process.env, parsed.instanceName, explicitInstance); if (parsed.subcommand === "list") { - return await runListCommand(repoDir, process.env, parsed.instanceName, hasInstanceFlag(args)); + const selectedInstanceName = explicitInstance + ? readSelectedRootcellInstanceForDisplay(repoDir, process.env) + : instanceName; + return await runListCommand(repoDir, process.env, instanceName, explicitInstance, selectedInstanceName); } if (parsed.subcommand === "stop" || parsed.subcommand === "remove") { - return await runLifecycleCommand(repoDir, process.env, parsed.subcommand, parsed.instanceName); + return await runLifecycleCommand(repoDir, process.env, parsed.subcommand, instanceName); } if (parsed.subcommand === "edit") { - return runEditCommand(repoDir, process.env, parsed.instanceName, parsed.rest[0]); + return runEditCommand(repoDir, process.env, instanceName, parsed.rest[0]); + } + if (parsed.subcommand === "extension") { + return await runExtensionCommand({ + repoDir, + env: process.env, + instanceName, + rest: parsed.rest, + log, + createContext: ({ extensionConfig }) => extensionHostCommandContext( + repoDir, + process.env, + instanceName, + extensionConfig, + ), + }); } - seedRootcellInstanceFiles(repoDir, parsed.instanceName, log); - loadDotEnv(instancePaths(repoDir, parsed.instanceName, process.env).envPath, process.env); - const instance = loadRootcellInstance(repoDir, parsed.instanceName, process.env); + seedRootcellInstanceFiles(repoDir, instanceName, log); + loadDotEnv(instancePaths(repoDir, instanceName, process.env).envPath, process.env); + const instance = loadRootcellInstance(repoDir, instanceName, process.env); const config = buildConfig(repoDir, process.env, instance); const app = new RootcellApp(config, createProviderBundle(config, log)); return await app.runAfterEnvironment(parsed.subcommand, parsed.rest, parsed.spyOptions); } catch (error) { log(messageFromUnknown(error)); - return 1; + return error instanceof SelectedInstanceStateError ? error.status : 1; + } +} + +function resolveRootcellInstanceName( + repoDir: string, + env: NodeJS.ProcessEnv, + parsedInstanceName: string, + explicitInstance: boolean, +): string { + if (explicitInstance) { + return parsedInstanceName; + } + return readSelectedRootcellInstance(repoDir, env); +} + +function readSelectedRootcellInstanceForDisplay(repoDir: string, env: NodeJS.ProcessEnv): string | undefined { + try { + return readSelectedRootcellInstance(repoDir, env); + } catch { + return undefined; } } diff --git a/src/rootcell/tunnels.test.ts b/src/rootcell/tunnels.test.ts new file mode 100644 index 0000000..7618bcc --- /dev/null +++ b/src/rootcell/tunnels.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, test } from "vitest"; +import type { LocalPortForwardOptions, VmRole } from "./providers/types.ts"; +import { + chooseLocalPort, + openRoleTargetTunnel, + waitForForegroundTunnel, + type RoleTargetTunnelSpec, +} from "./tunnels.ts"; + +type SignalName = "SIGINT" | "SIGTERM"; + +class FakeSignalEvents { + private readonly listeners = new Map void>>(); + + once(signal: SignalName, listener: () => void): void { + const listeners = this.listeners.get(signal) ?? new Set<() => void>(); + listeners.add(listener); + this.listeners.set(signal, listeners); + } + + removeListener(signal: SignalName, listener: () => void): void { + this.listeners.get(signal)?.delete(listener); + } + + emit(signal: SignalName): void { + const listeners = [...(this.listeners.get(signal) ?? [])]; + this.listeners.delete(signal); + for (const listener of listeners) { + listener(); + } + } + + count(signal: SignalName): number { + return this.listeners.get(signal)?.size ?? 0; + } +} + +describe("rootcell tunnels", () => { + test("chooses the preferred local port when it is available", async () => { + const checked: string[] = []; + + const chosen = await chooseLocalPort(19_432, "127.0.0.1", 100, (port, host) => { + checked.push(`${host}:${String(port)}`); + return Promise.resolve(true); + }); + + expect(chosen).toBe(19_432); + expect(checked).toEqual(["127.0.0.1:19432"]); + }); + + test("falls back to the next available local port", async () => { + const checked: number[] = []; + + const chosen = await chooseLocalPort(19_432, "127.0.0.1", 100, (port) => { + checked.push(port); + return Promise.resolve(port === 19_434); + }); + + expect(chosen).toBe(19_434); + expect(checked).toEqual([19_432, 19_433, 19_434]); + }); + + test("fails clearly when the scan limit is exhausted", async () => { + const checked: number[] = []; + + await expect(chooseLocalPort(19_432, "127.0.0.1", 3, (port) => { + checked.push(port); + return Promise.resolve(false); + })).rejects.toThrow("no available local port found starting at 19432"); + + expect(checked).toEqual([19_432, 19_433, 19_434]); + }); + + test.each([ + { role: "agent" as const, remoteHost: "127.0.0.1", remotePort: 19_432 }, + { role: "firewall" as const, remoteHost: "10.0.0.10", remotePort: 6174 }, + ])("opens a role-target tunnel for $role", async ({ role, remoteHost, remotePort }) => { + const calls: { role: VmRole; options: LocalPortForwardOptions }[] = []; + const spec: RoleTargetTunnelSpec = { + role, + remoteHost, + remotePort, + preferredLocalPort: 19_432, + }; + + const tunnel = await openRoleTargetTunnel( + (forwardRole, options) => { + calls.push({ role: forwardRole, options }); + return Promise.resolve({ + ...options, + closed: Promise.resolve(0), + close: () => Promise.resolve(), + }); + }, + spec, + { + scanLimit: 2, + portAvailable: (port) => Promise.resolve(port !== 19_432), + }, + ); + + expect(tunnel.role).toBe(role); + expect(tunnel.localHost).toBe("127.0.0.1"); + expect(tunnel.localPort).toBe(19_433); + expect(calls).toEqual([ + { + role, + options: { + localHost: "127.0.0.1", + localPort: 19_433, + remoteHost, + remotePort, + }, + }, + ]); + }); + + test("propagates tunnel startup failures", async () => { + await expect(openRoleTargetTunnel( + () => Promise.reject(new Error("provider forward failed")), + { + role: "agent", + localHost: "127.0.0.1", + remoteHost: "127.0.0.1", + remotePort: 19_432, + preferredLocalPort: 19_432, + }, + { + portAvailable: () => Promise.resolve(true), + }, + )).rejects.toThrow("provider forward failed"); + }); + + test("waits in the foreground, reports closed tunnels, and closes the handle", async () => { + const logs: string[] = []; + let closeCalls = 0; + + const status = await waitForForegroundTunnel({ + closed: Promise.resolve(7), + close: () => { + closeCalls += 1; + return Promise.resolve(); + }, + }, { log: (message) => logs.push(message) }); + + expect(status).toBe(1); + expect(closeCalls).toBe(1); + expect(logs).toEqual(["SSH tunnel closed."]); + }); + + test("returns signal statuses and closes the tunnel on Ctrl-C", async () => { + const signalEvents = new FakeSignalEvents(); + let closeCalls = 0; + let resolveClosed: (status: number) => void = () => undefined; + const closed = new Promise((resolve) => { + resolveClosed = resolve; + }); + + const waiting = waitForForegroundTunnel({ + closed, + close: () => { + closeCalls += 1; + resolveClosed(0); + return Promise.resolve(); + }, + }, { signalEvents }); + + expect(signalEvents.count("SIGINT")).toBe(1); + signalEvents.emit("SIGINT"); + + await expect(waiting).resolves.toBe(130); + expect(closeCalls).toBe(1); + expect(signalEvents.count("SIGINT")).toBe(0); + expect(signalEvents.count("SIGTERM")).toBe(0); + }); +}); diff --git a/src/rootcell/tunnels.ts b/src/rootcell/tunnels.ts new file mode 100644 index 0000000..6623354 --- /dev/null +++ b/src/rootcell/tunnels.ts @@ -0,0 +1,138 @@ +import { createServer } from "node:net"; +import type { LocalPortForwardHandle, LocalPortForwardOptions, VmRole } from "./providers/types.ts"; + +export const DEFAULT_TUNNEL_LOCAL_HOST = "127.0.0.1"; +export const DEFAULT_TUNNEL_PORT_SCAN_LIMIT = 100; + +export type PortAvailabilityCheck = (port: number, host: string) => Promise; + +export interface RoleTargetTunnelSpec { + readonly role: VmRole; + readonly remoteHost: string; + readonly remotePort: number; + readonly preferredLocalPort: number; + readonly localHost?: string; +} + +export interface OpenRoleTargetTunnelOptions { + readonly scanLimit?: number; + readonly portAvailable?: PortAvailabilityCheck; +} + +export type RoleTargetForwardLocalPort = ( + role: VmRole, + options: LocalPortForwardOptions, +) => Promise; + +export type RoleTargetTunnelHandle = LocalPortForwardHandle & { + readonly role: VmRole; +}; + +type SignalName = "SIGINT" | "SIGTERM"; + +interface SignalEvents { + once(signal: SignalName, listener: () => void): unknown; + removeListener(signal: SignalName, listener: () => void): unknown; +} + +export interface WaitForForegroundTunnelOptions { + readonly log?: (message: string) => void; + readonly signalEvents?: SignalEvents; +} + +export async function chooseLocalPort( + preferredPort: number, + localHost = DEFAULT_TUNNEL_LOCAL_HOST, + scanLimit = DEFAULT_TUNNEL_PORT_SCAN_LIMIT, + portAvailable: PortAvailabilityCheck = isLocalPortAvailable, +): Promise { + if (!Number.isInteger(preferredPort) || preferredPort <= 0 || preferredPort > 65_535) { + throw new Error(`invalid preferred local port: ${String(preferredPort)}`); + } + if (!Number.isInteger(scanLimit) || scanLimit <= 0) { + throw new Error(`invalid local port scan limit: ${String(scanLimit)}`); + } + + for (let offset = 0; offset < scanLimit; offset += 1) { + const candidate = preferredPort + offset; + if (candidate > 65_535) { + break; + } + if (await portAvailable(candidate, localHost)) { + return candidate; + } + } + throw new Error(`no available local port found starting at ${String(preferredPort)}`); +} + +export async function openRoleTargetTunnel( + forwardLocalPort: RoleTargetForwardLocalPort, + spec: RoleTargetTunnelSpec, + options: OpenRoleTargetTunnelOptions = {}, +): Promise { + const localHost = spec.localHost ?? DEFAULT_TUNNEL_LOCAL_HOST; + const localPort = await chooseLocalPort( + spec.preferredLocalPort, + localHost, + options.scanLimit ?? DEFAULT_TUNNEL_PORT_SCAN_LIMIT, + options.portAvailable ?? isLocalPortAvailable, + ); + const handle = await forwardLocalPort(spec.role, { + localHost, + localPort, + remoteHost: spec.remoteHost, + remotePort: spec.remotePort, + }); + return { role: spec.role, ...handle }; +} + +export async function waitForForegroundTunnel( + tunnel: Pick, + options: WaitForForegroundTunnelOptions = {}, +): Promise { + let signalStatus: number | undefined; + const signalEvents = options.signalEvents ?? process; + const signalPromise = new Promise((resolve) => { + const onSigint = (): void => { + signalStatus = 130; + resolve(signalStatus); + }; + const onSigterm = (): void => { + signalStatus = 143; + resolve(signalStatus); + }; + signalEvents.once("SIGINT", onSigint); + signalEvents.once("SIGTERM", onSigterm); + void tunnel.closed.finally(() => { + signalEvents.removeListener("SIGINT", onSigint); + signalEvents.removeListener("SIGTERM", onSigterm); + }); + }); + const tunnelPromise = tunnel.closed.then((status) => { + if (signalStatus === undefined) { + options.log?.("SSH tunnel closed."); + } + return status === 0 ? 0 : 1; + }); + + try { + return await Promise.race([signalPromise, tunnelPromise]); + } finally { + await tunnel.close(); + } +} + +async function isLocalPortAvailable(port: number, host: string): Promise { + return await new Promise((resolveAvailable) => { + const server = createServer(); + server.unref(); + server.once("error", () => { + resolveAvailable(false); + }); + server.listen({ host, port }, () => { + server.close(() => { + resolveAvailable(true); + }); + }); + }); +} diff --git a/src/rootcell/types.ts b/src/rootcell/types.ts index 4ff9bdd..ef03509 100644 --- a/src/rootcell/types.ts +++ b/src/rootcell/types.ts @@ -76,6 +76,7 @@ export const RootcellConfigSchema = z.object({ instanceDir: NonEmptyStringSchema, envPath: NonEmptyStringSchema, secretsPath: NonEmptyStringSchema, + extensionsPath: NonEmptyStringSchema, proxyDir: NonEmptyStringSchema, pkiDir: NonEmptyStringSchema, generatedDir: NonEmptyStringSchema, @@ -138,13 +139,21 @@ export const ParsedRootcellInitEnvArgsSchema = z.object({ export type ParsedRootcellInitEnvArgs = Readonly>; +export const ParsedRootcellSelectArgsSchema = z.object({ + kind: z.literal("select"), + selectedInstanceName: NonEmptyStringSchema, +}); + +export type ParsedRootcellSelectArgs = Readonly>; + export const ParsedRootcellArgsSchema = z.discriminatedUnion("kind", [ ParsedRootcellRunArgsSchema, ParsedRootcellHandledArgsSchema, ParsedRootcellInitEnvArgsSchema, + ParsedRootcellSelectArgsSchema, ]); -export type ParsedRootcellArgs = ParsedRootcellRunArgs | ParsedRootcellHandledArgs | ParsedRootcellInitEnvArgs; +export type ParsedRootcellArgs = ParsedRootcellRunArgs | ParsedRootcellHandledArgs | ParsedRootcellInitEnvArgs | ParsedRootcellSelectArgs; export const InstanceStateSchema = z.object({ schemaVersion: z.literal(1), @@ -161,6 +170,7 @@ export const RootcellInstanceSchema = z.object({ dir: NonEmptyStringSchema, envPath: NonEmptyStringSchema, secretsPath: NonEmptyStringSchema, + extensionsPath: NonEmptyStringSchema, proxyDir: NonEmptyStringSchema, pkiDir: NonEmptyStringSchema, generatedDir: NonEmptyStringSchema,