diff --git a/.changeset/enclave-allow-empty-packages.md b/.changeset/enclave-allow-empty-packages.md new file mode 100644 index 0000000..6cc6e5b --- /dev/null +++ b/.changeset/enclave-allow-empty-packages.md @@ -0,0 +1,5 @@ +--- +"pi-enclave": minor +--- + +Allow empty package lists in config (removes hardcoded default packages). diff --git a/.changeset/enclave-custom-image.md b/.changeset/enclave-custom-image.md new file mode 100644 index 0000000..588b76e --- /dev/null +++ b/.changeset/enclave-custom-image.md @@ -0,0 +1,5 @@ +--- +"pi-enclave": minor +--- + +Support custom Gondolin image tags via `image` config option. diff --git a/packages/enclave/README.md b/packages/enclave/README.md index 0f02ca4..4a5ff34 100644 --- a/packages/enclave/README.md +++ b/packages/enclave/README.md @@ -79,6 +79,15 @@ USER_EMAIL = { command = "git config --global user.email" } ## Configuration +### Image + +Use a [custom Gondolin image](https://earendil-works.github.io/gondolin/custom-images/) when you need a different base environment or a larger root filesystem. +Build and tag the image separately, then reference it here: + +```toml +image = "pi-enclave-large:latest" +``` + ### Env vars Non-secret values available in the VM and setup scripts. Three source types: @@ -154,7 +163,7 @@ mounts = [ ### Config layering -Two locations: global (`~/.pi/agent/extensions/pi-enclave.toml` + drop-ins) and project (`.pi/enclave.toml`). Project overrides global. Packages accumulate across all layers; secrets, hosts, and env merge by key (later wins). +Two locations: global (`~/.pi/agent/extensions/pi-enclave.toml` + drop-ins) and project (`.pi/enclave.toml`). Project overrides global. `image` uses the last configured value. Packages accumulate across all layers; secrets, hosts, and env merge by key (later wins). ```toml # .pi/enclave.toml — allow all GitHub operations in this project diff --git a/packages/enclave/src/config.ts b/packages/enclave/src/config.ts index f0081fe..58571b2 100644 --- a/packages/enclave/src/config.ts +++ b/packages/enclave/src/config.ts @@ -110,6 +110,7 @@ export type GitCredentialDef = v.InferOutput; /** Top-level enclave config file schema. */ export const EnclaveFileConfig = v.object({ enabled: v.optional(v.boolean()), + image: v.optional(v.string()), packages: v.optional(v.array(v.string())), mounts: v.optional(v.array(MountDef), []), env: v.optional(v.record(v.string(), EnvDef), {}), @@ -144,12 +145,6 @@ export interface ResolvedHostPolicy { graphql?: ResolvedGraphQLPolicy; } -// --------------------------------------------------------------------------- -// Default packages -// --------------------------------------------------------------------------- - -export const DEFAULT_PACKAGES = ["git", "curl", "jq"]; - // --------------------------------------------------------------------------- // Config loading // --------------------------------------------------------------------------- @@ -224,6 +219,7 @@ export function collectConfigFiles(cwd: string): { path: string; config: Enclave export function mergeConfigs(layers: EnclaveFileConfig[]): EnclaveFileConfig { const merged: EnclaveFileConfig = { enabled: undefined, + image: undefined, packages: [], mounts: [], env: {}, @@ -242,6 +238,9 @@ export function mergeConfigs(layers: EnclaveFileConfig[]): EnclaveFileConfig { if (layer.enabled !== undefined) { merged.enabled = layer.enabled; } + if (layer.image !== undefined) { + merged.image = layer.image; + } if (layer.packages) { for (const pkg of layer.packages) { if (!merged.packages!.includes(pkg)) { @@ -442,7 +441,8 @@ export function initProjectConfig(cwd: string): boolean { } /** - * Add a package to a config file. Creates the file if needed. + * Persist a package in config so future enclave starts install it automatically. + * Creates the config file if needed. */ export function addPackageToConfig(cwd: string, pkg: string, target: "project" | "global"): void { const configPath = target === "global" ? globalConfigPath() : projectConfigPath(cwd); @@ -457,7 +457,7 @@ export function addPackageToConfig(cwd: string, pkg: string, target: "project" | // File doesn't exist or is invalid, start fresh } - const packages = existing.packages ?? [...DEFAULT_PACKAGES]; + const packages = existing.packages ?? []; if (!packages.includes(pkg)) { packages.push(pkg); } diff --git a/packages/enclave/src/index.ts b/packages/enclave/src/index.ts index f7d807d..26b2997 100644 --- a/packages/enclave/src/index.ts +++ b/packages/enclave/src/index.ts @@ -13,7 +13,6 @@ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-age import { createBashTool, createEditTool, createReadTool, createWriteTool } from "@mariozechner/pi-coding-agent"; import { - DEFAULT_PACKAGES, addPackageToConfig, ensureGlobalConfig, globalConfigPath, @@ -167,7 +166,8 @@ export default function (pi: ExtensionAPI) { // ----------------------------------------------------------------------- const localCwd = process.cwd(); const { merged, policies, hasGlobalConfig, hasProjectConfig, dropIns } = loadConfig(localCwd); - const packages = merged.packages?.length ? merged.packages : DEFAULT_PACKAGES; + const image = merged.image; + const packages = merged.packages ?? []; const extraMounts = merged.mounts ?? []; const gitCredentials = merged["git-credentials"] ?? []; // Network allowlist is derived from secret hosts (Gondolin builds the allowlist) @@ -236,6 +236,7 @@ export default function (pi: ExtensionAPI) { const instance = new EnclaveVM({ workspaceDir: localCwd, + image, packages, extraMounts, secrets, diff --git a/packages/enclave/src/vm.ts b/packages/enclave/src/vm.ts index 3b40f68..0ad5705 100644 --- a/packages/enclave/src/vm.ts +++ b/packages/enclave/src/vm.ts @@ -32,6 +32,8 @@ export interface ExtraMount { export interface EnclaveVMOptions { /** Host directory to mount inside the VM */ workspaceDir: string; + /** Tagged Gondolin image or asset path to boot */ + image: string | undefined; /** Alpine packages to install */ packages: string[]; /** Additional directories to mount in the VM */ @@ -232,6 +234,7 @@ export class EnclaveVM { // Create and start VM this.vm = await VM.create({ + sandbox: this.options.image ? { imagePath: this.options.image } : undefined, httpHooks, env: { ...env, diff --git a/packages/enclave/templates/pi-enclave.toml b/packages/enclave/templates/pi-enclave.toml index 04c1fd5..88a7df1 100644 --- a/packages/enclave/templates/pi-enclave.toml +++ b/packages/enclave/templates/pi-enclave.toml @@ -4,8 +4,12 @@ # Default for projects without their own .pi/enclave.toml: # enabled = false +# Optional custom Gondolin image tag or asset path. +# Build a larger image separately if you need more disk space. +# image = "pi-enclave-large:latest" + # Base packages (drop-in files in pi-enclave.d/ add more): -packages = ["curl", "jq"] +packages = ["git", "curl", "jq"] # Environment variables available in the VM and setup scripts. # Values can be static strings, host commands, or host env vars. diff --git a/packages/enclave/test/config.test.ts b/packages/enclave/test/config.test.ts index 2bb1c80..4fdc800 100644 --- a/packages/enclave/test/config.test.ts +++ b/packages/enclave/test/config.test.ts @@ -1,6 +1,6 @@ import * as v from "valibot"; import { describe, expect, it } from "vitest"; -import { DEFAULT_PACKAGES, EnclaveFileConfig, mergeConfigs, resolveHostPolicies } from "../src/config.js"; +import { EnclaveFileConfig, mergeConfigs, resolveHostPolicies } from "../src/config.js"; describe("EnclaveFileConfig schema", () => { it("accepts empty config", () => { @@ -315,11 +315,3 @@ describe("resolveHostPolicies", () => { expect(policies.get("example.com")!.unmatched).toBe("allow"); }); }); - -describe("DEFAULT_PACKAGES", () => { - it("includes git, curl, jq", () => { - expect(DEFAULT_PACKAGES).toContain("git"); - expect(DEFAULT_PACKAGES).toContain("curl"); - expect(DEFAULT_PACKAGES).toContain("jq"); - }); -}); diff --git a/packages/enclave/test/integration.test.ts b/packages/enclave/test/integration.test.ts index ab72eb4..25cc512 100644 --- a/packages/enclave/test/integration.test.ts +++ b/packages/enclave/test/integration.test.ts @@ -120,6 +120,17 @@ describe("collectConfigFiles with drop-ins", () => { expect(result.dropIns).toEqual(["git", "github", "jj"]); }); + it("uses the last configured image", () => { + ensureGlobalConfig(); + writeFileSync(globalConfigPath(), 'image = "global:latest"\n'); + const projectDir = join(tmpDir, "project"); + mkdirSync(join(projectDir, ".pi"), { recursive: true }); + writeFileSync(join(projectDir, ".pi", "enclave.toml"), 'enabled = true\nimage = "project:latest"\n'); + + const { merged } = loadConfig(projectDir); + expect(merged.image).toBe("project:latest"); + }); + it("does not walk ancestor directories", () => { ensureGlobalConfig(); const parent = join(tmpDir, "parent"); @@ -151,6 +162,16 @@ describe("addPackageToConfig", () => { const matches = content.match(/curl/g); expect(matches).toHaveLength(1); }); + + it("does not seed project config with hardcoded packages", () => { + const projectDir = join(tmpDir, "project"); + addPackageToConfig(projectDir, "ripgrep", "project"); + const content = readFileSync(join(projectDir, ".pi", "enclave.toml"), "utf-8"); + expect(content).toContain('packages = ["ripgrep"]'); + expect(content).not.toContain("curl"); + expect(content).not.toContain("jq"); + expect(content).not.toContain("git"); + }); }); describe("mount path resolution", () => {