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
25 changes: 25 additions & 0 deletions packages/providers/src/community/pi/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,29 @@ describe('parsePiConfig', () => {
expect(() => parsePiConfig({ model: null })).not.toThrow();
expect(() => parsePiConfig({ model: [] })).not.toThrow();
});

test('parses enableExtensions: true', () => {
expect(parsePiConfig({ enableExtensions: true })).toEqual({
enableExtensions: true,
});
});

test('parses enableExtensions: false', () => {
expect(parsePiConfig({ enableExtensions: false })).toEqual({
enableExtensions: false,
});
});

test('drops non-boolean enableExtensions silently', () => {
expect(parsePiConfig({ enableExtensions: 'yes' })).toEqual({});
expect(parsePiConfig({ enableExtensions: 1 })).toEqual({});
expect(parsePiConfig({ enableExtensions: null })).toEqual({});
});

test('combines model and enableExtensions', () => {
expect(parsePiConfig({ model: 'google/gemini-2.5-pro', enableExtensions: true })).toEqual({
model: 'google/gemini-2.5-pro',
enableExtensions: true,
});
});
});
4 changes: 4 additions & 0 deletions packages/providers/src/community/pi/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,9 @@ export function parsePiConfig(raw: Record<string, unknown>): PiProviderDefaults
result.model = raw.model;
}

if (typeof raw.enableExtensions === 'boolean') {
result.enableExtensions = raw.enableExtensions;
}

return result;
}
55 changes: 55 additions & 0 deletions packages/providers/src/community/pi/provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -909,6 +909,61 @@ describe('PiProvider', () => {
expect(caps.hooks).toBe(false);
});

test('extensions are suppressed by default (noExtensions: true)', async () => {
process.env.GEMINI_API_KEY = 'sk-test';
resetScript(scriptedAgentEnd());

await consume(
new PiProvider().sendQuery('hi', '/tmp', undefined, {
model: 'google/gemini-2.5-pro',
})
);

const loaderArgs = MockDefaultResourceLoader.mock.calls[0]?.[0] as
| Record<string, unknown>
| undefined;
expect(loaderArgs?.noExtensions).toBe(true);
});

test('assistantConfig.enableExtensions: true flips noExtensions to false', async () => {
process.env.GEMINI_API_KEY = 'sk-test';
resetScript(scriptedAgentEnd());

await consume(
new PiProvider().sendQuery('hi', '/tmp', undefined, {
model: 'google/gemini-2.5-pro',
assistantConfig: { enableExtensions: true },
})
);

const loaderArgs = MockDefaultResourceLoader.mock.calls[0]?.[0] as
| Record<string, unknown>
| undefined;
expect(loaderArgs?.noExtensions).toBe(false);
// Skills/prompts/themes/context still suppressed — only extensions opt-in.
expect(loaderArgs?.noSkills).toBe(true);
expect(loaderArgs?.noPromptTemplates).toBe(true);
expect(loaderArgs?.noThemes).toBe(true);
expect(loaderArgs?.noContextFiles).toBe(true);
});

test('assistantConfig.enableExtensions: false keeps noExtensions: true', async () => {
process.env.GEMINI_API_KEY = 'sk-test';
resetScript(scriptedAgentEnd());

await consume(
new PiProvider().sendQuery('hi', '/tmp', undefined, {
model: 'google/gemini-2.5-pro',
assistantConfig: { enableExtensions: false },
})
);

const loaderArgs = MockDefaultResourceLoader.mock.calls[0]?.[0] as
| Record<string, unknown>
| undefined;
expect(loaderArgs?.noExtensions).toBe(true);
});

test('nodeConfig.skills with unknown name yields system warning, does not abort', async () => {
process.env.GEMINI_API_KEY = 'sk-test';
resetScript(scriptedAgentEnd());
Expand Down
8 changes: 7 additions & 1 deletion packages/providers/src/community/pi/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,12 +242,17 @@ export class PiProvider implements IAgentProvider {

// ModelRegistry + settings stay in-memory — only sessions persist, to
// match Claude/Codex. Resource loader still suppresses filesystem
// discovery except for explicitly-passed skill paths.
// discovery by default, except for explicitly-passed skill paths and —
// when piConfig.enableExtensions is true — Pi's community extension
// ecosystem (tools + lifecycle hooks from ~/.pi/agent/extensions/ and
// packages installed via `pi install npm:<pkg>`).
const modelRegistry = ModelRegistry.inMemory(authStorage);
const settingsManager = SettingsManager.inMemory();
const enableExtensions = piConfig.enableExtensions === true;
const resourceLoader = createNoopResourceLoader(cwd, {
...(systemPrompt !== undefined ? { systemPrompt } : {}),
...(skillPaths.length > 0 ? { additionalSkillPaths: skillPaths } : {}),
...(enableExtensions ? { enableExtensions: true } : {}),
});

getLog().info(
Expand All @@ -260,6 +265,7 @@ export class PiProvider implements IAgentProvider {
hasSystemPrompt: systemPrompt !== undefined,
skillCount: skillPaths.length,
missingSkillCount: missingSkills.length,
extensionsEnabled: enableExtensions,
resumed: resumeSessionId !== undefined && !resumeFailed,
},
'pi.session_started'
Expand Down
50 changes: 37 additions & 13 deletions packages/providers/src/community/pi/resource-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,30 +19,54 @@ export interface NoopResourceLoaderOptions {
* config through to Pi after resolution — see `resolvePiSkills`.
*/
additionalSkillPaths?: string[];

/**
* Opt-in to Pi's extension discovery. When true, `noExtensions` flips to
* false and Pi loads:
* - `~/.pi/agent/extensions/*.ts` (global, operator-installed)
* - packages listed in `~/.pi/agent/settings.json` (from `pi install`)
* - `<cwd>/.pi/extensions/*.ts` (project-local — REPO-CONTROLLED, risky)
* - packages listed in `<cwd>/.pi/settings.json`
*
* This is the switch that opens up the community package ecosystem
* (https://shittycodingagent.ai/packages) — ~540 npm packages registering
* custom tools and lifecycle hooks via `pi.registerTool()` / `pi.on()`.
* Tools and hooks work fully in programmatic sessions; TUI-only features
* (renderers, keybindings, slash commands) silently no-op.
*
* Trust boundary: enabling this loads arbitrary JS code with the Archon
* server's OS permissions. Only flip this on when the operator trusts both
* globally-installed extensions AND whatever `.pi/` the workflow's target
* repo happens to contain.
*
* @default false
*/
enableExtensions?: boolean;
}

/**
* Build a Pi ResourceLoader that performs no filesystem discovery. Archon is
* the source of truth for extensions, skills, prompts, themes, and context
* files — Pi should not walk cwd or read ~/.pi/agent/ during server-side
* workflow execution.
*
* Implementation note: we delegate to `DefaultResourceLoader` with all
* `no*` flags set, rather than implementing `ResourceLoader` ourselves. The
* interface's `getExtensions()` returns a `LoadExtensionsResult` requiring a
* real `ExtensionRuntime`, which we can't meaningfully stub. DefaultResourceLoader
* honors the flags and returns empty-but-valid results.
* Build a Pi ResourceLoader. By default performs no filesystem discovery —
* Archon is the source of truth for skills, prompts, themes, and context
* files, and Pi should not walk cwd or read `~/.pi/agent/` during server-side
* workflow execution. When `enableExtensions: true`, the `noExtensions` gate
* is lifted so Pi discovers and loads tools + hooks from the community
* ecosystem (see `NoopResourceLoaderOptions.enableExtensions`). Skills and
* prompts/themes remain suppressed even when extensions are enabled — skills
* are still driven by Archon's explicit `additionalSkillPaths` plumbing.
*
* A caller-supplied `systemPrompt` is still applied (it's set on the loader
* directly, not via filesystem discovery).
* Implementation note: we delegate to `DefaultResourceLoader` with the
* relevant `no*` flags set, rather than implementing `ResourceLoader`
* ourselves. The interface's `getExtensions()` returns a `LoadExtensionsResult`
* requiring a real `ExtensionRuntime`, which we can't meaningfully stub.
* DefaultResourceLoader honors the flags and returns empty-but-valid results.
*/
export function createNoopResourceLoader(
cwd: string,
options: NoopResourceLoaderOptions = {}
): DefaultResourceLoader {
return new DefaultResourceLoader({
cwd,
noExtensions: true,
noExtensions: options.enableExtensions !== true,
noSkills: true,
noPromptTemplates: true,
noThemes: true,
Expand Down
13 changes: 13 additions & 0 deletions packages/providers/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,19 @@ export interface PiProviderDefaults {
[key: string]: unknown;
/** Default model ref in '<pi-provider-id>/<model-id>' format, e.g. 'google/gemini-2.5-pro' */
model?: string;
/**
* Opt-in to Pi's extension discovery (tools + lifecycle hooks from community
* packages — see https://shittycodingagent.ai/packages). When true, Pi loads
* extensions from `~/.pi/agent/extensions/`, `~/.pi/agent/settings.json`
* packages, AND the workflow's cwd (`<cwd>/.pi/extensions/`,
* `<cwd>/.pi/settings.json`). The cwd scope is the risky one — a workflow
* running against an untrusted repo can auto-load whatever extension code
* that repo ships. Disabled by default to preserve the "Archon is source of
* truth" trust boundary. Flip to true only on hosts whose workflows run
* against repos you trust.
* @default false
*/
enableExtensions?: boolean;
}

/** Generic per-provider defaults bag used by config surfaces and UI. */
Expand Down
Loading