Skip to content

Commit 523e9e9

Browse files
authored
feat(pi-enclave): support custom gondolin image tags (#39)
Adds an `image` config option that lets pi-enclave boot a custom Gondolin image (tag or asset path), and removes the hardcoded default package list so `packages` can be empty. Closes #38
1 parent a7b0107 commit 523e9e9

9 files changed

Lines changed: 61 additions & 21 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"pi-enclave": minor
3+
---
4+
5+
Allow empty package lists in config (removes hardcoded default packages).

.changeset/enclave-custom-image.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"pi-enclave": minor
3+
---
4+
5+
Support custom Gondolin image tags via `image` config option.

packages/enclave/README.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,15 @@ USER_EMAIL = { command = "git config --global user.email" }
7979

8080
## Configuration
8181

82+
### Image
83+
84+
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.
85+
Build and tag the image separately, then reference it here:
86+
87+
```toml
88+
image = "pi-enclave-large:latest"
89+
```
90+
8291
### Env vars
8392

8493
Non-secret values available in the VM and setup scripts. Three source types:
@@ -154,7 +163,7 @@ mounts = [
154163

155164
### Config layering
156165

157-
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).
166+
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).
158167

159168
```toml
160169
# .pi/enclave.toml — allow all GitHub operations in this project

packages/enclave/src/config.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ export type GitCredentialDef = v.InferOutput<typeof GitCredentialDef>;
110110
/** Top-level enclave config file schema. */
111111
export const EnclaveFileConfig = v.object({
112112
enabled: v.optional(v.boolean()),
113+
image: v.optional(v.string()),
113114
packages: v.optional(v.array(v.string())),
114115
mounts: v.optional(v.array(MountDef), []),
115116
env: v.optional(v.record(v.string(), EnvDef), {}),
@@ -144,12 +145,6 @@ export interface ResolvedHostPolicy {
144145
graphql?: ResolvedGraphQLPolicy;
145146
}
146147

147-
// ---------------------------------------------------------------------------
148-
// Default packages
149-
// ---------------------------------------------------------------------------
150-
151-
export const DEFAULT_PACKAGES = ["git", "curl", "jq"];
152-
153148
// ---------------------------------------------------------------------------
154149
// Config loading
155150
// ---------------------------------------------------------------------------
@@ -224,6 +219,7 @@ export function collectConfigFiles(cwd: string): { path: string; config: Enclave
224219
export function mergeConfigs(layers: EnclaveFileConfig[]): EnclaveFileConfig {
225220
const merged: EnclaveFileConfig = {
226221
enabled: undefined,
222+
image: undefined,
227223
packages: [],
228224
mounts: [],
229225
env: {},
@@ -242,6 +238,9 @@ export function mergeConfigs(layers: EnclaveFileConfig[]): EnclaveFileConfig {
242238
if (layer.enabled !== undefined) {
243239
merged.enabled = layer.enabled;
244240
}
241+
if (layer.image !== undefined) {
242+
merged.image = layer.image;
243+
}
245244
if (layer.packages) {
246245
for (const pkg of layer.packages) {
247246
if (!merged.packages!.includes(pkg)) {
@@ -442,7 +441,8 @@ export function initProjectConfig(cwd: string): boolean {
442441
}
443442

444443
/**
445-
* Add a package to a config file. Creates the file if needed.
444+
* Persist a package in config so future enclave starts install it automatically.
445+
* Creates the config file if needed.
446446
*/
447447
export function addPackageToConfig(cwd: string, pkg: string, target: "project" | "global"): void {
448448
const configPath = target === "global" ? globalConfigPath() : projectConfigPath(cwd);
@@ -457,7 +457,7 @@ export function addPackageToConfig(cwd: string, pkg: string, target: "project" |
457457
// File doesn't exist or is invalid, start fresh
458458
}
459459

460-
const packages = existing.packages ?? [...DEFAULT_PACKAGES];
460+
const packages = existing.packages ?? [];
461461
if (!packages.includes(pkg)) {
462462
packages.push(pkg);
463463
}

packages/enclave/src/index.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-age
1313
import { createBashTool, createEditTool, createReadTool, createWriteTool } from "@mariozechner/pi-coding-agent";
1414

1515
import {
16-
DEFAULT_PACKAGES,
1716
addPackageToConfig,
1817
ensureGlobalConfig,
1918
globalConfigPath,
@@ -167,7 +166,8 @@ export default function (pi: ExtensionAPI) {
167166
// -----------------------------------------------------------------------
168167
const localCwd = process.cwd();
169168
const { merged, policies, hasGlobalConfig, hasProjectConfig, dropIns } = loadConfig(localCwd);
170-
const packages = merged.packages?.length ? merged.packages : DEFAULT_PACKAGES;
169+
const image = merged.image;
170+
const packages = merged.packages ?? [];
171171
const extraMounts = merged.mounts ?? [];
172172
const gitCredentials = merged["git-credentials"] ?? [];
173173
// Network allowlist is derived from secret hosts (Gondolin builds the allowlist)
@@ -236,6 +236,7 @@ export default function (pi: ExtensionAPI) {
236236

237237
const instance = new EnclaveVM({
238238
workspaceDir: localCwd,
239+
image,
239240
packages,
240241
extraMounts,
241242
secrets,

packages/enclave/src/vm.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ export interface ExtraMount {
3232
export interface EnclaveVMOptions {
3333
/** Host directory to mount inside the VM */
3434
workspaceDir: string;
35+
/** Tagged Gondolin image or asset path to boot */
36+
image: string | undefined;
3537
/** Alpine packages to install */
3638
packages: string[];
3739
/** Additional directories to mount in the VM */
@@ -232,6 +234,7 @@ export class EnclaveVM {
232234

233235
// Create and start VM
234236
this.vm = await VM.create({
237+
sandbox: this.options.image ? { imagePath: this.options.image } : undefined,
235238
httpHooks,
236239
env: {
237240
...env,

packages/enclave/templates/pi-enclave.toml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,12 @@
44
# Default for projects without their own .pi/enclave.toml:
55
# enabled = false
66

7+
# Optional custom Gondolin image tag or asset path.
8+
# Build a larger image separately if you need more disk space.
9+
# image = "pi-enclave-large:latest"
10+
711
# Base packages (drop-in files in pi-enclave.d/ add more):
8-
packages = ["curl", "jq"]
12+
packages = ["git", "curl", "jq"]
913

1014
# Environment variables available in the VM and setup scripts.
1115
# Values can be static strings, host commands, or host env vars.

packages/enclave/test/config.test.ts

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import * as v from "valibot";
22
import { describe, expect, it } from "vitest";
3-
import { DEFAULT_PACKAGES, EnclaveFileConfig, mergeConfigs, resolveHostPolicies } from "../src/config.js";
3+
import { EnclaveFileConfig, mergeConfigs, resolveHostPolicies } from "../src/config.js";
44

55
describe("EnclaveFileConfig schema", () => {
66
it("accepts empty config", () => {
@@ -315,11 +315,3 @@ describe("resolveHostPolicies", () => {
315315
expect(policies.get("example.com")!.unmatched).toBe("allow");
316316
});
317317
});
318-
319-
describe("DEFAULT_PACKAGES", () => {
320-
it("includes git, curl, jq", () => {
321-
expect(DEFAULT_PACKAGES).toContain("git");
322-
expect(DEFAULT_PACKAGES).toContain("curl");
323-
expect(DEFAULT_PACKAGES).toContain("jq");
324-
});
325-
});

packages/enclave/test/integration.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,17 @@ describe("collectConfigFiles with drop-ins", () => {
120120
expect(result.dropIns).toEqual(["git", "github", "jj"]);
121121
});
122122

123+
it("uses the last configured image", () => {
124+
ensureGlobalConfig();
125+
writeFileSync(globalConfigPath(), 'image = "global:latest"\n');
126+
const projectDir = join(tmpDir, "project");
127+
mkdirSync(join(projectDir, ".pi"), { recursive: true });
128+
writeFileSync(join(projectDir, ".pi", "enclave.toml"), 'enabled = true\nimage = "project:latest"\n');
129+
130+
const { merged } = loadConfig(projectDir);
131+
expect(merged.image).toBe("project:latest");
132+
});
133+
123134
it("does not walk ancestor directories", () => {
124135
ensureGlobalConfig();
125136
const parent = join(tmpDir, "parent");
@@ -151,6 +162,16 @@ describe("addPackageToConfig", () => {
151162
const matches = content.match(/curl/g);
152163
expect(matches).toHaveLength(1);
153164
});
165+
166+
it("does not seed project config with hardcoded packages", () => {
167+
const projectDir = join(tmpDir, "project");
168+
addPackageToConfig(projectDir, "ripgrep", "project");
169+
const content = readFileSync(join(projectDir, ".pi", "enclave.toml"), "utf-8");
170+
expect(content).toContain('packages = ["ripgrep"]');
171+
expect(content).not.toContain("curl");
172+
expect(content).not.toContain("jq");
173+
expect(content).not.toContain("git");
174+
});
154175
});
155176

156177
describe("mount path resolution", () => {

0 commit comments

Comments
 (0)