diff --git a/packages/providers/src/community/pi/config.test.ts b/packages/providers/src/community/pi/config.test.ts index ff0fa511ed..31353e86ab 100644 --- a/packages/providers/src/community/pi/config.test.ts +++ b/packages/providers/src/community/pi/config.test.ts @@ -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, + }); + }); }); diff --git a/packages/providers/src/community/pi/config.ts b/packages/providers/src/community/pi/config.ts index 313a19daa4..66b2e6a120 100644 --- a/packages/providers/src/community/pi/config.ts +++ b/packages/providers/src/community/pi/config.ts @@ -15,5 +15,9 @@ export function parsePiConfig(raw: Record): PiProviderDefaults result.model = raw.model; } + if (typeof raw.enableExtensions === 'boolean') { + result.enableExtensions = raw.enableExtensions; + } + return result; } diff --git a/packages/providers/src/community/pi/provider.test.ts b/packages/providers/src/community/pi/provider.test.ts index 135ee17d3c..837352e815 100644 --- a/packages/providers/src/community/pi/provider.test.ts +++ b/packages/providers/src/community/pi/provider.test.ts @@ -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 + | 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 + | 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 + | 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()); diff --git a/packages/providers/src/community/pi/provider.ts b/packages/providers/src/community/pi/provider.ts index 6ced994209..10edca5560 100644 --- a/packages/providers/src/community/pi/provider.ts +++ b/packages/providers/src/community/pi/provider.ts @@ -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:`). 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( @@ -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' diff --git a/packages/providers/src/community/pi/resource-loader.ts b/packages/providers/src/community/pi/resource-loader.ts index dee5c9a35c..593c65e9d3 100644 --- a/packages/providers/src/community/pi/resource-loader.ts +++ b/packages/providers/src/community/pi/resource-loader.ts @@ -19,22 +19,46 @@ 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`) + * - `/.pi/extensions/*.ts` (project-local — REPO-CONTROLLED, risky) + * - packages listed in `/.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, @@ -42,7 +66,7 @@ export function createNoopResourceLoader( ): DefaultResourceLoader { return new DefaultResourceLoader({ cwd, - noExtensions: true, + noExtensions: options.enableExtensions !== true, noSkills: true, noPromptTemplates: true, noThemes: true, diff --git a/packages/providers/src/types.ts b/packages/providers/src/types.ts index 260bfee313..6ece94d4c8 100644 --- a/packages/providers/src/types.ts +++ b/packages/providers/src/types.ts @@ -39,6 +39,19 @@ export interface PiProviderDefaults { [key: string]: unknown; /** Default model ref in '/' 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 (`/.pi/extensions/`, + * `/.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. */