From 84542d8bf7030503e3da5e8cca850423ee2852b0 Mon Sep 17 00:00:00 2001 From: Jim Pudar Date: Mon, 25 May 2026 07:07:18 -0400 Subject: [PATCH 01/10] Add rootcell extension core model --- EXTENSIONS_PLAN.md | 260 ++++++++++++++++++ agent-vm.nix | 5 +- extensions/subagent/home-manager.nix | 23 ++ firewall-vm.nix | 5 +- home.nix | 70 +---- pi/pi-coding-agent.nix | 38 +++ src/rootcell/args.ts | 66 ++++- src/rootcell/extensions/commands.ts | 102 +++++++ src/rootcell/extensions/config.ts | 231 ++++++++++++++++ src/rootcell/extensions/nix.ts | 54 ++++ src/rootcell/extensions/registry.ts | 82 ++++++ src/rootcell/instance.ts | 7 + .../providers/macos-lima-user-v2/provider.ts | 1 + src/rootcell/metadata.ts | 3 +- src/rootcell/rootcell.test.ts | 257 ++++++++++++++++- src/rootcell/rootcell.ts | 42 ++- src/rootcell/types.ts | 2 + 17 files changed, 1170 insertions(+), 78 deletions(-) create mode 100644 EXTENSIONS_PLAN.md create mode 100644 extensions/subagent/home-manager.nix create mode 100644 pi/pi-coding-agent.nix create mode 100644 src/rootcell/extensions/commands.ts create mode 100644 src/rootcell/extensions/config.ts create mode 100644 src/rootcell/extensions/nix.ts create mode 100644 src/rootcell/extensions/registry.ts diff --git a/EXTENSIONS_PLAN.md b/EXTENSIONS_PLAN.md new file mode 100644 index 0000000..eca4595 --- /dev/null +++ b/EXTENSIONS_PLAN.md @@ -0,0 +1,260 @@ +# Rootcell Extensions Implementation Plan + +Status: Phase 1 core extension framework is mostly implemented; extension host-command dispatch remains open. + +## 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. + +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 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. +- `subagent` opt-in preserves current behavior: Pi subagent extension plus example agents. +- Existing VMs keep old subagent files until explicit provision; after provisioning with `subagent=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. `plannotator=false` and `subagent=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 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. `plannotator=false` and `subagent=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. +- `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 `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 plannotator tunnel` in one terminal and start Pi normally in another. +- `rootcell extension 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 `subagent` Rootcell extension's Home Manager hook. +- Enabling `subagent` 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 `subagent=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/subagent/home-manager.nix` — Home Manager hook module preserving current subagent behavior behind opt-in. +- `extensions/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 `subagent` 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 `subagent=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. + +- [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`. +- [ ] Include `requiresProvision` in extension metadata so the CLI can print accurate next steps. Open: metadata exists, but enable/disable guidance is still unconditional instead of metadata-driven. +- [ ] Include a minimal extension host command registry interface even in the first implementation: an extension can define commands with `name`, `description`, `complete`, and `run`. Open: management commands are implemented, but extension-owned host commands are not yet registered through the extension definitions. +- [ ] 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. Open: blocked on the host command registry interface. +- [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/subagent/home-manager.nix` and `extensions/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 `subagent` is no longer unconditional and is provided by its extension's Home Manager hook module. +- [X] The `subagent` 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`). +- [ ] 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 command dispatch/tunnel setup behavior. Open: framework tests exist, but command-dispatch/tunnel setup tests are still open with the host command registry. +- [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 + +- Reuse the generic `VmProvider.forwardLocalPort` / SSH local-forwarding components from the spy browser branch, assuming they are likely merged before implementation. +- 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. +- Add tests for SSH config/forward command construction and tunnel lifecycle. + +### Phase 3: Plannotator extension package/install + +- Package Plannotator through Nix/Home Manager, not runtime `pi install` inside the agent VM. +- 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. +- 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. +- 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. +- Install/configure Plannotator using Pi's normal package model and package identity (`@plannotator/pi-extension`) rather than renaming it to the Rootcell extension id. +- Let Nix control the pinned package content, while Pi loads it through normal package mechanisms. +- Do not have Home Manager own/clobber `~/.pi/agent/settings.json`, because that is a user-editable Pi settings file. +- 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. +- 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`. +- 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. +- 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. +- 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 + +- 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). +- Require `plannotator=true` before `rootcell extension 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. +- 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. +- 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. +- 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. +- Print the host URL; do not automatically open a browser and do not add `--open` in the first iteration. +- 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. +- 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`. +- Provide clear readiness/error messages. + +### Phase 5: Documentation and migration + +- Document the extension concept, commands, and Plannotator workflow. +- Document the subagent migration clearly: existing VMs keep current files until explicit provision, but after provisioning with `subagent=false`, Home Manager removes the previously managed subagent extension/example agents. Users who rely on it must run `./rootcell extension enable subagent && ./rootcell provision`. +- Add README examples. 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/subagent/home-manager.nix b/extensions/subagent/home-manager.nix new file mode 100644 index 0000000..a7714de --- /dev/null +++ b/extensions/subagent/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..aac3401 100644 --- a/src/rootcell/args.ts +++ b/src/rootcell/args.ts @@ -1,5 +1,6 @@ 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 { parseSchema } from "./schema.ts"; @@ -34,6 +35,10 @@ interface EditArgs extends GlobalArgs { readonly target?: string; } +interface ExtensionArgs extends GlobalArgs { + readonly extensionArgs?: readonly string[]; +} + type ParserArgv = Argv; function subcommandDescription(name: RootcellSubcommand): string { @@ -97,21 +102,48 @@ 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 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 rawWords = argv._.map((value) => String(value)); + const words = rawWords[0] === "rootcell" ? rawWords.slice(1) : rawWords; + const instance = lastString(argv.instance) ?? "default"; + try { + return completeExtensionCommand({ + repoDir: process.cwd(), + env: process.env, + instanceName: instance, + words, + current, + }); + } catch { + return []; + } +} + +function createParser(args: readonly string[]): Argv { return yargs([...args]) .scriptName("rootcell") .exitProcess(false) @@ -152,13 +184,25 @@ function createParser(args: readonly string[]): Argv { subcommandDescription("edit"), (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"), @@ -225,7 +269,11 @@ export function parseRootcellArgs(args: readonly string[]): ParsedRootcellArgs { } 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), @@ -258,7 +306,7 @@ 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; } diff --git a/src/rootcell/extensions/commands.ts b/src/rootcell/extensions/commands.ts new file mode 100644 index 0000000..05707bc --- /dev/null +++ b/src/rootcell/extensions/commands.ts @@ -0,0 +1,102 @@ +import { instancePaths, seedRootcellInstanceFiles } from "../instance.ts"; +import { + disabledExtensionIds, + ensureExtensionsConfig, + formatExtensionsList, + parseExtensionsConfig, + readExtensionsConfig, + setExtensionEnabled, +} from "./config.ts"; +import { + ROOTCELL_EXTENSION_IDS, + isRootcellExtensionId, +} from "./registry.ts"; + +export function runExtensionCommand(input: { + readonly repoDir: string; + readonly env: NodeJS.ProcessEnv; + readonly instanceName: string; + readonly rest: readonly string[]; + readonly log: (message: string) => void; +}): number { + const [command, id, ...extra] = input.rest; + if (command === undefined) { + input.log("usage: rootcell extension list | enable | disable "); + return 2; + } + + seedRootcellInstanceFiles(input.repoDir, input.instanceName, input.log, input.env); + const path = instancePaths(input.repoDir, input.instanceName, input.env).extensionsPath; + + if (command === "list") { + if (id !== undefined) { + input.log("usage: rootcell extension list"); + return 2; + } + process.stdout.write(formatExtensionsList(ensureExtensionsConfig(path, input.log))); + return 0; + } + + if (command !== "enable" && command !== "disable") { + input.log(`unknown extension command '${command}' (expected list, enable, disable)`); + return 2; + } + if (id === undefined || extra.length > 0) { + input.log(`usage: rootcell extension ${command} `); + return 2; + } + if (!isRootcellExtensionId(id)) { + input.log(`unknown extension id '${id}' (known: ${ROOTCELL_EXTENSION_IDS.join(", ")})`); + return 2; + } + + const enabled = command === "enable"; + const result = setExtensionEnabled(path, id, enabled); + const state = enabled ? "enabled" : "disabled"; + const already = result.changed ? "" : " already"; + process.stdout.write(`${id}${already} ${state} for instance '${input.instanceName}'.\n`); + process.stdout.write(`run ./rootcell --instance ${input.instanceName} provision to apply VM changes.\n`); + return 0; +} + +export function completeExtensionCommand(input: { + readonly repoDir: string; + readonly env: NodeJS.ProcessEnv; + readonly instanceName: string; + readonly words: readonly string[]; + readonly current: string; +}): readonly string[] | undefined { + 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) { + return startsWith(["list", "enable", "disable"], 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); + } + return []; +} + +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..75c0691 --- /dev/null +++ b/src/rootcell/extensions/config.ts @@ -0,0 +1,231 @@ +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"; + +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}`); + } + if (seen.has(key)) { + throw new Error(`duplicate extension key in extensions.txt on line ${String(index + 1)}: ${key}`); + } + seen.add(key); + + const valueEnabled = parseExtensionBoolean(value, key, index + 1); + const known = isRootcellExtensionId(key); + if (known && valueEnabled) { + enabled.add(key); + } + 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; + } + present.add(line.key); + if (isRootcellExtensionId(line.key) && overrides.has(line.key)) { + lines.push(`${line.key}=${overrides.get(line.key) === true ? "true" : "false"}`); + continue; + } + 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 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/registry.ts b/src/rootcell/extensions/registry.ts new file mode 100644 index 0000000..469f0b6 --- /dev/null +++ b/src/rootcell/extensions/registry.ts @@ -0,0 +1,82 @@ +import { z } from "zod"; +import { NonEmptyStringSchema, parseSchema } from "../schema.ts"; + +export const RootcellExtensionIdSchema = z.enum(["plannotator", "subagent"]); + +export type RootcellExtensionId = z.infer; + +export const ExtensionGuestHookSchema = z.enum(["agentNixos", "firewallNixos", "homeManager"]); + +export type ExtensionGuestHook = z.infer; + +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, +}).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; + } +>; + +const RootcellExtensionDefinitionsSchema = z.array(RootcellExtensionDefinitionSchema); + +const NO_GUEST_HOOKS: RootcellExtensionGuestHooks = parseSchema(RootcellExtensionGuestHooksSchema, { + agentNixos: [], + firewallNixos: [], + homeManager: [], +}, "invalid empty rootcell extension guest hooks"); + +export const ROOTCELL_EXTENSIONS: readonly RootcellExtensionDefinition[] = parseSchema(RootcellExtensionDefinitionsSchema, [ + { + id: "plannotator", + description: "Pi Plannotator integration metadata placeholder", + requiresProvision: true, + guestHooks: NO_GUEST_HOOKS, + }, + { + id: "subagent", + description: "Pi subagent extension and bundled example agents", + requiresProvision: true, + guestHooks: { + agentNixos: [], + firewallNixos: [], + homeManager: ["extensions/subagent/home-manager.nix"], + }, + }, +] 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..5e7159e 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, @@ -29,6 +30,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; @@ -75,6 +77,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); @@ -141,6 +146,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 +166,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"), 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..701291a 100644 --- a/src/rootcell/metadata.ts +++ b/src/rootcell/metadata.ts @@ -1,5 +1,5 @@ export interface SubcommandMetadata { - readonly name: "provision" | "allow" | "pubkey" | "spy" | "list" | "stop" | "remove" | "edit"; + readonly name: "provision" | "allow" | "pubkey" | "spy" | "list" | "stop" | "remove" | "edit" | "extension"; readonly description: string; } @@ -8,6 +8,7 @@ export const ROOTCELL_SUBCOMMANDS: readonly SubcommandMetadata[] = [ { 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..2aa2b27 100644 --- a/src/rootcell/rootcell.test.ts +++ b/src/rootcell/rootcell.test.ts @@ -1,6 +1,18 @@ import { describe, expect, test } from "vitest"; import { z } from "zod"; import { parseRootcellArgs } from "./args.ts"; +import { + ensureExtensionsConfig, + formatExtensionsList, + parseExtensionsConfig, + renderExtensionsConfig, + setExtensionEnabled, +} from "./extensions/config.ts"; +import { + GENERATED_EXTENSION_HOOK_FILES, + renderExtensionNixAggregator, + writeExtensionNixAggregators, +} from "./extensions/nix.ts"; import { ROOTCELL_SUBCOMMANDS } from "./metadata.ts"; import { loadDotEnv, parseSecretMappings } from "./env.ts"; import { resolveHostTool } from "./host-tools.ts"; @@ -42,7 +54,7 @@ import { } from "./images.ts"; import { forgetKnownHost, 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, writeFileSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { @@ -160,10 +172,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", "subagent"]); + expectRunArgs(enable); + expect(enable).toEqual({ + kind: "run", + instanceName: "dev", + subcommand: "extension", + rest: ["enable", "subagent"], + 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); @@ -441,6 +495,103 @@ describe("environment parsing", () => { }); }); +describe("rootcell extension config", () => { + test("parses extension booleans and unknown valid keys", () => { + const config = parseExtensionsConfig([ + "# local extension choices", + "subagent=yes", + "plannotator", + "future-extension=off", + "", + ].join("\n")); + + expect(config.enabled.has("subagent")).toBe(true); + expect(config.enabled.has("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("subagent=maybe\n")).toThrow("invalid boolean value"); + expect(() => parseExtensionsConfig("subagent=true\nsubagent=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", + "", + "subagent=off", + "", + ].join("\n")), new Map([["subagent", true]])); + + expect(rendered).toBe([ + "# keep this", + "future-extension=true", + "", + "subagent=true", + "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("plannotator disabled"); + expect(readFileSync(path, "utf8")).toBe("plannotator=false\nsubagent=false\n"); + + const enabled = setExtensionEnabled(path, "subagent", true); + expect(enabled.changed).toBe(true); + expect(readFileSync(path, "utf8")).toBe("plannotator=false\nsubagent=true\n"); + + const enabledAgain = setExtensionEnabled(path, "subagent", true); + expect(enabledAgain.changed).toBe(false); + expect(readFileSync(path, "utf8")).toBe("plannotator=false\nsubagent=true\n"); + } finally { + rmSync(repo, { recursive: true, force: true }); + } + }); +}); + +describe("rootcell extension Nix hooks", () => { + test("renders empty aggregators when no extensions are enabled", () => { + expect(renderExtensionNixAggregator(parseExtensionsConfig("subagent=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("subagent=true\n"), "homeManager"); + expect(rendered).toContain("../extensions/subagent/home-manager.nix"); + expect(renderExtensionNixAggregator(parseExtensionsConfig("subagent=true\n"), "agentNixos")).not.toContain("../extensions/subagent"); + }); + + test("writes the explicit generated hook files", () => { + const repo = makeInstanceRepo(); + try { + const generatedDir = join(repo, "generated"); + mkdirSync(generatedDir, { recursive: true }); + writeExtensionNixAggregators(generatedDir, parseExtensionsConfig("subagent=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/subagent/home-manager.nix"); + } finally { + rmSync(repo, { recursive: true, force: true }); + } + }); +}); + describe("host tool resolution", () => { const limaSpec = { name: "limactl", @@ -1072,6 +1223,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 +1251,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", () => { @@ -1871,6 +2030,70 @@ 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("plannotator=false\nsubagent=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("plannotator disabled"); + expect(list.stdout).toContain("subagent disabled"); + expect(readFileSync(join(repo, ".state", "dev", "extensions.txt"), "utf8")).toBe("plannotator=false\nsubagent=false\n"); + + const enable = runCapture("./rootcell", ["--instance", "dev", "extension", "enable", "subagent"], { env }); + expect(enable.stdout).toContain("subagent 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("plannotator=false\nsubagent=true\n"); + + const enableAgain = runCapture("./rootcell", ["--instance", "dev", "extension", "enable", "subagent"], { env }); + expect(enableAgain.stdout).toContain("subagent already enabled for instance 'dev'."); + + const disable = runCapture("./rootcell", ["--instance", "dev", "extension", "disable", "subagent"], { env }); + expect(disable.stdout).toContain("subagent disabled for instance 'dev'."); + expect(readFileSync(join(repo, ".state", "dev", "extensions.txt"), "utf8")).toBe("plannotator=false\nsubagent=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 }); + } + }); +}); + +describe("rootcell edit env command", () => { test("opens the selected instance environment in EDITOR", async () => { const repo = makeInstanceRepo(); const oldRootcellStateDir = process.env.ROOTCELL_STATE_DIR; @@ -1935,6 +2158,37 @@ 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"), "plannotator=false\nsubagent=true\n", "utf8"); + const env = completionEnv("/bin/bash"); + env.ROOTCELL_STATE_DIR = stateDir; + + 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 enable = runCapture("./rootcell", ["--get-yargs-completions", "rootcell", "--instance", "dev", "extension", "enable", ""], { env }).stdout; + expect(enable).toContain("plannotator\n"); + expect(enable).not.toContain("subagent\n"); + + const disable = runCapture("./rootcell", ["--get-yargs-completions", "rootcell", "--instance", "dev", "extension", "disable", ""], { env }).stdout; + expect(disable).toContain("subagent\n"); + expect(disable).not.toContain("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); + } finally { + rmSync(repo, { recursive: true, force: true }); + } + }); }); function runArgs(args: readonly string[]): ParsedRootcellRunArgs { @@ -1994,6 +2248,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..5a0e93f 100644 --- a/src/rootcell/rootcell.ts +++ b/src/rootcell/rootcell.ts @@ -9,6 +9,9 @@ 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 { 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 { @@ -36,7 +39,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 +50,7 @@ const VM_FILES: VmFileSet = { "home.nix", "network.nix", "pi", + "extensions", ], firewall: [ "flake.nix", @@ -54,6 +58,7 @@ const VM_FILES: VmFileSet = { "common.nix", "firewall-vm.nix", "network.nix", + "extensions", "proxy", "src/bin/reload.ts", "dist/spy-service.js", @@ -142,6 +147,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, @@ -187,6 +193,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 +337,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 +450,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, @@ -867,6 +893,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 +944,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(); @@ -1156,6 +1184,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]); } @@ -1275,6 +1306,15 @@ export async function rootcellMain(args: readonly string[], importMetaPath: stri if (parsed.subcommand === "edit") { return runEditCommand(repoDir, process.env, parsed.instanceName, parsed.rest[0]); } + if (parsed.subcommand === "extension") { + return runExtensionCommand({ + repoDir, + env: process.env, + instanceName: parsed.instanceName, + rest: parsed.rest, + log, + }); + } seedRootcellInstanceFiles(repoDir, parsed.instanceName, log); loadDotEnv(instancePaths(repoDir, parsed.instanceName, process.env).envPath, process.env); diff --git a/src/rootcell/types.ts b/src/rootcell/types.ts index 4ff9bdd..db02a5a 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, @@ -161,6 +162,7 @@ export const RootcellInstanceSchema = z.object({ dir: NonEmptyStringSchema, envPath: NonEmptyStringSchema, secretsPath: NonEmptyStringSchema, + extensionsPath: NonEmptyStringSchema, proxyDir: NonEmptyStringSchema, pkiDir: NonEmptyStringSchema, generatedDir: NonEmptyStringSchema, From a74925495f5f5e2cac170345c518f2c7721c2ec5 Mon Sep 17 00:00:00 2001 From: Jim Pudar Date: Mon, 25 May 2026 07:20:26 -0400 Subject: [PATCH 02/10] Add extension host command registry --- EXTENSIONS_PLAN.md | 13 +- src/rootcell/extensions/commands.ts | 185 ++++++++++++++++--- src/rootcell/extensions/registry.ts | 66 ++++++- src/rootcell/rootcell.test.ts | 265 +++++++++++++++++++++++++++- src/rootcell/rootcell.ts | 39 +++- 5 files changed, 530 insertions(+), 38 deletions(-) diff --git a/EXTENSIONS_PLAN.md b/EXTENSIONS_PLAN.md index eca4595..ffa0ba0 100644 --- a/EXTENSIONS_PLAN.md +++ b/EXTENSIONS_PLAN.md @@ -1,6 +1,6 @@ # Rootcell Extensions Implementation Plan -Status: Phase 1 core extension framework is mostly implemented; extension host-command dispatch remains open. +Status: Phase 1 core extension framework is implemented through extension-owned host-command dispatch; tunnel-specific work remains in later phases. ## Goal @@ -188,11 +188,13 @@ Initial target extensions: 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 `subagent=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 `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`. -- [ ] Include `requiresProvision` in extension metadata so the CLI can print accurate next steps. Open: metadata exists, but enable/disable guidance is still unconditional instead of metadata-driven. -- [ ] Include a minimal extension host command registry interface even in the first implementation: an extension can define commands with `name`, `description`, `complete`, and `run`. Open: management commands are implemented, but extension-owned host commands are not yet registered through the extension definitions. -- [ ] 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. Open: blocked on the host command registry interface. +- [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/subagent/home-manager.nix` and `extensions/plannotator/home-manager.nix`. - [X] Store TypeScript registry/host command implementation under `src/rootcell/extensions/`. @@ -213,7 +215,8 @@ Implementation update: the first Phase 1 slice added a Zod-validated built-in ex - [X] During `provision`, log the enabled extension set concisely (including `none`). - [X] Change `home.nix` so `subagent` is no longer unconditional and is provided by its extension's Home Manager hook module. - [X] The `subagent` 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`). -- [ ] 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 command dispatch/tunnel setup behavior. Open: framework tests exist, but command-dispatch/tunnel setup tests are still open with the host command registry. +- [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. +- [ ] Add tunnel setup tests with the tunnel implementation. Open: tunnel setup behavior depends on the Phase 2 tunnel primitive and Phase 4 Plannotator host command. - [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`. diff --git a/src/rootcell/extensions/commands.ts b/src/rootcell/extensions/commands.ts index 05707bc..c1a43e2 100644 --- a/src/rootcell/extensions/commands.ts +++ b/src/rootcell/extensions/commands.ts @@ -1,62 +1,85 @@ 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"; -export function runExtensionCommand(input: { +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; -}): number { - const [command, id, ...extra] = input.rest; - if (command === undefined) { - input.log("usage: rootcell extension list | enable | disable "); + 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; } - seedRootcellInstanceFiles(input.repoDir, input.instanceName, input.log, input.env); - const path = instancePaths(input.repoDir, input.instanceName, input.env).extensionsPath; - - if (command === "list") { - if (id !== undefined) { + 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 (command !== "enable" && command !== "disable") { - input.log(`unknown extension command '${command}' (expected list, enable, disable)`); - return 2; - } - if (id === undefined || extra.length > 0) { - input.log(`usage: rootcell extension ${command} `); - return 2; - } - if (!isRootcellExtensionId(id)) { - input.log(`unknown extension id '${id}' (known: ${ROOTCELL_EXTENSION_IDS.join(", ")})`); - return 2; + 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; } - const enabled = command === "enable"; - const result = setExtensionEnabled(path, id, enabled); - const state = enabled ? "enabled" : "disabled"; - const already = result.changed ? "" : " already"; - process.stdout.write(`${id}${already} ${state} for instance '${input.instanceName}'.\n`); - process.stdout.write(`run ./rootcell --instance ${input.instanceName} provision to apply VM changes.\n`); - return 0; + return await runOperationalExtensionCommand({ ...input, extensions, commandOrId, idOrCommand, extra }); } export function completeExtensionCommand(input: { @@ -65,7 +88,9 @@ export function completeExtensionCommand(input: { 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; @@ -73,7 +98,11 @@ export function completeExtensionCommand(input: { const after = input.words.slice(extensionAt + 1); const first = after[0]; if (after.length <= 1) { - return startsWith(["list", "enable", "disable"], input.current); + 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); @@ -82,9 +111,107 @@ export function completeExtensionCommand(input: { : 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, diff --git a/src/rootcell/extensions/registry.ts b/src/rootcell/extensions/registry.ts index 469f0b6..b580ce6 100644 --- a/src/rootcell/extensions/registry.ts +++ b/src/rootcell/extensions/registry.ts @@ -1,5 +1,8 @@ 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"; export const RootcellExtensionIdSchema = z.enum(["plannotator", "subagent"]); @@ -9,6 +12,57 @@ export const ExtensionGuestHookSchema = z.enum(["agentNixos", "firewallNixos", " 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"); @@ -24,6 +78,7 @@ export const RootcellExtensionDefinitionSchema = z.object({ description: NonEmptyStringSchema, requiresProvision: z.boolean(), guestHooks: RootcellExtensionGuestHooksSchema, + hostCommands: RootcellExtensionHostCommandsSchema, }).strict(); type RootcellExtensionGuestHooksOutput = z.infer; @@ -35,8 +90,9 @@ type RootcellExtensionGuestHooks = Readonly<{ }>; export type RootcellExtensionDefinition = Readonly< - Omit & { + Omit & { readonly guestHooks: RootcellExtensionGuestHooks; + readonly hostCommands: readonly RootcellExtensionHostCommand[]; } >; @@ -48,12 +104,19 @@ const NO_GUEST_HOOKS: RootcellExtensionGuestHooks = parseSchema(RootcellExtensio homeManager: [], }, "invalid empty rootcell extension guest hooks"); +const NO_HOST_COMMANDS: readonly RootcellExtensionHostCommand[] = parseSchema( + RootcellExtensionHostCommandsSchema, + [], + "invalid empty rootcell extension host commands", +); + export const ROOTCELL_EXTENSIONS: readonly RootcellExtensionDefinition[] = parseSchema(RootcellExtensionDefinitionsSchema, [ { id: "plannotator", description: "Pi Plannotator integration metadata placeholder", requiresProvision: true, guestHooks: NO_GUEST_HOOKS, + hostCommands: NO_HOST_COMMANDS, }, { id: "subagent", @@ -64,6 +127,7 @@ export const ROOTCELL_EXTENSIONS: readonly RootcellExtensionDefinition[] = parse firewallNixos: [], homeManager: ["extensions/subagent/home-manager.nix"], }, + hostCommands: NO_HOST_COMMANDS, }, ] as const, "invalid built-in rootcell extension definitions"); diff --git a/src/rootcell/rootcell.test.ts b/src/rootcell/rootcell.test.ts index 2aa2b27..6dc0521 100644 --- a/src/rootcell/rootcell.test.ts +++ b/src/rootcell/rootcell.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "vitest"; import { z } from "zod"; import { parseRootcellArgs } from "./args.ts"; +import { completeExtensionCommand, runExtensionCommand } from "./extensions/commands.ts"; import { ensureExtensionsConfig, formatExtensionsList, @@ -13,6 +14,10 @@ import { renderExtensionNixAggregator, writeExtensionNixAggregators, } from "./extensions/nix.ts"; +import { + 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"; @@ -29,7 +34,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, @@ -85,6 +90,35 @@ function expectRunArgs(value: ParsedRootcellRunArgs): void { expect(value).toEqual(expect.schemaMatching(ParsedRootcellRunArgsSchema)); } +describe("rootcell extension registry", () => { + test("validates host command definitions", () => { + const valid = { + id: "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); + }); +}); + describe("rootcell argument parsing", () => { test("parses known subcommands", () => { const parsed = runArgs(["provision"]); @@ -2091,6 +2125,132 @@ describe("rootcell extension command", () => { 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"), "plannotator=true\nsubagent=false\n", "utf8"); + const calls: string[] = []; + const extensions = testHostCommandExtensions(async (context, args) => { + calls.push(`run:${context.instanceName}:${args.join(",")}:${context.extensionConfig.enabled.has("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: ["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: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("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: ["plannotator", "check"] })).toBe(1); + expect(logs.join("\n")).toContain("extension '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"), "plannotator=true\nsubagent=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: ["plannotator"] })).toBe(2); + expect(logs.join("\n")).toContain("usage: rootcell extension plannotator "); + + logs.length = 0; + expect(await runExtensionCommand({ ...input, rest: ["plannotator", "missing"] })).toBe(2); + expect(logs.join("\n")).toContain("unknown command for extension 'plannotator': 'missing'"); + expect(contexts).toBe(0); + } finally { + rmSync(repo, { recursive: true, force: true }); + } + }); }); describe("rootcell edit env command", () => { @@ -2189,6 +2349,73 @@ describe("shell completions", () => { 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"), "plannotator=true\nsubagent=false\n", "utf8"); + + const root = completeExtensionCommand({ + repoDir: repo, + env, + instanceName: "dev", + words: ["extension", ""], + current: "", + extensions, + }); + expect(root).toContain("list"); + expect(root).toContain("plannotator"); + + const commands = completeExtensionCommand({ + repoDir: repo, + env, + instanceName: "dev", + words: ["extension", "plannotator", ""], + current: "", + extensions, + }); + expect(commands).toEqual(["check"]); + + const commandArgs = completeExtensionCommand({ + repoDir: repo, + env, + instanceName: "dev", + words: ["extension", "plannotator", "check", ""], + current: "", + extensions, + }); + expect(commandArgs).toEqual(["alpha"]); + + writeFileSync(join(instanceDir, "extensions.txt"), "plannotator=false\nsubagent=false\n", "utf8"); + const disabledRoot = completeExtensionCommand({ + repoDir: repo, + env, + instanceName: "dev", + words: ["extension", ""], + current: "", + extensions, + }); + expect(disabledRoot).not.toContain("plannotator"); + + const missing = completeExtensionCommand({ + repoDir: repo, + env, + instanceName: "new", + words: ["extension", ""], + current: "", + extensions, + }); + expect(missing).not.toContain("plannotator"); + expect(existsSync(join(stateDir, "new", "extensions.txt"))).toBe(false); + } finally { + rmSync(repo, { recursive: true, force: true }); + } + }); }); function runArgs(args: readonly string[]): ParsedRootcellRunArgs { @@ -2203,6 +2430,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: "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: "subagent", + 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; diff --git a/src/rootcell/rootcell.ts b/src/rootcell/rootcell.ts index 5a0e93f..c650cfb 100644 --- a/src/rootcell/rootcell.ts +++ b/src/rootcell/rootcell.ts @@ -10,6 +10,7 @@ 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"; @@ -25,7 +26,7 @@ import { 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 { RootcellConfigSchema, type RootcellConfig, type RootcellInstance, type SpyOptions, type VmFileSet } from "./types.ts"; @@ -1093,6 +1094,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); @@ -1307,12 +1336,18 @@ export async function rootcellMain(args: readonly string[], importMetaPath: stri return runEditCommand(repoDir, process.env, parsed.instanceName, parsed.rest[0]); } if (parsed.subcommand === "extension") { - return runExtensionCommand({ + return await runExtensionCommand({ repoDir, env: process.env, instanceName: parsed.instanceName, rest: parsed.rest, log, + createContext: ({ extensionConfig }) => extensionHostCommandContext( + repoDir, + process.env, + parsed.instanceName, + extensionConfig, + ), }); } From 974736464bb27fafff0774d89dbd7ae20b319756 Mon Sep 17 00:00:00 2001 From: Jim Pudar Date: Mon, 25 May 2026 07:22:14 -0400 Subject: [PATCH 03/10] Move tunnel tests to later extension phases --- EXTENSIONS_PLAN.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/EXTENSIONS_PLAN.md b/EXTENSIONS_PLAN.md index ffa0ba0..63d937e 100644 --- a/EXTENSIONS_PLAN.md +++ b/EXTENSIONS_PLAN.md @@ -216,7 +216,6 @@ Implementation update: the host-command registry slice added `RootcellExtensionD - [X] Change `home.nix` so `subagent` is no longer unconditional and is provided by its extension's Home Manager hook module. - [X] The `subagent` 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. -- [ ] Add tunnel setup tests with the tunnel implementation. Open: tunnel setup behavior depends on the Phase 2 tunnel primitive and Phase 4 Plannotator host command. - [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`. @@ -227,7 +226,7 @@ Implementation update: the host-command registry slice added `RootcellExtensionD - Reuse the generic `VmProvider.forwardLocalPort` / SSH local-forwarding components from the spy browser branch, assuming they are likely merged before implementation. - 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. -- Add tests for SSH config/forward command construction and tunnel lifecycle. +- 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 @@ -255,6 +254,7 @@ Implementation update: the host-command registry slice added `RootcellExtensionD - 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. - 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`. - Provide clear readiness/error messages. +- 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 From 3d78f94e8f9a921d2a4a87e6ed8e3e75cb926c05 Mon Sep 17 00:00:00 2001 From: Jim Pudar Date: Mon, 25 May 2026 07:42:26 -0400 Subject: [PATCH 04/10] Add shared tunnel primitive --- EXTENSIONS_PLAN.md | 10 +- src/rootcell/rootcell.test.ts | 212 +++++++++++++++++++++++++++++++++- src/rootcell/rootcell.ts | 87 ++++---------- src/rootcell/tunnels.test.ts | 176 ++++++++++++++++++++++++++++ src/rootcell/tunnels.ts | 138 ++++++++++++++++++++++ 5 files changed, 549 insertions(+), 74 deletions(-) create mode 100644 src/rootcell/tunnels.test.ts create mode 100644 src/rootcell/tunnels.ts diff --git a/EXTENSIONS_PLAN.md b/EXTENSIONS_PLAN.md index 63d937e..e3ffbb2 100644 --- a/EXTENSIONS_PLAN.md +++ b/EXTENSIONS_PLAN.md @@ -1,6 +1,6 @@ # Rootcell Extensions Implementation Plan -Status: Phase 1 core extension framework is implemented through extension-owned host-command dispatch; tunnel-specific work remains in later phases. +Status: Phase 2 host tunnel primitive is implemented; Plannotator package/install and the Plannotator extension command remain in later phases. ## Goal @@ -224,9 +224,11 @@ Implementation update: the host-command registry slice added `RootcellExtensionD ### Phase 2: Host tunnel primitive -- Reuse the generic `VmProvider.forwardLocalPort` / SSH local-forwarding components from the spy browser branch, assuming they are likely merged before implementation. -- 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. -- 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. +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 diff --git a/src/rootcell/rootcell.test.ts b/src/rootcell/rootcell.test.ts index 6dc0521..064978a 100644 --- a/src/rootcell/rootcell.test.ts +++ b/src/rootcell/rootcell.test.ts @@ -1,4 +1,4 @@ -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"; @@ -22,7 +22,7 @@ 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 { buildConfig, formatVmList, renderSpyEnv, rootcellMain, RootcellApp } from "./rootcell.ts"; import { deriveVmNames, instancePaths, listRootcellVmInstanceNames, loadRootcellInstance, seedRootcellInstanceFiles } from "./instance.ts"; import { runCapture } from "./process.ts"; import { parseAwsEc2Config } from "./providers/aws-ec2-config.ts"; @@ -57,7 +57,8 @@ 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, existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; @@ -357,8 +358,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); }); @@ -1052,6 +1053,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(); @@ -1644,6 +1750,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", @@ -2504,6 +2692,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 { diff --git a/src/rootcell/rootcell.ts b/src/rootcell/rootcell.ts index c650cfb..036dfec 100644 --- a/src/rootcell/rootcell.ts +++ b/src/rootcell/rootcell.ts @@ -5,7 +5,6 @@ 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"; @@ -29,6 +28,7 @@ import { createProviderBundle } from "./providers/factory.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"; @@ -94,6 +94,10 @@ export interface VmListEntry { readonly state: string; } +export interface RootcellAppOptions { + readonly tunnelPortAvailable?: PortAvailabilityCheck; +} + function log(message: string): void { console.error(`rootcell: ${message}`); } @@ -173,6 +177,7 @@ export class RootcellApp { constructor( private readonly config: RootcellConfig, private readonly providers: ProviderBundle, + private readonly options: RootcellAppOptions = {}, ) { this.networkPlan = this.providers.network.plan(); } @@ -629,15 +634,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)`); @@ -645,11 +658,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"> { @@ -720,29 +729,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" @@ -1245,20 +1231,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"); @@ -1284,21 +1256,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 [ 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); + }); + }); + }); +} From a4bbada2718cb3dd3a4147cc0f0030a9541af7f8 Mon Sep 17 00:00:00 2001 From: Jim Pudar Date: Mon, 25 May 2026 08:22:22 -0400 Subject: [PATCH 05/10] Add plannotator package extension --- EXTENSIONS_PLAN.md | 28 +- extensions/plannotator/home-manager.nix | 29 + extensions/plannotator/package.nix | 43 ++ .../runtime-deps/package-lock.json | 616 ++++++++++++++++++ .../plannotator/runtime-deps/package.json | 11 + src/rootcell/extensions/registry.ts | 14 +- src/rootcell/rootcell.test.ts | 14 +- 7 files changed, 733 insertions(+), 22 deletions(-) create mode 100644 extensions/plannotator/home-manager.nix create mode 100644 extensions/plannotator/package.nix create mode 100644 extensions/plannotator/runtime-deps/package-lock.json create mode 100644 extensions/plannotator/runtime-deps/package.json diff --git a/EXTENSIONS_PLAN.md b/EXTENSIONS_PLAN.md index e3ffbb2..4aad054 100644 --- a/EXTENSIONS_PLAN.md +++ b/EXTENSIONS_PLAN.md @@ -1,6 +1,6 @@ # Rootcell Extensions Implementation Plan -Status: Phase 2 host tunnel primitive is implemented; Plannotator package/install and the Plannotator extension command remain in later phases. +Status: Phase 3 Plannotator package/install is implemented; the Plannotator extension command remains in a later phase. ## Goal @@ -232,18 +232,20 @@ Implementation update: the Phase 2 slice added shared tunnel helpers for local-p ### Phase 3: Plannotator extension package/install -- Package Plannotator through Nix/Home Manager, not runtime `pi install` inside the agent VM. -- 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. -- 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. -- 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. -- Install/configure Plannotator using Pi's normal package model and package identity (`@plannotator/pi-extension`) rather than renaming it to the Rootcell extension id. -- Let Nix control the pinned package content, while Pi loads it through normal package mechanisms. -- Do not have Home Manager own/clobber `~/.pi/agent/settings.json`, because that is a user-editable Pi settings file. -- 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. -- 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`. -- 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. -- 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. -- 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. +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 diff --git a/extensions/plannotator/home-manager.nix b/extensions/plannotator/home-manager.nix new file mode 100644 index 0000000..fbb36f8 --- /dev/null +++ b/extensions/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/plannotator/package.nix b/extensions/plannotator/package.nix new file mode 100644 index 0000000..ecb2a53 --- /dev/null +++ b/extensions/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.16"; + + src = pkgs.fetchurl { + url = "https://registry.npmjs.org/@plannotator/pi-extension/-/pi-extension-${version}.tgz"; + hash = "sha256-b7jxuG6FNhN3PT8yDLOMnmf3PqdO4tuwZM4o8nJMNto="; + }; + + 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/plannotator/runtime-deps/package-lock.json b/extensions/plannotator/runtime-deps/package-lock.json new file mode 100644 index 0000000..a309259 --- /dev/null +++ b/extensions/plannotator/runtime-deps/package-lock.json @@ -0,0 +1,616 @@ +{ + "name": "plannotator-runtime-deps", + "version": "0.19.16", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "plannotator-runtime-deps", + "version": "0.19.16", + "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/plannotator/runtime-deps/package.json b/extensions/plannotator/runtime-deps/package.json new file mode 100644 index 0000000..315cadb --- /dev/null +++ b/extensions/plannotator/runtime-deps/package.json @@ -0,0 +1,11 @@ +{ + "name": "plannotator-runtime-deps", + "version": "0.19.16", + "private": true, + "type": "module", + "dependencies": { + "@joplin/turndown-plugin-gfm": "^1.0.64", + "@pierre/diffs": "^1.1.12", + "turndown": "^7.2.4" + } +} diff --git a/src/rootcell/extensions/registry.ts b/src/rootcell/extensions/registry.ts index b580ce6..1a68bdd 100644 --- a/src/rootcell/extensions/registry.ts +++ b/src/rootcell/extensions/registry.ts @@ -98,12 +98,6 @@ export type RootcellExtensionDefinition = Readonly< const RootcellExtensionDefinitionsSchema = z.array(RootcellExtensionDefinitionSchema); -const NO_GUEST_HOOKS: RootcellExtensionGuestHooks = parseSchema(RootcellExtensionGuestHooksSchema, { - agentNixos: [], - firewallNixos: [], - homeManager: [], -}, "invalid empty rootcell extension guest hooks"); - const NO_HOST_COMMANDS: readonly RootcellExtensionHostCommand[] = parseSchema( RootcellExtensionHostCommandsSchema, [], @@ -113,9 +107,13 @@ const NO_HOST_COMMANDS: readonly RootcellExtensionHostCommand[] = parseSchema( export const ROOTCELL_EXTENSIONS: readonly RootcellExtensionDefinition[] = parseSchema(RootcellExtensionDefinitionsSchema, [ { id: "plannotator", - description: "Pi Plannotator integration metadata placeholder", + description: "Pi Plannotator integration package and remote-session configuration", requiresProvision: true, - guestHooks: NO_GUEST_HOOKS, + guestHooks: { + agentNixos: [], + firewallNixos: [], + homeManager: ["extensions/plannotator/home-manager.nix"], + }, hostCommands: NO_HOST_COMMANDS, }, { diff --git a/src/rootcell/rootcell.test.ts b/src/rootcell/rootcell.test.ts index 064978a..f36de9c 100644 --- a/src/rootcell/rootcell.test.ts +++ b/src/rootcell/rootcell.test.ts @@ -15,6 +15,7 @@ import { writeExtensionNixAggregators, } from "./extensions/nix.ts"; import { + ROOTCELL_EXTENSIONS, RootcellExtensionDefinitionSchema, type RootcellExtensionDefinition, } from "./extensions/registry.ts"; @@ -118,6 +119,15 @@ describe("rootcell extension registry", () => { hostCommands: [{ ...valid.hostCommands[0], name: "CheckStatus" }], }).success).toBe(false); }); + + test("registers Plannotator package install hook without host commands", () => { + const plannotator = ROOTCELL_EXTENSIONS.find((extension) => extension.id === "plannotator"); + + expect(plannotator?.guestHooks.homeManager).toEqual(["extensions/plannotator/home-manager.nix"]); + expect(plannotator?.guestHooks.agentNixos).toEqual(expect.schemaMatching(EmptyStringArraySchema)); + expect(plannotator?.guestHooks.firewallNixos).toEqual(expect.schemaMatching(EmptyStringArraySchema)); + expect(plannotator?.hostCommands).toEqual(expect.schemaMatching(EmptyStringArraySchema)); + }); }); describe("rootcell argument parsing", () => { @@ -605,9 +615,11 @@ describe("rootcell extension Nix hooks", () => { }); test("renders enabled Home Manager extension imports", () => { - const rendered = renderExtensionNixAggregator(parseExtensionsConfig("subagent=true\n"), "homeManager"); + const rendered = renderExtensionNixAggregator(parseExtensionsConfig("subagent=true\nplannotator=true\n"), "homeManager"); expect(rendered).toContain("../extensions/subagent/home-manager.nix"); + expect(rendered).toContain("../extensions/plannotator/home-manager.nix"); expect(renderExtensionNixAggregator(parseExtensionsConfig("subagent=true\n"), "agentNixos")).not.toContain("../extensions/subagent"); + expect(renderExtensionNixAggregator(parseExtensionsConfig("plannotator=false\n"), "homeManager")).not.toContain("../extensions/plannotator/home-manager.nix"); }); test("writes the explicit generated hook files", () => { From 1feb03e7b057faf632b6865d86886f7fc4c7d88a Mon Sep 17 00:00:00 2001 From: Jim Pudar Date: Mon, 25 May 2026 08:30:39 -0400 Subject: [PATCH 06/10] Add Plannotator extension tunnel --- EXTENSIONS_PLAN.md | 24 +- src/rootcell/extensions/plannotator.test.ts | 232 ++++++++++++++++++++ src/rootcell/extensions/plannotator.ts | 70 ++++++ src/rootcell/extensions/registry.ts | 3 +- src/rootcell/rootcell.test.ts | 4 +- 5 files changed, 319 insertions(+), 14 deletions(-) create mode 100644 src/rootcell/extensions/plannotator.test.ts create mode 100644 src/rootcell/extensions/plannotator.ts diff --git a/EXTENSIONS_PLAN.md b/EXTENSIONS_PLAN.md index 4aad054..5b87b39 100644 --- a/EXTENSIONS_PLAN.md +++ b/EXTENSIONS_PLAN.md @@ -1,6 +1,6 @@ # Rootcell Extensions Implementation Plan -Status: Phase 3 Plannotator package/install is implemented; the Plannotator extension command remains in a later phase. +Status: Phase 4 Plannotator host tunnel command is implemented; documentation and migration notes remain in Phase 5. ## Goal @@ -249,16 +249,18 @@ Implementation update: the Phase 3 slice added a Nix package for `@plannotator/p ### Phase 4: Plannotator host command -- 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). -- Require `plannotator=true` before `rootcell extension 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. -- 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. -- 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. -- 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. -- Print the host URL; do not automatically open a browser and do not add `--open` in the first iteration. -- 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. -- 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`. -- Provide clear readiness/error messages. -- 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. +Implementation update: the Phase 4 slice added `rootcell extension plannotator tunnel` as an extension-owned host command in `src/rootcell/extensions/plannotator.ts` and registered it from the built-in extension registry. The command requires `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/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 `plannotator=true` before `rootcell extension 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 diff --git a/src/rootcell/extensions/plannotator.test.ts b/src/rootcell/extensions/plannotator.test.ts new file mode 100644 index 0000000..b4cfe30 --- /dev/null +++ b/src/rootcell/extensions/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 "./plannotator.ts"; +import type { ExtensionHostCommandContext } from "./registry.ts"; + +describe("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"), "plannotator=false\nsubagent=false\n", "utf8"); + + const status = await runExtensionCommand({ + repoDir: repo, + env: { ...process.env, ROOTCELL_STATE_DIR: stateDir }, + instanceName: "dev", + rest: ["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 '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"), "plannotator=true\nsubagent=false\n", "utf8"); + + expect(completeExtensionCommand({ + repoDir: repo, + env, + instanceName: "dev", + words: ["extension", ""], + current: "", + })).toContain("plannotator"); + expect(completeExtensionCommand({ + repoDir: repo, + env, + instanceName: "dev", + words: ["extension", "plannotator", ""], + current: "", + })).toEqual(["tunnel"]); + expect(completeExtensionCommand({ + repoDir: repo, + env, + instanceName: "dev", + words: ["extension", "plannotator", "tunnel", ""], + current: "", + })).toEqual([]); + + writeFileSync(join(instanceDir, "extensions.txt"), "plannotator=false\nsubagent=false\n", "utf8"); + expect(completeExtensionCommand({ + repoDir: repo, + env, + instanceName: "dev", + words: ["extension", ""], + current: "", + })).not.toContain("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 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-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("plannotator=true\nsubagent=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/plannotator.ts b/src/rootcell/extensions/plannotator.ts new file mode 100644 index 0000000..4820ca7 --- /dev/null +++ b/src/rootcell/extensions/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 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 plannotator tunnel again.`); +} diff --git a/src/rootcell/extensions/registry.ts b/src/rootcell/extensions/registry.ts index 1a68bdd..8ff28bc 100644 --- a/src/rootcell/extensions/registry.ts +++ b/src/rootcell/extensions/registry.ts @@ -3,6 +3,7 @@ import type { LocalPortForwardHandle, LocalPortForwardOptions, VmRole, VmStatus import { NonEmptyStringSchema, parseSchema } from "../schema.ts"; import type { RootcellConfig } from "../types.ts"; import type { ParsedExtensionsConfig } from "./config.ts"; +import { PLANNOTATOR_TUNNEL_COMMAND } from "./plannotator.ts"; export const RootcellExtensionIdSchema = z.enum(["plannotator", "subagent"]); @@ -114,7 +115,7 @@ export const ROOTCELL_EXTENSIONS: readonly RootcellExtensionDefinition[] = parse firewallNixos: [], homeManager: ["extensions/plannotator/home-manager.nix"], }, - hostCommands: NO_HOST_COMMANDS, + hostCommands: [PLANNOTATOR_TUNNEL_COMMAND], }, { id: "subagent", diff --git a/src/rootcell/rootcell.test.ts b/src/rootcell/rootcell.test.ts index f36de9c..df2df10 100644 --- a/src/rootcell/rootcell.test.ts +++ b/src/rootcell/rootcell.test.ts @@ -120,13 +120,13 @@ describe("rootcell extension registry", () => { }).success).toBe(false); }); - test("registers Plannotator package install hook without host commands", () => { + test("registers Plannotator package install hook and tunnel command", () => { const plannotator = ROOTCELL_EXTENSIONS.find((extension) => extension.id === "plannotator"); expect(plannotator?.guestHooks.homeManager).toEqual(["extensions/plannotator/home-manager.nix"]); expect(plannotator?.guestHooks.agentNixos).toEqual(expect.schemaMatching(EmptyStringArraySchema)); expect(plannotator?.guestHooks.firewallNixos).toEqual(expect.schemaMatching(EmptyStringArraySchema)); - expect(plannotator?.hostCommands).toEqual(expect.schemaMatching(EmptyStringArraySchema)); + expect(plannotator?.hostCommands.map((command) => command.name)).toEqual(["tunnel"]); }); }); From 4e3d1b3fec818c24ac9ebeb4880a4f6a74d94d0e Mon Sep 17 00:00:00 2001 From: Jim Pudar Date: Mon, 25 May 2026 08:37:08 -0400 Subject: [PATCH 07/10] Document extension workflows --- EXTENSIONS_PLAN.md | 10 ++++--- README.md | 72 +++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 77 insertions(+), 5 deletions(-) diff --git a/EXTENSIONS_PLAN.md b/EXTENSIONS_PLAN.md index 5b87b39..2572221 100644 --- a/EXTENSIONS_PLAN.md +++ b/EXTENSIONS_PLAN.md @@ -1,6 +1,6 @@ # Rootcell Extensions Implementation Plan -Status: Phase 4 Plannotator host tunnel command is implemented; documentation and migration notes remain in Phase 5. +Status: Phase 5 documentation and migration notes are implemented; the V1 Rootcell extensions plan is complete. ## Goal @@ -264,6 +264,8 @@ Implementation update: the Phase 4 slice added `rootcell extension plannotator t ### Phase 5: Documentation and migration -- Document the extension concept, commands, and Plannotator workflow. -- Document the subagent migration clearly: existing VMs keep current files until explicit provision, but after provisioning with `subagent=false`, Home Manager removes the previously managed subagent extension/example agents. Users who rely on it must run `./rootcell extension enable subagent && ./rootcell provision`. -- Add README examples. +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 `subagent=false`, Home Manager removes the previously managed subagent extension/example agents. Users who rely on it must run `./rootcell extension enable subagent && ./rootcell provision`. +- [X] Add README examples. diff --git a/README.md b/README.md index 0beedc5..20df9df 100644 --- a/README.md +++ b/README.md @@ -216,6 +216,7 @@ state root. ./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,6 +224,8 @@ 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 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 @@ -235,6 +238,65 @@ state root. 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 broader than Pi extensions: a Rootcell extension can install Pi +resources, add host commands, expose local tunnels, or contribute guest NixOS and +Home Manager modules. + +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 plannotator +./rootcell extension disable plannotator +./rootcell edit extensions + +./rootcell --instance dev extension list +./rootcell --instance dev extension enable subagent +./rootcell --instance dev provision +``` + +### Plannotator + +The `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 plannotator +./rootcell provision + +# Terminal 1: keep the tunnel open. +./rootcell extension plannotator tunnel + +# Terminal 2: start Pi normally. +./rootcell pi +``` + +The tunnel command requires `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. + +### Subagent + +The `subagent` 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 `subagent=false`, Home Manager +removes Rootcell-managed subagent resources. If you rely on them, opt back in +before provisioning: + +```bash +./rootcell extension enable subagent && ./rootcell provision +``` + ## Allowing Network Access Network policy is per instance. On first run, `./rootcell` copies each tracked @@ -277,8 +339,11 @@ instance, use the same paths under that instance's state directory and run 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 +366,10 @@ 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 Pi packages and extension resources live under the +top-level `extensions/` directory and are installed only when their Rootcell +extension is enabled and provisioned. + 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 +451,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 From 8476bce3ca2ca21a6c02ba78a6a4a252f31f683c Mon Sep 17 00:00:00 2001 From: Jim Pudar Date: Mon, 25 May 2026 09:24:39 -0400 Subject: [PATCH 08/10] Bump plannotator extension to 0.19.22 --- extensions/plannotator/package.nix | 4 ++-- extensions/plannotator/runtime-deps/package-lock.json | 4 ++-- extensions/plannotator/runtime-deps/package.json | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/extensions/plannotator/package.nix b/extensions/plannotator/package.nix index ecb2a53..7afd2fc 100644 --- a/extensions/plannotator/package.nix +++ b/extensions/plannotator/package.nix @@ -11,11 +11,11 @@ let in pkgs.stdenvNoCC.mkDerivation rec { pname = "plannotator-pi-extension"; - version = "0.19.16"; + version = "0.19.22"; src = pkgs.fetchurl { url = "https://registry.npmjs.org/@plannotator/pi-extension/-/pi-extension-${version}.tgz"; - hash = "sha256-b7jxuG6FNhN3PT8yDLOMnmf3PqdO4tuwZM4o8nJMNto="; + hash = "sha256-X9JB3e5mgvWylLTtaFgysOnUy7QoCJ7t1MDog23SAoo="; }; nativeBuildInputs = [ diff --git a/extensions/plannotator/runtime-deps/package-lock.json b/extensions/plannotator/runtime-deps/package-lock.json index a309259..ca06aa8 100644 --- a/extensions/plannotator/runtime-deps/package-lock.json +++ b/extensions/plannotator/runtime-deps/package-lock.json @@ -1,12 +1,12 @@ { "name": "plannotator-runtime-deps", - "version": "0.19.16", + "version": "0.19.22", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "plannotator-runtime-deps", - "version": "0.19.16", + "version": "0.19.22", "dependencies": { "@joplin/turndown-plugin-gfm": "^1.0.64", "@pierre/diffs": "^1.1.12", diff --git a/extensions/plannotator/runtime-deps/package.json b/extensions/plannotator/runtime-deps/package.json index 315cadb..9938ede 100644 --- a/extensions/plannotator/runtime-deps/package.json +++ b/extensions/plannotator/runtime-deps/package.json @@ -1,6 +1,6 @@ { "name": "plannotator-runtime-deps", - "version": "0.19.16", + "version": "0.19.22", "private": true, "type": "module", "dependencies": { From f135741d9ad8e70d23c8770ab5b81a36989ff7fb Mon Sep 17 00:00:00 2001 From: Jim Pudar Date: Mon, 25 May 2026 09:47:57 -0400 Subject: [PATCH 09/10] Rename Pi rootcell extensions --- EXTENSIONS_PLAN.md | 51 ++++--- README.md | 56 ++++--- .../home-manager.nix | 0 .../package.nix | 0 .../runtime-deps/package-lock.json | 0 .../runtime-deps/package.json | 0 .../home-manager.nix | 0 src/rootcell/extensions/config.ts | 31 +++- ...notator.test.ts => pi-plannotator.test.ts} | 28 ++-- .../{plannotator.ts => pi-plannotator.ts} | 4 +- src/rootcell/extensions/registry.ts | 12 +- src/rootcell/rootcell.test.ts | 140 +++++++++--------- 12 files changed, 180 insertions(+), 142 deletions(-) rename extensions/{plannotator => pi-plannotator}/home-manager.nix (100%) rename extensions/{plannotator => pi-plannotator}/package.nix (100%) rename extensions/{plannotator => pi-plannotator}/runtime-deps/package-lock.json (100%) rename extensions/{plannotator => pi-plannotator}/runtime-deps/package.json (100%) rename extensions/{subagent => pi-subagents}/home-manager.nix (100%) rename src/rootcell/extensions/{plannotator.test.ts => pi-plannotator.test.ts} (86%) rename src/rootcell/extensions/{plannotator.ts => pi-plannotator.ts} (94%) diff --git a/EXTENSIONS_PLAN.md b/EXTENSIONS_PLAN.md index 2572221..38110e5 100644 --- a/EXTENSIONS_PLAN.md +++ b/EXTENSIONS_PLAN.md @@ -8,6 +8,10 @@ Add an opt-in Rootcell extension mechanism so optional Rootcell capabilities can 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. @@ -19,7 +23,7 @@ Initial target extensions: - 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 plannotator tunnel`. +- 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. @@ -33,8 +37,8 @@ Initial target extensions: - 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. -- `subagent` opt-in preserves current behavior: Pi subagent extension plus example agents. -- Existing VMs keep old subagent files until explicit provision; after provisioning with `subagent=false`, managed subagent resources are removed. +- `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 @@ -65,13 +69,13 @@ Initial target extensions: ## 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. `plannotator=false` and `subagent=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. +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 plannotator tunnel` + - `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; @@ -93,10 +97,11 @@ Initial target extensions: - 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. `plannotator=false` and `subagent=false`. Missing files for existing instances are seeded the same way and logged. +- `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. @@ -141,10 +146,10 @@ Initial target extensions: - 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 `plannotator`. +- 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 plannotator tunnel` in one terminal and start Pi normally in another. -- `rootcell extension 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. +- 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. @@ -152,9 +157,9 @@ Initial target extensions: ### Subagent migration -- Move current unconditional subagent install into the `subagent` Rootcell extension's Home Manager hook. -- Enabling `subagent` 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 `subagent=false`, managed subagent resources are removed. Document how to opt back in. +- 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 @@ -167,8 +172,8 @@ Initial target extensions: - `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/subagent/home-manager.nix` — Home Manager hook module preserving current subagent behavior behind opt-in. -- `extensions/plannotator/home-manager.nix` — Home Manager hook module for Plannotator package/env setup. +- `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. @@ -178,7 +183,7 @@ Initial target extensions: - 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 `subagent` extension. +- 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. @@ -186,9 +191,9 @@ Initial target extensions: ### 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 `subagent=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 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 `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`. +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`. @@ -196,7 +201,7 @@ Implementation update: the host-command registry slice added `RootcellExtensionD - [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/subagent/home-manager.nix` and `extensions/plannotator/home-manager.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. @@ -213,8 +218,8 @@ Implementation update: the host-command registry slice added `RootcellExtensionD - [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 `subagent` is no longer unconditional and is provided by its extension's Home Manager hook module. -- [X] The `subagent` 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] 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. @@ -249,10 +254,10 @@ Implementation update: the Phase 3 slice added a Nix package for `@plannotator/p ### Phase 4: Plannotator host command -Implementation update: the Phase 4 slice added `rootcell extension plannotator tunnel` as an extension-owned host command in `src/rootcell/extensions/plannotator.ts` and registered it from the built-in extension registry. The command requires `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/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`. +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 `plannotator=true` before `rootcell extension 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 `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. @@ -267,5 +272,5 @@ Implementation update: the Phase 4 slice added `rootcell extension plannotator t 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 `subagent=false`, Home Manager removes the previously managed subagent extension/example agents. Users who rely on it must run `./rootcell extension enable subagent && ./rootcell provision`. +- [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 20df9df..9a59040 100644 --- a/README.md +++ b/README.md @@ -225,7 +225,7 @@ state root. ./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 plannotator # enable an extension for next provision +./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 @@ -241,9 +241,18 @@ Detailed browser spy operator and developer notes live in ## Extensions Rootcell extensions are per-instance opt-ins for optional Rootcell capabilities. -They are broader than Pi extensions: a Rootcell extension can install Pi -resources, add host commands, expose local tunnels, or contribute guest NixOS and -Home Manager modules. +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 @@ -253,48 +262,48 @@ Enabling or disabling an extension only edits that file; run ```bash ./rootcell extension list -./rootcell extension enable plannotator -./rootcell extension disable plannotator +./rootcell extension enable pi-plannotator +./rootcell extension disable pi-plannotator ./rootcell edit extensions ./rootcell --instance dev extension list -./rootcell --instance dev extension enable subagent +./rootcell --instance dev extension enable pi-subagents ./rootcell --instance dev provision ``` ### Plannotator -The `plannotator` extension installs the Plannotator Pi package in the agent VM -and configures Pi sessions for remote browser access. A typical workflow is: +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 plannotator +./rootcell extension enable pi-plannotator ./rootcell provision # Terminal 1: keep the tunnel open. -./rootcell extension plannotator tunnel +./rootcell extension pi-plannotator tunnel # Terminal 2: start Pi normally. ./rootcell pi ``` -The tunnel command requires `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. +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. -### Subagent +### Pi Subagents -The `subagent` extension installs the Pi subagent extension and bundled example -agents. It is disabled by default for new provisions. +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 `subagent=false`, Home Manager +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 subagent && ./rootcell provision +./rootcell extension enable pi-subagents && ./rootcell provision ``` ## Allowing Network Access @@ -366,9 +375,10 @@ 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 Pi packages and extension resources live under the -top-level `extensions/` directory and are installed only when their Rootcell -extension is enabled and provisioned. +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`. diff --git a/extensions/plannotator/home-manager.nix b/extensions/pi-plannotator/home-manager.nix similarity index 100% rename from extensions/plannotator/home-manager.nix rename to extensions/pi-plannotator/home-manager.nix diff --git a/extensions/plannotator/package.nix b/extensions/pi-plannotator/package.nix similarity index 100% rename from extensions/plannotator/package.nix rename to extensions/pi-plannotator/package.nix diff --git a/extensions/plannotator/runtime-deps/package-lock.json b/extensions/pi-plannotator/runtime-deps/package-lock.json similarity index 100% rename from extensions/plannotator/runtime-deps/package-lock.json rename to extensions/pi-plannotator/runtime-deps/package-lock.json diff --git a/extensions/plannotator/runtime-deps/package.json b/extensions/pi-plannotator/runtime-deps/package.json similarity index 100% rename from extensions/plannotator/runtime-deps/package.json rename to extensions/pi-plannotator/runtime-deps/package.json diff --git a/extensions/subagent/home-manager.nix b/extensions/pi-subagents/home-manager.nix similarity index 100% rename from extensions/subagent/home-manager.nix rename to extensions/pi-subagents/home-manager.nix diff --git a/src/rootcell/extensions/config.ts b/src/rootcell/extensions/config.ts index 75c0691..377231e 100644 --- a/src/rootcell/extensions/config.ts +++ b/src/rootcell/extensions/config.ts @@ -15,6 +15,11 @@ import { 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"); @@ -102,15 +107,17 @@ export function parseExtensionsConfig(text: string): ParsedExtensionsConfig { if (!ExtensionConfigKeySchema.safeParse(key).success) { throw new Error(`invalid extension key in extensions.txt on line ${String(index + 1)}: ${key}`); } - if (seen.has(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(key); + seen.add(canonicalKey); const valueEnabled = parseExtensionBoolean(value, key, index + 1); - const known = isRootcellExtensionId(key); + const known = canonicalId !== undefined; if (known && valueEnabled) { - enabled.add(key); + enabled.add(canonicalId); } if (!known) { unknownKeys.push(key); @@ -183,11 +190,14 @@ export function renderExtensionsConfig( lines.push(line.raw); continue; } - present.add(line.key); - if (isRootcellExtensionId(line.key) && overrides.has(line.key)) { - lines.push(`${line.key}=${overrides.get(line.key) === true ? "true" : "false"}`); + 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); } @@ -219,6 +229,13 @@ function writeExtensionsConfig(path: string, text: string): void { 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)) { diff --git a/src/rootcell/extensions/plannotator.test.ts b/src/rootcell/extensions/pi-plannotator.test.ts similarity index 86% rename from src/rootcell/extensions/plannotator.test.ts rename to src/rootcell/extensions/pi-plannotator.test.ts index b4cfe30..18b48f9 100644 --- a/src/rootcell/extensions/plannotator.test.ts +++ b/src/rootcell/extensions/pi-plannotator.test.ts @@ -6,10 +6,10 @@ import type { LocalPortForwardOptions, LocalPortForwardHandle, VmRole, VmStatus import type { RootcellConfig } from "../types.ts"; import { completeExtensionCommand, runExtensionCommand } from "./commands.ts"; import { parseExtensionsConfig } from "./config.ts"; -import { createPlannotatorTunnelCommand } from "./plannotator.ts"; +import { createPlannotatorTunnelCommand } from "./pi-plannotator.ts"; import type { ExtensionHostCommandContext } from "./registry.ts"; -describe("plannotator extension host command", () => { +describe("pi-plannotator extension host command", () => { test("disabled extension gating prevents context creation", async () => { const repo = makeRepo(); try { @@ -18,13 +18,13 @@ describe("plannotator extension host command", () => { const logs: string[] = []; let contexts = 0; mkdirSync(instanceDir, { recursive: true }); - writeFileSync(join(instanceDir, "extensions.txt"), "plannotator=false\nsubagent=false\n", "utf8"); + 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: ["plannotator", "tunnel"], + rest: ["pi-plannotator", "tunnel"], log: (message) => logs.push(message), createContext: () => { contexts += 1; @@ -34,7 +34,7 @@ describe("plannotator extension host command", () => { expect(status).toBe(1); expect(contexts).toBe(0); - expect(logs.join("\n")).toContain("extension 'plannotator' is disabled"); + expect(logs.join("\n")).toContain("extension 'pi-plannotator' is disabled"); } finally { rmSync(repo, { recursive: true, force: true }); } @@ -47,7 +47,7 @@ describe("plannotator extension host command", () => { const instanceDir = join(stateDir, "dev"); const env = { ...process.env, ROOTCELL_STATE_DIR: stateDir }; mkdirSync(instanceDir, { recursive: true }); - writeFileSync(join(instanceDir, "extensions.txt"), "plannotator=true\nsubagent=false\n", "utf8"); + writeFileSync(join(instanceDir, "extensions.txt"), "pi-plannotator=true\npi-subagents=false\n", "utf8"); expect(completeExtensionCommand({ repoDir: repo, @@ -55,30 +55,30 @@ describe("plannotator extension host command", () => { instanceName: "dev", words: ["extension", ""], current: "", - })).toContain("plannotator"); + })).toContain("pi-plannotator"); expect(completeExtensionCommand({ repoDir: repo, env, instanceName: "dev", - words: ["extension", "plannotator", ""], + words: ["extension", "pi-plannotator", ""], current: "", })).toEqual(["tunnel"]); expect(completeExtensionCommand({ repoDir: repo, env, instanceName: "dev", - words: ["extension", "plannotator", "tunnel", ""], + words: ["extension", "pi-plannotator", "tunnel", ""], current: "", })).toEqual([]); - writeFileSync(join(instanceDir, "extensions.txt"), "plannotator=false\nsubagent=false\n", "utf8"); + 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("plannotator"); + })).not.toContain("pi-plannotator"); } finally { rmSync(repo, { recursive: true, force: true }); } @@ -99,7 +99,7 @@ describe("plannotator extension host command", () => { expect(status).toBe(2); expect(statusChecks).toBe(0); - expect(logs).toEqual(["usage: rootcell extension plannotator tunnel"]); + expect(logs).toEqual(["usage: rootcell extension pi-plannotator tunnel"]); }); test.each([ @@ -199,7 +199,7 @@ describe("plannotator extension host command", () => { }); function makeRepo(): string { - return mkdtempSync(join(tmpdir(), "rootcell-plannotator-")); + return mkdtempSync(join(tmpdir(), "rootcell-pi-plannotator-")); } function testContext(input: { @@ -210,7 +210,7 @@ function testContext(input: { return { repoDir: "/repo", instanceName: "dev", - extensionConfig: parseExtensionsConfig("plannotator=true\nsubagent=false\n"), + 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" })), diff --git a/src/rootcell/extensions/plannotator.ts b/src/rootcell/extensions/pi-plannotator.ts similarity index 94% rename from src/rootcell/extensions/plannotator.ts rename to src/rootcell/extensions/pi-plannotator.ts index 4820ca7..e6e5702 100644 --- a/src/rootcell/extensions/plannotator.ts +++ b/src/rootcell/extensions/pi-plannotator.ts @@ -23,7 +23,7 @@ export function createPlannotatorTunnelCommand( complete: () => [], run: async (context, args) => { if (args.length > 0) { - context.log("usage: rootcell extension plannotator tunnel"); + context.log("usage: rootcell extension pi-plannotator tunnel"); return 2; } @@ -66,5 +66,5 @@ function logAgentVmNotRunning(context: ExtensionHostCommandContext, status: Excl 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 plannotator tunnel again.`); + 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 index 8ff28bc..f9bf3a9 100644 --- a/src/rootcell/extensions/registry.ts +++ b/src/rootcell/extensions/registry.ts @@ -3,9 +3,9 @@ import type { LocalPortForwardHandle, LocalPortForwardOptions, VmRole, VmStatus import { NonEmptyStringSchema, parseSchema } from "../schema.ts"; import type { RootcellConfig } from "../types.ts"; import type { ParsedExtensionsConfig } from "./config.ts"; -import { PLANNOTATOR_TUNNEL_COMMAND } from "./plannotator.ts"; +import { PLANNOTATOR_TUNNEL_COMMAND } from "./pi-plannotator.ts"; -export const RootcellExtensionIdSchema = z.enum(["plannotator", "subagent"]); +export const RootcellExtensionIdSchema = z.enum(["pi-plannotator", "pi-subagents"]); export type RootcellExtensionId = z.infer; @@ -107,24 +107,24 @@ const NO_HOST_COMMANDS: readonly RootcellExtensionHostCommand[] = parseSchema( export const ROOTCELL_EXTENSIONS: readonly RootcellExtensionDefinition[] = parseSchema(RootcellExtensionDefinitionsSchema, [ { - id: "plannotator", + id: "pi-plannotator", description: "Pi Plannotator integration package and remote-session configuration", requiresProvision: true, guestHooks: { agentNixos: [], firewallNixos: [], - homeManager: ["extensions/plannotator/home-manager.nix"], + homeManager: ["extensions/pi-plannotator/home-manager.nix"], }, hostCommands: [PLANNOTATOR_TUNNEL_COMMAND], }, { - id: "subagent", + id: "pi-subagents", description: "Pi subagent extension and bundled example agents", requiresProvision: true, guestHooks: { agentNixos: [], firewallNixos: [], - homeManager: ["extensions/subagent/home-manager.nix"], + homeManager: ["extensions/pi-subagents/home-manager.nix"], }, hostCommands: NO_HOST_COMMANDS, }, diff --git a/src/rootcell/rootcell.test.ts b/src/rootcell/rootcell.test.ts index df2df10..44210cf 100644 --- a/src/rootcell/rootcell.test.ts +++ b/src/rootcell/rootcell.test.ts @@ -95,7 +95,7 @@ function expectRunArgs(value: ParsedRootcellRunArgs): void { describe("rootcell extension registry", () => { test("validates host command definitions", () => { const valid = { - id: "plannotator", + id: "pi-plannotator", description: "test extension", requiresProvision: true, guestHooks: { @@ -121,9 +121,9 @@ describe("rootcell extension registry", () => { }); test("registers Plannotator package install hook and tunnel command", () => { - const plannotator = ROOTCELL_EXTENSIONS.find((extension) => extension.id === "plannotator"); + const plannotator = ROOTCELL_EXTENSIONS.find((extension) => extension.id === "pi-plannotator"); - expect(plannotator?.guestHooks.homeManager).toEqual(["extensions/plannotator/home-manager.nix"]); + 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"]); @@ -242,13 +242,13 @@ describe("rootcell argument parsing", () => { spyOptions: { open: true }, }); - const enable = runArgs(["--instance", "dev", "extension", "enable", "subagent"]); + const enable = runArgs(["--instance", "dev", "extension", "enable", "pi-subagents"]); expectRunArgs(enable); expect(enable).toEqual({ kind: "run", instanceName: "dev", subcommand: "extension", - rest: ["enable", "subagent"], + rest: ["enable", "pi-subagents"], spyOptions: { open: true }, }); @@ -544,21 +544,21 @@ describe("rootcell extension config", () => { test("parses extension booleans and unknown valid keys", () => { const config = parseExtensionsConfig([ "# local extension choices", - "subagent=yes", - "plannotator", + "pi-subagents=yes", + "pi-plannotator", "future-extension=off", "", ].join("\n")); - expect(config.enabled.has("subagent")).toBe(true); - expect(config.enabled.has("plannotator")).toBe(false); + 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("subagent=maybe\n")).toThrow("invalid boolean value"); - expect(() => parseExtensionsConfig("subagent=true\nsubagent=false\n")).toThrow("duplicate 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", () => { @@ -566,16 +566,16 @@ describe("rootcell extension config", () => { "# keep this", "future-extension=true", "", - "subagent=off", + "pi-subagents=off", "", - ].join("\n")), new Map([["subagent", true]])); + ].join("\n")), new Map([["pi-subagents", true]])); expect(rendered).toBe([ "# keep this", "future-extension=true", "", - "subagent=true", - "plannotator=false", + "pi-subagents=true", + "pi-plannotator=false", "", ].join("\n")); }); @@ -585,25 +585,31 @@ describe("rootcell extension config", () => { try { const path = join(repo, "extensions.txt"); const seeded = ensureExtensionsConfig(path); - expect(formatExtensionsList(seeded)).toContain("plannotator disabled"); - expect(readFileSync(path, "utf8")).toBe("plannotator=false\nsubagent=false\n"); + expect(formatExtensionsList(seeded)).toContain("pi-plannotator disabled"); + expect(readFileSync(path, "utf8")).toBe("pi-plannotator=false\npi-subagents=false\n"); - const enabled = setExtensionEnabled(path, "subagent", true); + const enabled = setExtensionEnabled(path, "pi-subagents", true); expect(enabled.changed).toBe(true); - expect(readFileSync(path, "utf8")).toBe("plannotator=false\nsubagent=true\n"); + expect(readFileSync(path, "utf8")).toBe("pi-plannotator=false\npi-subagents=true\n"); - const enabledAgain = setExtensionEnabled(path, "subagent", true); + const enabledAgain = setExtensionEnabled(path, "pi-subagents", true); expect(enabledAgain.changed).toBe(false); - expect(readFileSync(path, "utf8")).toBe("plannotator=false\nsubagent=true\n"); + 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("subagent=false\n"), "homeManager")).toBe([ + expect(renderExtensionNixAggregator(parseExtensionsConfig("pi-subagents=false\n"), "homeManager")).toBe([ "# Generated by ./rootcell from this instance's extensions.txt. DO NOT EDIT.", "{ ... }:", "{", @@ -615,11 +621,11 @@ describe("rootcell extension Nix hooks", () => { }); test("renders enabled Home Manager extension imports", () => { - const rendered = renderExtensionNixAggregator(parseExtensionsConfig("subagent=true\nplannotator=true\n"), "homeManager"); - expect(rendered).toContain("../extensions/subagent/home-manager.nix"); - expect(rendered).toContain("../extensions/plannotator/home-manager.nix"); - expect(renderExtensionNixAggregator(parseExtensionsConfig("subagent=true\n"), "agentNixos")).not.toContain("../extensions/subagent"); - expect(renderExtensionNixAggregator(parseExtensionsConfig("plannotator=false\n"), "homeManager")).not.toContain("../extensions/plannotator/home-manager.nix"); + 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", () => { @@ -627,12 +633,12 @@ describe("rootcell extension Nix hooks", () => { try { const generatedDir = join(repo, "generated"); mkdirSync(generatedDir, { recursive: true }); - writeExtensionNixAggregators(generatedDir, parseExtensionsConfig("subagent=true\n")); + 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/subagent/home-manager.nix"); + expect(readFileSync(join(generatedDir, "extensions-home-manager.nix"), "utf8")).toContain("../extensions/pi-subagents/home-manager.nix"); } finally { rmSync(repo, { recursive: true, force: true }); } @@ -2286,7 +2292,7 @@ describe("rootcell edit command", () => { 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("plannotator=false\nsubagent=false\n"); + 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); @@ -2302,21 +2308,21 @@ describe("rootcell extension command", () => { try { const env = { ...process.env, ROOTCELL_STATE_DIR: join(repo, ".state") }; const list = runCapture("./rootcell", ["--instance", "dev", "extension", "list"], { env }); - expect(list.stdout).toContain("plannotator disabled"); - expect(list.stdout).toContain("subagent disabled"); - expect(readFileSync(join(repo, ".state", "dev", "extensions.txt"), "utf8")).toBe("plannotator=false\nsubagent=false\n"); + 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", "subagent"], { env }); - expect(enable.stdout).toContain("subagent enabled for instance 'dev'."); + 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("plannotator=false\nsubagent=true\n"); + 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", "subagent"], { env }); - expect(enableAgain.stdout).toContain("subagent already enabled for instance 'dev'."); + 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", "subagent"], { env }); - expect(disable.stdout).toContain("subagent disabled for instance 'dev'."); - expect(readFileSync(join(repo, ".state", "dev", "extensions.txt"), "utf8")).toBe("plannotator=false\nsubagent=false\n"); + 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); @@ -2331,10 +2337,10 @@ describe("rootcell extension command", () => { 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"), "plannotator=true\nsubagent=false\n", "utf8"); + 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("plannotator") ? "enabled" : "disabled"}`); + 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", { @@ -2351,7 +2357,7 @@ describe("rootcell extension command", () => { repoDir: repo, env, instanceName: "dev", - rest: ["plannotator", "check", "one", "two"], + rest: ["pi-plannotator", "check", "one", "two"], log: (message) => calls.push(`log:${message}`), extensions, createContext: ({ extension, command, extensionConfig }) => { @@ -2380,7 +2386,7 @@ describe("rootcell extension command", () => { expect(status).toBe(0); expect(calls).toEqual([ - "context:plannotator:check", + "context:pi-plannotator:check", "run:dev:one,two:enabled", "vmStatus:agent", "status:running", @@ -2409,7 +2415,7 @@ describe("rootcell extension command", () => { return { repoDir: repo, instanceName: "dev", - extensionConfig: parseExtensionsConfig("plannotator=true\n"), + extensionConfig: parseExtensionsConfig("pi-plannotator=true\n"), config: buildConfig(repo, env, fakeInstance("dev", repo, env)), log: ignoreLog, vmStatus: (role: VmRole) => { @@ -2428,24 +2434,24 @@ describe("rootcell extension command", () => { }, }; - expect(await runExtensionCommand({ ...input, rest: ["plannotator", "check"] })).toBe(1); - expect(logs.join("\n")).toContain("extension 'plannotator' is disabled"); + 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"), "plannotator=true\nsubagent=false\n", "utf8"); + 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: ["plannotator"] })).toBe(2); - expect(logs.join("\n")).toContain("usage: rootcell extension plannotator "); + 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: ["plannotator", "missing"] })).toBe(2); - expect(logs.join("\n")).toContain("unknown command for extension 'plannotator': 'missing'"); + 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 }); @@ -2525,7 +2531,7 @@ describe("shell completions", () => { const stateDir = join(repo, ".state"); const instanceDir = join(stateDir, "dev"); mkdirSync(instanceDir, { recursive: true }); - writeFileSync(join(instanceDir, "extensions.txt"), "plannotator=false\nsubagent=true\n", "utf8"); + writeFileSync(join(instanceDir, "extensions.txt"), "pi-plannotator=false\npi-subagents=true\n", "utf8"); const env = completionEnv("/bin/bash"); env.ROOTCELL_STATE_DIR = stateDir; @@ -2535,12 +2541,12 @@ describe("shell completions", () => { expect(root).toContain("disable\n"); const enable = runCapture("./rootcell", ["--get-yargs-completions", "rootcell", "--instance", "dev", "extension", "enable", ""], { env }).stdout; - expect(enable).toContain("plannotator\n"); - expect(enable).not.toContain("subagent\n"); + 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("subagent\n"); - expect(disable).not.toContain("plannotator\n"); + 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(""); @@ -2558,7 +2564,7 @@ describe("shell completions", () => { const env = { ...completionEnv("/bin/bash"), ROOTCELL_STATE_DIR: stateDir }; const extensions = testHostCommandExtensions(); mkdirSync(instanceDir, { recursive: true }); - writeFileSync(join(instanceDir, "extensions.txt"), "plannotator=true\nsubagent=false\n", "utf8"); + writeFileSync(join(instanceDir, "extensions.txt"), "pi-plannotator=true\npi-subagents=false\n", "utf8"); const root = completeExtensionCommand({ repoDir: repo, @@ -2569,13 +2575,13 @@ describe("shell completions", () => { extensions, }); expect(root).toContain("list"); - expect(root).toContain("plannotator"); + expect(root).toContain("pi-plannotator"); const commands = completeExtensionCommand({ repoDir: repo, env, instanceName: "dev", - words: ["extension", "plannotator", ""], + words: ["extension", "pi-plannotator", ""], current: "", extensions, }); @@ -2585,13 +2591,13 @@ describe("shell completions", () => { repoDir: repo, env, instanceName: "dev", - words: ["extension", "plannotator", "check", ""], + words: ["extension", "pi-plannotator", "check", ""], current: "", extensions, }); expect(commandArgs).toEqual(["alpha"]); - writeFileSync(join(instanceDir, "extensions.txt"), "plannotator=false\nsubagent=false\n", "utf8"); + writeFileSync(join(instanceDir, "extensions.txt"), "pi-plannotator=false\npi-subagents=false\n", "utf8"); const disabledRoot = completeExtensionCommand({ repoDir: repo, env, @@ -2600,7 +2606,7 @@ describe("shell completions", () => { current: "", extensions, }); - expect(disabledRoot).not.toContain("plannotator"); + expect(disabledRoot).not.toContain("pi-plannotator"); const missing = completeExtensionCommand({ repoDir: repo, @@ -2610,7 +2616,7 @@ describe("shell completions", () => { current: "", extensions, }); - expect(missing).not.toContain("plannotator"); + expect(missing).not.toContain("pi-plannotator"); expect(existsSync(join(stateDir, "new", "extensions.txt"))).toBe(false); } finally { rmSync(repo, { recursive: true, force: true }); @@ -2635,7 +2641,7 @@ function testHostCommandExtensions( ): readonly RootcellExtensionDefinition[] { return [ { - id: "plannotator", + id: "pi-plannotator", description: "test host command extension", requiresProvision: true, guestHooks: { @@ -2653,7 +2659,7 @@ function testHostCommandExtensions( ], }, { - id: "subagent", + id: "pi-subagents", description: "test extension without host commands", requiresProvision: true, guestHooks: { From 7e4ced302241d1858b8aef44742164d7d910f05c Mon Sep 17 00:00:00 2001 From: Jim Pudar Date: Tue, 26 May 2026 07:16:14 -0400 Subject: [PATCH 10/10] Add rootcell instance selection --- README.md | 53 +++++---- src/rootcell/args.ts | 132 +++++++++++++++++----- src/rootcell/instance.ts | 63 ++++++++++- src/rootcell/metadata.ts | 3 +- src/rootcell/rootcell.test.ts | 205 ++++++++++++++++++++++++++++++++-- src/rootcell/rootcell.ts | 102 ++++++++++++++--- src/rootcell/types.ts | 10 +- 7 files changed, 486 insertions(+), 82 deletions(-) diff --git a/README.md b/README.md index 9a59040..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,7 +212,8 @@ 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 @@ -229,10 +232,10 @@ state root. ./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 @@ -284,7 +287,7 @@ VM and configures Pi sessions for remote browser access. A typical workflow is: ./rootcell extension pi-plannotator tunnel # Terminal 2: start Pi normally. -./rootcell pi +./rootcell -- pi ``` The tunnel command requires `pi-plannotator=true` and a running agent VM. It @@ -339,9 +342,9 @@ 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 @@ -489,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 @@ -504,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 @@ -592,6 +597,8 @@ rootcell completion >> ~/.bashrc Named instances are isolated from each other: ```bash +./rootcell select dev +./rootcell ./rootcell --instance dev ./rootcell --instance review ``` @@ -599,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. @@ -614,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/src/rootcell/args.ts b/src/rootcell/args.ts index aac3401..81f66dd 100644 --- a/src/rootcell/args.ts +++ b/src/rootcell/args.ts @@ -2,12 +2,13 @@ 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, @@ -39,7 +40,11 @@ interface ExtensionArgs extends GlobalArgs { readonly extensionArgs?: readonly string[]; } -type ParserArgv = Argv; +interface SelectArgs extends GlobalArgs { + readonly selectedInstance?: string; +} + +type ParserArgv = Argv; function subcommandDescription(name: RootcellSubcommand): string { return ROOTCELL_SUBCOMMANDS.find((subcommand) => subcommand.name === name)?.description ?? ""; @@ -53,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[] { @@ -78,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, @@ -108,6 +113,12 @@ function completion( return; } + const selectCompletions = completeSelectCompletion(current, argv); + if (selectCompletions !== undefined) { + done([...selectCompletions]); + return; + } + const extensionCompletions = completeExtensionCompletion(current, argv); if (extensionCompletions !== undefined) { done([...extensionCompletions]); @@ -127,10 +138,9 @@ function completeExtensionCompletion( current: string, argv: ArgumentsCamelCase, ): readonly string[] | undefined { - const rawWords = argv._.map((value) => String(value)); - const words = rawWords[0] === "rootcell" ? rawWords.slice(1) : rawWords; - const instance = lastString(argv.instance) ?? "default"; + const words = rootcellWords(argv); try { + const instance = lastString(argv.instance) ?? readSelectedRootcellInstance(process.cwd(), process.env); return completeExtensionCommand({ repoDir: process.cwd(), env: process.env, @@ -143,7 +153,23 @@ function completeExtensionCompletion( } } -function createParser(args: readonly string[]): Argv { +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) @@ -153,12 +179,11 @@ function createParser(args: readonly string[]): Argv", + 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")) @@ -182,7 +218,7 @@ function createParser(args: readonly string[]): Argv", subcommandDescription("edit"), - (argv: ParserArgv) => argv + (argv: ParserArgv) => argv .positional("target", { choices: ["env", "http", "https", "dns", "ssh", "extensions"], describe: "instance file to edit", @@ -194,7 +230,7 @@ function createParser(args: readonly string[]): Argv) => argv + (argv: ParserArgv) => argv .positional("extensionArgs", { array: true, describe: "extension command and arguments", @@ -206,7 +242,7 @@ function createParser(args: readonly string[]): Argv) => 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", @@ -217,19 +253,16 @@ function createParser(args: readonly string[]): Argv) => 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") @@ -244,7 +277,7 @@ function createParser(args: readonly string[]): Argv; const firstToken = firstRootcellToken(args); if ( argv.help === true @@ -268,6 +301,19 @@ 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)] @@ -288,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"); } @@ -306,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/instance.ts b/src/rootcell/instance.ts index 5e7159e..8fe6f73 100644 --- a/src/rootcell/instance.ts +++ b/src/rootcell/instance.ts @@ -20,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])?$/; @@ -42,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)`); @@ -132,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")) @@ -187,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/metadata.ts b/src/rootcell/metadata.ts index 701291a..fcd14f5 100644 --- a/src/rootcell/metadata.ts +++ b/src/rootcell/metadata.ts @@ -1,9 +1,10 @@ export interface SubcommandMetadata { - readonly name: "provision" | "allow" | "pubkey" | "spy" | "list" | "stop" | "remove" | "edit" | "extension"; + 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" }, diff --git a/src/rootcell/rootcell.test.ts b/src/rootcell/rootcell.test.ts index 44210cf..76cad98 100644 --- a/src/rootcell/rootcell.test.ts +++ b/src/rootcell/rootcell.test.ts @@ -24,7 +24,16 @@ import { loadDotEnv, parseSecretMappings } from "./env.ts"; import { resolveHostTool } from "./host-tools.ts"; import { initRootcellInstanceEnv } from "./init-env.ts"; import { buildConfig, formatVmList, renderSpyEnv, rootcellMain, RootcellApp } from "./rootcell.ts"; -import { deriveVmNames, instancePaths, listRootcellVmInstanceNames, loadRootcellInstance, seedRootcellInstanceFiles } from "./instance.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"; @@ -61,7 +70,7 @@ import { import { chooseLocalPort } from "./tunnels.ts"; import { forgetKnownHost, ProxyJumpSshTransport, sshConfig } from "./transports/proxyjump-ssh.ts"; import { dnsmasqAllowlistConfig, generatedLineCount } from "../bin/reload.ts"; -import { chmodSync, existsSync, 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 { @@ -273,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", () => { @@ -306,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", @@ -315,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", () => { @@ -2025,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"); }); }); @@ -2090,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 { @@ -2238,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; @@ -2492,6 +2603,49 @@ describe("rootcell edit env 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"); @@ -2534,12 +2688,18 @@ describe("shell completions", () => { 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"); @@ -2551,6 +2711,27 @@ describe("shell completions", () => { 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 }); } diff --git a/src/rootcell/rootcell.ts b/src/rootcell/rootcell.ts index 036dfec..fb012fb 100644 --- a/src/rootcell/rootcell.ts +++ b/src/rootcell/rootcell.ts @@ -20,7 +20,10 @@ 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"; @@ -94,6 +97,13 @@ 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; } @@ -102,16 +112,40 @@ 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 { @@ -1119,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; } @@ -1139,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; } @@ -1266,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); @@ -1279,43 +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: parsed.instanceName, + instanceName, rest: parsed.rest, log, createContext: ({ extensionConfig }) => extensionHostCommandContext( repoDir, process.env, - parsed.instanceName, + 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/types.ts b/src/rootcell/types.ts index db02a5a..ef03509 100644 --- a/src/rootcell/types.ts +++ b/src/rootcell/types.ts @@ -139,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),