Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
```

Expand All @@ -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
```

Expand Down
21 changes: 20 additions & 1 deletion src/rootcell/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ interface SpyArgs extends GlobalArgs {
readonly tui?: boolean;
}

interface EditArgs extends GlobalArgs {
readonly allowlist?: string;
}

type ParserArgv<T> = Argv<T>;

function subcommandDescription(name: RootcellSubcommand): string {
Expand Down Expand Up @@ -136,6 +140,18 @@ function createParser(args: readonly string[]): Argv<GuestArgs & SpyArgs> {
.command(...rootcellSubcommand("list"))
.command(...rootcellSubcommand("stop"))
.command(...rootcellSubcommand("remove"))
.command(
"edit <allowlist>",
subcommandDescription("edit"),
(argv: ParserArgv<EditArgs>) => argv
.positional("allowlist", {
choices: ["http", "https", "dns", "ssh"],
describe: "allowlist to edit",
type: "string",
})
.demandCommand(0, 0)
.strictOptions(),
)
.command(
"spy",
subcommandDescription("spy"),
Expand Down Expand Up @@ -171,6 +187,8 @@ function createParser(args: readonly string[]): Argv<GuestArgs & SpyArgs> {
.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")
Expand All @@ -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<EditArgs>).allowlist)] : [];
return parseSchema(ParsedRootcellRunArgsSchema, {
kind: "run",
instanceName: instanceName(argv),
subcommand,
rest: [],
rest,
spyOptions: subcommand === "spy"
? parseSchema(SpyOptionsSchema, {
raw: argv.raw ?? false,
Expand Down
3 changes: 2 additions & 1 deletion src/rootcell/metadata.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
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;
}

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" },
Expand Down
79 changes: 78 additions & 1 deletion src/rootcell/rootcell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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,
Expand Down
36 changes: 36 additions & 0 deletions src/rootcell/rootcell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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 [
Expand Down Expand Up @@ -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);
Expand Down