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
5 changes: 5 additions & 0 deletions .changeset/enclave-allow-empty-packages.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"pi-enclave": minor
---

Allow empty package lists in config (removes hardcoded default packages).
5 changes: 5 additions & 0 deletions .changeset/enclave-custom-image.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"pi-enclave": minor
---

Support custom Gondolin image tags via `image` config option.
11 changes: 10 additions & 1 deletion packages/enclave/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
16 changes: 8 additions & 8 deletions packages/enclave/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ export type GitCredentialDef = v.InferOutput<typeof GitCredentialDef>;
/** 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), {}),
Expand Down Expand Up @@ -144,12 +145,6 @@ export interface ResolvedHostPolicy {
graphql?: ResolvedGraphQLPolicy;
}

// ---------------------------------------------------------------------------
// Default packages
// ---------------------------------------------------------------------------

export const DEFAULT_PACKAGES = ["git", "curl", "jq"];

// ---------------------------------------------------------------------------
// Config loading
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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: {},
Expand All @@ -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)) {
Expand Down Expand Up @@ -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);
Expand All @@ -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);
}
Expand Down
5 changes: 3 additions & 2 deletions packages/enclave/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -236,6 +236,7 @@ export default function (pi: ExtensionAPI) {

const instance = new EnclaveVM({
workspaceDir: localCwd,
image,
packages,
extraMounts,
secrets,
Expand Down
3 changes: 3 additions & 0 deletions packages/enclave/src/vm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 5 additions & 1 deletion packages/enclave/templates/pi-enclave.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 1 addition & 9 deletions packages/enclave/test/config.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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");
});
});
21 changes: 21 additions & 0 deletions packages/enclave/test/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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", () => {
Expand Down
Loading