From 8e830d790547efd94c0d34150f04437313b2a869 Mon Sep 17 00:00:00 2001 From: Jim Pudar Date: Wed, 20 May 2026 08:30:34 -0400 Subject: [PATCH] Add rootcell edit commands for instance allowlists --- README.md | 9 ++-- src/rootcell/args.ts | 21 +++++++++- src/rootcell/metadata.ts | 3 +- src/rootcell/rootcell.test.ts | 79 ++++++++++++++++++++++++++++++++++- src/rootcell/rootcell.ts | 36 ++++++++++++++++ 5 files changed, 142 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 62980fc..5fe69d3 100644 --- a/README.md +++ b/README.md @@ -182,6 +182,9 @@ nixos-lima guest contract while avoiding the template's default host mounts. ./rootcell # open a bash shell inside the agent VM ./rootcell pi # run pi directly ./rootcell -- nix flake update # run any command inside the agent VM +./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 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 @@ -193,6 +196,7 @@ nixos-lima guest contract while avoiding the template's default host mounts. ./rootcell spy --tui # browse Bedrock Runtime traffic interactively ./rootcell --instance dev # open the dev instance shell +./rootcell --instance dev edit dns # edit the dev instance DNS allowlist ./rootcell --instance dev allow # reload only the dev instance allowlists ``` @@ -208,9 +212,8 @@ Network policy is per instance. On first run, `./rootcell` copies each tracked For most HTTPS access, add the host to both DNS and HTTPS, then reload: ```bash -INSTANCE_DIR="${ROOTCELL_STATE_DIR:-$PWD/instances}/default" -$EDITOR "$INSTANCE_DIR/proxy/allowed-dns.txt" -$EDITOR "$INSTANCE_DIR/proxy/allowed-https.txt" +./rootcell edit dns +./rootcell edit http ./rootcell allow ``` diff --git a/src/rootcell/args.ts b/src/rootcell/args.ts index 5a774d8..723e918 100644 --- a/src/rootcell/args.ts +++ b/src/rootcell/args.ts @@ -28,6 +28,10 @@ interface SpyArgs extends GlobalArgs { readonly tui?: boolean; } +interface EditArgs extends GlobalArgs { + readonly allowlist?: string; +} + type ParserArgv = Argv; function subcommandDescription(name: RootcellSubcommand): string { @@ -136,6 +140,18 @@ function createParser(args: readonly string[]): Argv { .command(...rootcellSubcommand("list")) .command(...rootcellSubcommand("stop")) .command(...rootcellSubcommand("remove")) + .command( + "edit ", + subcommandDescription("edit"), + (argv: ParserArgv) => argv + .positional("allowlist", { + choices: ["http", "https", "dns", "ssh"], + describe: "allowlist to edit", + type: "string", + }) + .demandCommand(0, 0) + .strictOptions(), + ) .command( "spy", subcommandDescription("spy"), @@ -171,6 +187,8 @@ function createParser(args: readonly string[]): Argv { .example("$0", "open an interactive shell inside the agent VM") .example("$0 pi", "run pi inside the agent VM") .example("$0 -- nix flake update", "run any command inside the agent VM") + .example("$0 edit http", "edit the HTTPS allowlist for the default 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 list", "list rootcell VMs and their current state") .example("$0 stop --instance dev", "stop the dev instance VMs") @@ -195,11 +213,12 @@ export function parseRootcellArgs(args: readonly string[]): ParsedRootcellArgs { const subcommand = parsedSubcommand(argv); if (subcommand !== undefined) { + const rest = subcommand === "edit" ? [argString((argv as ArgumentsCamelCase).allowlist)] : []; return parseSchema(ParsedRootcellRunArgsSchema, { kind: "run", instanceName: instanceName(argv), subcommand, - rest: [], + rest, spyOptions: subcommand === "spy" ? parseSchema(SpyOptionsSchema, { raw: argv.raw ?? false, diff --git a/src/rootcell/metadata.ts b/src/rootcell/metadata.ts index 2d19f18..bd0ea92 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"; + readonly name: "provision" | "allow" | "pubkey" | "spy" | "list" | "stop" | "remove" | "edit"; readonly description: string; } @@ -7,6 +7,7 @@ export const ROOTCELL_SUBCOMMANDS: readonly SubcommandMetadata[] = [ { name: "list", description: "list rootcell VMs and their current state" }, { name: "stop", description: "stop the selected rootcell instance VMs" }, { name: "remove", description: "stop the selected instance and delete VM state" }, + { name: "edit", description: "open an allowlist in $EDITOR" }, { 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 e4d7083..00c6c9a 100644 --- a/src/rootcell/rootcell.test.ts +++ b/src/rootcell/rootcell.test.ts @@ -4,7 +4,7 @@ import { parseRootcellArgs } from "./args.ts"; import { ROOTCELL_SUBCOMMANDS } from "./metadata.ts"; import { loadDotEnv, parseSecretMappings } from "./env.ts"; import { resolveHostTool } from "./host-tools.ts"; -import { buildConfig, formatVmList } from "./rootcell.ts"; +import { buildConfig, formatVmList, rootcellMain } from "./rootcell.ts"; import { deriveVmNames, instancePaths, listRootcellVmInstanceNames, loadRootcellInstance, seedRootcellInstanceFiles } from "./instance.ts"; import { runCapture } from "./process.ts"; import { createProviderBundle } from "./providers/factory.ts"; @@ -105,6 +105,41 @@ describe("rootcell argument parsing", () => { }); }); + test("parses edit allowlist subcommands", () => { + const http = runArgs(["edit", "http"]); + expectRunArgs(http); + expect(http).toEqual({ + kind: "run", + instanceName: "default", + subcommand: "edit", + rest: ["http"], + spyOptions: { raw: false, dedupe: true, tui: false }, + }); + + const dns = runArgs(["--instance", "dev", "edit", "dns"]); + expectRunArgs(dns); + expect(dns).toEqual({ + kind: "run", + instanceName: "dev", + subcommand: "edit", + rest: ["dns"], + spyOptions: { raw: false, dedupe: true, tui: false }, + }); + + const ssh = runArgs(["edit", "ssh", "-i", "dev"]); + expectRunArgs(ssh); + expect(ssh).toEqual({ + kind: "run", + instanceName: "dev", + subcommand: "edit", + rest: ["ssh"], + spyOptions: { raw: false, dedupe: true, tui: false }, + }); + + expect(() => parseRootcellArgs(["edit"])).toThrow(); + expect(() => parseRootcellArgs(["edit", "smtp"])).toThrow(); + }); + test("parses pass-through guest commands", () => { const explicit = runArgs(["--", "nix", "flake", "update"]); expectRunArgs(explicit); @@ -1117,6 +1152,40 @@ describe("instance state", () => { }); }); +describe("rootcell edit command", () => { + test("opens the selected instance allowlist 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, "completions"), { recursive: true }); + 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", "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")); + expect(readFileSync(join(repo, ".state", "dev", "proxy", "allowed-dns.txt"), "utf8")).toBe("\n"); + } finally { + restoreEnv("ROOTCELL_STATE_DIR", oldRootcellStateDir); + restoreEnv("EDITOR", oldEditor); + restoreEnv("ROOTCELL_EDITOR_RECORD", oldRecord); + 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"); @@ -1213,6 +1282,14 @@ function makeInstanceRepo(): string { return repo; } +function restoreEnv(name: string, value: string | undefined): void { + if (value === undefined) { + Reflect.deleteProperty(process.env, name); + return; + } + process.env[name] = value; +} + function stateJson(name: string, prefix: string): string { return `${JSON.stringify({ schemaVersion: 1, diff --git a/src/rootcell/rootcell.ts b/src/rootcell/rootcell.ts index 40ac1ff..82d4256 100644 --- a/src/rootcell/rootcell.ts +++ b/src/rootcell/rootcell.ts @@ -27,6 +27,13 @@ import { RootcellConfigSchema, type RootcellConfig, type RootcellInstance, type const GUEST_USER = "luser"; +const EDIT_ALLOWLIST_FILES = { + http: "allowed-https.txt", + https: "allowed-https.txt", + dns: "allowed-dns.txt", + ssh: "allowed-ssh.txt", +} as const; + const VM_FILES: VmFileSet = { agent: [ "flake.nix", @@ -889,6 +896,32 @@ async function runLifecycleCommand( return 0; } +function runEditCommand( + repoDir: string, + env: NodeJS.ProcessEnv, + instanceName: string, + editTarget: string | undefined, +): number { + const file = editTarget === undefined ? undefined : EDIT_ALLOWLIST_FILES[editTarget as keyof typeof EDIT_ALLOWLIST_FILES]; + if (file === undefined) { + log(`unknown allowlist '${editTarget ?? ""}' (expected http, dns, or ssh)`); + return 2; + } + const editor = env.EDITOR; + if (editor === undefined || editor.length === 0) { + log("EDITOR is not set; set EDITOR to edit allowlists."); + return 1; + } + + seedRootcellInstanceFiles(repoDir, instanceName, log, env); + const path = join(instancePaths(repoDir, instanceName, env).proxyDir, file); + log(`opening ${path}`); + return runInherited("sh", ["-c", "exec $EDITOR \"$1\"", "sh", path], { + allowFailure: true, + env, + }).status; +} + function missingVmEntries(instanceName: string): readonly VmListEntry[] { const vmNames = deriveVmNames(instanceName); return [ @@ -918,6 +951,9 @@ export async function rootcellMain(args: readonly string[], importMetaPath: stri if (parsed.subcommand === "stop" || parsed.subcommand === "remove") { return await runLifecycleCommand(repoDir, process.env, parsed.subcommand, parsed.instanceName); } + if (parsed.subcommand === "edit") { + return runEditCommand(repoDir, process.env, parsed.instanceName, parsed.rest[0]); + } seedRootcellInstanceFiles(repoDir, parsed.instanceName, log); loadDotEnv(instancePaths(repoDir, parsed.instanceName, process.env).envPath, process.env);