diff --git a/README.md b/README.md index 3ee0625a6..016fdead2 100644 --- a/README.md +++ b/README.md @@ -231,7 +231,7 @@ The control endpoint supports project, session, and Agent workflows plus a revie Extensions written for the [pi](https://github.com/badlogic/pi-mono) CLI can run inside PI-Desktop's agent unchanged. -A plugin can list them under `contributes.agentExtensions`, or **Plugins → Import pi extension** can wrap an existing extension file or directory in a plugin. +A plugin can list them under `contributes.agentExtensions`, or **Plugins → Import pi extension** can wrap an existing extension file or directory in a plugin. If the directory declares npm `dependencies`, they are installed into the generated plugin before its first load (`--ignore-scripts`, so no third-party install script ever runs). They can register tools, slash commands, and hooks on every turn, tool call, and provider request. They run with the same access as the agent's own tools, gated by the `agent.extension` permission. diff --git a/README.zh-CN.md b/README.zh-CN.md index 4592f4290..1feb3b6d9 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -291,7 +291,7 @@ PI_DESKTOP_MCP_CONTROL=1 **Plugins → Import pi extension** -直接把现有 extension 文件或目录包装成 PI-Desktop 插件。 +直接把现有 extension 文件或目录包装成 PI-Desktop 插件。若目录声明了 npm `dependencies`,会在首次加载前安装到生成的插件中(`--ignore-scripts`,绝不运行第三方安装脚本)。 这些扩展可以注册: diff --git a/apps/desktop/electron/main/agent-extensions-ipc.ts b/apps/desktop/electron/main/agent-extensions-ipc.ts index e6f367951..b5dfe2236 100644 --- a/apps/desktop/electron/main/agent-extensions-ipc.ts +++ b/apps/desktop/electron/main/agent-extensions-ipc.ts @@ -9,7 +9,11 @@ */ import { dialog, type BrowserWindow } from "electron"; import { ErrorCodes, IPC, type TrustedExtensionUiPromptResponse } from "@pi-desktop/shared"; -import { generateImportedExtensionPlugin, type AgentExtensionBridge } from "./agent-extensions.js"; +import { + generateImportedExtensionPlugin, + installExtensionDependencies, + type AgentExtensionBridge, +} from "./agent-extensions.js"; export type AgentExtensionIpcDeps = { handle: (channel: string, fn: (...args: any[]) => Promise) => void; @@ -71,7 +75,10 @@ export function registerAgentExtensionIpc(deps: AgentExtensionIpcDeps): void { const source = picked.canceled ? undefined : picked.filePaths[0]; if (!source) return { canceled: true }; const generated = generateImportedExtensionPlugin(source, deps.importRoot); + // Install before registration so the extension's first load already sees + // its dependencies; a failed install registers anyway and is reported. + const dependencies = await installExtensionDependencies(generated.path); const loaded = await deps.loadDevPlugin(generated.path); - return { canceled: false, ...generated, loaded }; + return { canceled: false, ...generated, loaded, dependencies }; }); } diff --git a/apps/desktop/electron/main/agent-extensions.ts b/apps/desktop/electron/main/agent-extensions.ts index d6bdee8a5..f60ebccbe 100644 --- a/apps/desktop/electron/main/agent-extensions.ts +++ b/apps/desktop/electron/main/agent-extensions.ts @@ -8,9 +8,11 @@ * the modal prompts between the sidecar and the renderer. Discovery and * enablement are the plugin system's job; nothing here touches the filesystem. */ +import { spawn } from "node:child_process"; import { createHash, randomUUID } from "node:crypto"; -import { cpSync, existsSync, lstatSync, mkdirSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { copyFileSync, cpSync, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs"; import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import { tmpdir } from "node:os"; import { assertImportedPackagePath, discoverImportedPackageSkills } from "./imported-package-skills"; import { discoverManualPath } from "@pi-desktop/agent-runtime"; import { @@ -237,6 +239,248 @@ export class AgentExtensionBridge { } } +export type ExtensionDependencyInstallResult = + | { state: "skipped"; reason: "no-package-json" | "no-dependencies" } + | { state: "installed" } + | { state: "failed"; error: string }; + +/** Injectable so tests never run npm. Resolves with the exit code and captured stderr. */ +export type DependencyCommandRunner = ( + command: string, + args: string[], + cwd: string, + timeoutMs: number, +) => Promise<{ code: number; stderr: string }>; + +const NPM_INSTALL_TIMEOUT_MS = 120_000; +/** npm output is not toast-shaped; the tail carries the actual failure. */ +const DEPENDENCY_ERROR_TAIL_CHARS = 200; +/** Rolling cap so a chatty npm cannot balloon the main process's memory. */ +const DEPENDENCY_STDERR_KEEP_CHARS = 8192; + +function dependencyErrorTail(text: string): string { + return text.length > DEPENDENCY_ERROR_TAIL_CHARS + ? `…${text.slice(-DEPENDENCY_ERROR_TAIL_CHARS)}` + : text; +} + +const PUBLIC_NPM_REGISTRY = "https://registry.npmjs.org/"; + +function isRegistryDependencySpec(value: unknown): boolean { + if (typeof value !== "string") return false; + const spec = value.trim(); + // Registry versions and ranges contain no URL/path separators or scheme + // delimiter. This also rejects npm aliases and workspace/local specs. + return spec.length > 0 && !/[\\/:]/.test(spec); +} + +function dependencyMapError(field: string, value: unknown): string | undefined { + if (value === undefined) return undefined; + if (!value || typeof value !== "object" || Array.isArray(value)) { + return `${field} must be a JSON object`; + } + for (const [name, spec] of Object.entries(value as Record)) { + if (!isRegistryDependencySpec(spec)) { + return `dependency ${name} in ${field} uses a non-registry spec (${String(spec)}); only registry versions are installable`; + } + } + return undefined; +} + +function overrideSpecError(value: unknown, path = "overrides"): string | undefined { + if (value === undefined) return undefined; + if (typeof value === "string") { + // npm's $name references reuse a dependency spec already declared above. + if (value.startsWith("$")) return undefined; + if (!isRegistryDependencySpec(value)) { + return `override ${path} uses a non-registry spec (${value}); only registry versions are installable`; + } + return undefined; + } + if (!value || typeof value !== "object" || Array.isArray(value)) { + return `override ${path} must be a JSON object or registry version`; + } + for (const [name, nested] of Object.entries(value as Record)) { + const error = overrideSpecError(nested, `${path}.${name}`); + if (error) return error; + } + return undefined; +} + +function lockfileHasUnsafeSource(value: unknown): boolean { + if (Array.isArray(value)) return value.some(lockfileHasUnsafeSource); + if (!value || typeof value !== "object") return false; + for (const [key, nested] of Object.entries(value as Record)) { + if (key === "resolved") { + if ( + typeof nested !== "string" || + (nested.length > 0 && !nested.startsWith(PUBLIC_NPM_REGISTRY)) + ) { + return true; + } + } + if (key === "link" && nested === true) return true; + if ((key === "version" || key === "from") && typeof nested === "string" && nested.length > 0) { + if (!isRegistryDependencySpec(nested)) return true; + } + if (lockfileHasUnsafeSource(nested)) return true; + } + return false; +} + +export function defaultDependencyRunner( + command: string, + args: string[], + cwd: string, + timeoutMs: number, +): Promise<{ code: number; stderr: string }> { + return new Promise((resolve, reject) => { + // Shell only where npm is a .cmd shim (Windows); every arg is a literal. + // A shell kill on Windows terminates the shim, possibly leaving npm + // itself running — accepted for v1, the timeout result still resolves. + // Explicit minimal environment: npm must not see npm auth tokens, + // proxy/SSH configuration or anything else from the desktop process. + const isolatedUserConfig = join(tmpdir(), `.pi-desktop-npm-user-${randomUUID()}.npmrc`); + const isolatedGlobalConfig = join(tmpdir(), `.pi-desktop-npm-global-${randomUUID()}.npmrc`); + const child = spawn(command, args, { + cwd, + shell: process.platform === "win32", + windowsHide: true, + stdio: ["ignore", "ignore", "pipe"], + env: { + PATH: process.env.PATH ?? "", + HOME: process.env.HOME ?? process.env.USERPROFILE ?? "", + TMPDIR: process.env.TMPDIR ?? process.env.TEMP ?? "", + LANG: process.env.LANG ?? "en_US.UTF-8", + npm_config_userconfig: isolatedUserConfig, + npm_config_globalconfig: isolatedGlobalConfig, + npm_config_registry: PUBLIC_NPM_REGISTRY, + npm_config_proxy: "", + npm_config_https_proxy: "", + npm_config_noproxy: "*", + npm_config_ignore_scripts: "true", + npm_config_audit: "false", + npm_config_fund: "false", + npm_config_update_notifier: "false", + }, + }); + let stderr = ""; + child.stderr?.on("data", (chunk: Buffer) => { + stderr += chunk.toString("utf8"); + if (stderr.length > DEPENDENCY_STDERR_KEEP_CHARS * 2) { + stderr = stderr.slice(-DEPENDENCY_STDERR_KEEP_CHARS); + } + }); + let killTimer: ReturnType | undefined; + const timer = setTimeout(() => { + stderr += `\nnpm install exceeded ${timeoutMs}ms and was terminated`; + child.kill("SIGTERM"); + killTimer = setTimeout(() => child.kill("SIGKILL"), 5_000); + }, timeoutMs); + const settle = (fn: () => void) => { + clearTimeout(timer); + if (killTimer) clearTimeout(killTimer); + fn(); + }; + child.on("error", (err) => { + settle(() => reject(err)); + }); + child.on("close", (code) => { + settle(() => resolve({ code: code ?? 1, stderr })); + }); + }); +} + +/** + * Install an imported extension's npm dependencies inside the generated plugin + * directory (spec 07-plugins/16 §3): the sidecar's jiti resolves bare imports + * from the plugin root's `node_modules`, and the kernel packages (`pi-ai`, + * `pi-coding-agent`, `pi-tui`) keep winning through virtual modules, so + * installing them is harmless. `--legacy-peer-deps` keeps `pi-coding-agent` + * peers out of the tree; `--ignore-scripts` means no third-party install + * script ever runs here — a native module that needs one fails to load with a + * diagnostic instead (documented workaround: rebuild against Electron + * headers). A failure never blocks the import; the extension reports its own + * load error and the renderer surfaces this result. + */ +export async function installExtensionDependencies( + pluginDir: string, + options?: { runner?: DependencyCommandRunner; timeoutMs?: number }, +): Promise { + const packageJsonPath = join(pluginDir, "package.json"); + if (!existsSync(packageJsonPath)) { + return { state: "skipped", reason: "no-package-json" }; + } + let manifest: { + dependencies?: unknown; + optionalDependencies?: unknown; + overrides?: unknown; + }; + try { + manifest = JSON.parse(readFileSync(packageJsonPath, "utf8")); + } catch (err) { + return { + state: "failed", + error: `package.json is not valid JSON: ${err instanceof Error ? err.message : String(err)}`, + }; + } + // JSON.parse("null") succeeds; reading .dependencies below would then throw + // outside this guard and block the whole import. + if (manifest === null || typeof manifest !== "object" || Array.isArray(manifest)) { + return { state: "failed", error: "package.json is not a JSON object" }; + } + for (const field of ["dependencies", "optionalDependencies"] as const) { + const error = dependencyMapError(field, manifest[field]); + if (error) return { state: "failed", error }; + } + const overrideError = overrideSpecError(manifest.overrides); + if (overrideError) return { state: "failed", error: overrideError }; + const hasDependencies = [manifest.dependencies, manifest.optionalDependencies].some( + (value) => value && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length > 0, + ); + if (!hasDependencies) { + return { state: "skipped", reason: "no-dependencies" }; + } + // A copied lockfile pins resolved URLs; keep it only when every entry + // resolves from the public registry, otherwise drop it so npm resolves + // from package.json against the default registry. + const lockfileCandidates = ["package-lock.json", "npm-shrinkwrap.json", "yarn.lock", "pnpm-lock.yaml"]; + for (const name of lockfileCandidates) { + const lockPath = join(pluginDir, name); + if (!existsSync(lockPath)) continue; + if (name === "package-lock.json" || name === "npm-shrinkwrap.json") { + try { + const lock = JSON.parse(readFileSync(lockPath, "utf8")) as unknown; + if (lockfileHasUnsafeSource(lock)) rmSync(lockPath); + } catch { + rmSync(lockPath); + } + } else { + rmSync(lockPath); + } + } + try { + const result = await (options?.runner ?? defaultDependencyRunner)( + "npm", + ["install", "--omit=dev", "--legacy-peer-deps", "--no-audit", "--no-fund", "--ignore-scripts"], + pluginDir, + options?.timeoutMs ?? NPM_INSTALL_TIMEOUT_MS, + ); + if (result.code !== 0) { + return { + state: "failed", + error: dependencyErrorTail(`npm install exited ${result.code}: ${result.stderr.trim()}`), + }; + } + return { state: "installed" }; + } catch (err) { + return { + state: "failed", + error: dependencyErrorTail(err instanceof Error ? err.message : String(err)), + }; + } +} + const PLUGIN_ID_PREFIX = "imported."; function slugFor(path: string): string { @@ -250,7 +494,10 @@ function slugFor(path: string): string { /** * Build a plugin directory from a pi extension file or directory (spec §3): * copies the source under `src/`, writes a manifest that declares the entry - * files as `contributes.agentExtensions`, and a no-op `main.js`. + * files as `contributes.agentExtensions`, and a no-op `main.js`. A directory + * that ships a `package.json` also gets it (plus its lockfile) at the plugin + * root so {@link installExtensionDependencies} can resolve its dependencies + * there; `node_modules` itself is never copied — it is reinstalled. */ export function generateImportedExtensionPlugin( source: string, @@ -330,5 +577,27 @@ export function generateImportedExtensionPlugin( "// Generated by PI-Desktop: declarative skills and/or agent extensions.\nmodule.exports = {};\n", "utf8", ); + if (isDirectory) { + const rootPackageJson = join(resolved, "package.json"); + if (existsSync(rootPackageJson)) { + // A `workspaces` field would send npm into the copied sources under + // src/; strip it so the install sees only the declared dependencies. + try { + const pkg = JSON.parse(readFileSync(rootPackageJson, "utf8")) as Record; + if (pkg && typeof pkg === "object" && "workspaces" in pkg) { + delete pkg.workspaces; + writeFileSync(join(dir, "package.json"), JSON.stringify(pkg, null, 2) + "\n", "utf8"); + } else { + copyFileSync(rootPackageJson, join(dir, "package.json")); + } + } catch { + // Not valid JSON: copy verbatim; the install step reports the failure. + copyFileSync(rootPackageJson, join(dir, "package.json")); + } + for (const file of ["package-lock.json", "npm-shrinkwrap.json"]) { + if (existsSync(join(resolved, file))) copyFileSync(join(resolved, file), join(dir, file)); + } + } + } return { path: dir, id, entries }; } diff --git a/apps/desktop/src/features/plugins/usePluginsPage.ts b/apps/desktop/src/features/plugins/usePluginsPage.ts index a9ea6f1e7..16ed83204 100644 --- a/apps/desktop/src/features/plugins/usePluginsPage.ts +++ b/apps/desktop/src/features/plugins/usePluginsPage.ts @@ -294,13 +294,21 @@ export function usePluginsPage() { }); // A pi CLI extension becomes a development plugin holding `agent.extension` - // (spec 07-plugins/16 §3); the confirm is the trust decision. + // (spec 07-plugins/16 §3); the confirm is the trust decision. Declared npm + // dependencies are installed (scripts disabled) before the first load. const importExtension = () => run(async () => { if (!window.confirm(t("plugins.agentExtension.importConfirm"))) return; const result = await api.importPiExtension(); if (result.canceled) return; await refreshPlugins(); + if (result.dependencies.state === "failed") { + showToast( + t("plugins.importExtensionDepsFailed", { id: result.id, error: result.dependencies.error }), + { variant: "warning" }, + ); + return; + } showToast(t("plugins.importExtensionDone", { id: result.id }), { variant: "success" }); }); diff --git a/apps/desktop/src/lib/api.ts b/apps/desktop/src/lib/api.ts index 6b6c89e2f..ec2579cb4 100644 --- a/apps/desktop/src/lib/api.ts +++ b/apps/desktop/src/lib/api.ts @@ -809,9 +809,19 @@ export const api = { ), /** Import a pi CLI extension file or directory as a development plugin (spec 16 §3). */ importPiExtension: () => - invoke<{ canceled: true } | { canceled: false; id: string; path: string; entries: string[] }>( - IPC.invoke.pluginImportExtension, - ), + invoke< + | { canceled: true } + | { + canceled: false; + id: string; + path: string; + entries: string[]; + dependencies: + | { state: "skipped"; reason: "no-package-json" | "no-dependencies" } + | { state: "installed" } + | { state: "failed"; error: string }; + } + >(IPC.invoke.pluginImportExtension), runExtensionCommand: (input: { sessionId: string; name: string; args: string }) => invoke<{ ok: boolean }>(IPC.invoke.extensionsCommandRun, input), respondExtensionPrompt: (response: TrustedExtensionUiPromptResponse) => diff --git a/apps/desktop/test/agent-extensions.test.mjs b/apps/desktop/test/agent-extensions.test.mjs index 88044c9b6..caf492f41 100644 --- a/apps/desktop/test/agent-extensions.test.mjs +++ b/apps/desktop/test/agent-extensions.test.mjs @@ -9,6 +9,7 @@ register(new URL("./helpers/ts-import-hooks.mjs", import.meta.url)); const { AgentExtensionBridge, generateImportedExtensionPlugin, + installExtensionDependencies, } = await import("../electron/main/agent-extensions.ts"); function bridge(overrides = {}) { @@ -27,6 +28,89 @@ function bridge(overrides = {}) { return { b, events }; } +test("installing a package.json whose JSON body is null or an array fails without throwing", async () => { + for (const body of ["null", "[]"]) { + const root = mkdtempSync(join(tmpdir(), "ext-deps-null-")); + writeFileSync(join(root, "package.json"), body); + const result = await installExtensionDependencies(root, { runner: async () => ({ code: 0, stderr: "" }) }); + assert.equal(result.state, "failed"); + assert.match(String(result.error), /not a JSON object/); + } +}); + +test("non-registry dependency specs are rejected before npm runs", async () => { + let npmRan = false; + const root = mkdtempSync(join(tmpdir(), "ext-deps-git-")); + writeFileSync(join(root, "package.json"), JSON.stringify({ dependencies: { evil: "github:a/b" } })); + const result = await installExtensionDependencies(root, { + runner: async () => { + npmRan = true; + return { code: 0, stderr: "" }; + }, + }); + assert.equal(result.state, "failed"); + assert.match(String(result.error), /non-registry spec/); + assert.equal(npmRan, false); +}); + +test("optional dependencies and overrides cannot escape the registry before npm runs", async () => { + const cases = [ + { + dependencies: { "left-pad": "^1.3.0" }, + optionalDependencies: { evil: "git+ssh://git@evil.example/evil.git" }, + }, + { + dependencies: { "left-pad": "^1.3.0" }, + overrides: { "left-pad": "https://evil.example/left-pad.tgz" }, + }, + ]; + for (const packageJson of cases) { + const root = mkdtempSync(join(tmpdir(), "ext-deps-source-")); + writeFileSync(join(root, "package.json"), JSON.stringify(packageJson)); + let npmRan = false; + const result = await installExtensionDependencies(root, { + runner: async () => { + npmRan = true; + return { code: 0, stderr: "" }; + }, + }); + assert.equal(result.state, "failed"); + assert.match(String(result.error), /non-registry spec/); + assert.equal(npmRan, false); + } +}); + +test("a lockfile with non-registry resolved urls is dropped before install", async () => { + const root = mkdtempSync(join(tmpdir(), "ext-deps-lock-")); + writeFileSync(join(root, "package.json"), JSON.stringify({ dependencies: { "left-pad": "^1.3.0" } })); + writeFileSync( + join(root, "package-lock.json"), + JSON.stringify({ packages: { "node_modules/evil": { resolved: "https://evil.example/x.tgz" } } }), + ); + const result = await installExtensionDependencies(root, { runner: async () => ({ code: 0, stderr: "" }) }); + assert.equal(result.state, "installed"); + assert.equal(existsSync(join(root, "package-lock.json")), false); +}); +test("a legacy package-lock dependency tree with a non-registry source is dropped", async () => { + const root = mkdtempSync(join(tmpdir(), "ext-deps-legacy-lock-")); + writeFileSync(join(root, "package.json"), JSON.stringify({ dependencies: { "left-pad": "^1.3.0" } })); + writeFileSync( + join(root, "package-lock.json"), + JSON.stringify({ + name: "x", + lockfileVersion: 1, + dependencies: { + evil: { version: "1.0.0", resolved: "https://evil.example/evil.tgz" }, + }, + }), + ); + const result = await installExtensionDependencies(root, { + runner: async () => ({ code: 0, stderr: "" }), + }); + assert.deepEqual(result, { state: "installed" }); + assert.equal(existsSync(join(root, "package-lock.json")), false); +}); + test("session publications drive the plugin's agent-extension status and the command list", () => { const { b, events } = bridge(); const ids = ["/p/a.ts", "/p/b.ts"]; @@ -119,3 +203,146 @@ test("importing a pi extension directory or file generates a plugin holding agen writeFileSync(join(root, "notes.md"), "# no"); assert.throws(() => generateImportedExtensionPlugin(join(root, "notes.md"), importRoot), /no extension entry/); }); + +test("importing a directory keeps its package.json at the plugin root and never copies node_modules", () => { + const root = mkdtempSync(join(tmpdir(), "pi-ax-pkg-")); + const extDir = join(root, "memory-ext"); + mkdirSync(join(extDir, "node_modules", "some-dep"), { recursive: true }); + writeFileSync(join(extDir, "node_modules", "some-dep", "index.js"), "module.exports = {};"); + writeFileSync(join(extDir, "index.ts"), "export default function () {}\n"); + writeFileSync( + join(extDir, "package.json"), + JSON.stringify({ name: "memory-ext", dependencies: { "some-dep": "^1.0.0" }, pi: { extensions: ["index.ts"] } }), + ); + writeFileSync(join(extDir, "package-lock.json"), "{}"); + + const generated = generateImportedExtensionPlugin(extDir, join(root, "imported")); + assert.equal(readFileSync(join(generated.path, "package.json"), "utf8"), readFileSync(join(extDir, "package.json"), "utf8"), "package.json lands at the plugin root for dependency install"); + assert.ok(existsSync(join(generated.path, "package-lock.json"))); + assert.ok(!existsSync(join(generated.path, "src", "node_modules")), "node_modules is reinstalled, never copied"); + + const file = join(root, "solo.ts"); + writeFileSync(file, "export default function () {}\n"); + const single = generateImportedExtensionPlugin(file, join(root, "imported")); + assert.ok(!existsSync(join(single.path, "package.json")), "a lone file has nothing to install from"); +}); + +test("importing strips workspaces from the copied package.json so npm never enters src/", () => { + const root = mkdtempSync(join(tmpdir(), "pi-ax-ws-")); + const extDir = join(root, "monorepo-ext"); + mkdirSync(extDir, { recursive: true }); + writeFileSync(join(extDir, "index.ts"), "export default function () {}\n"); + writeFileSync( + join(extDir, "package.json"), + JSON.stringify({ name: "monorepo-ext", workspaces: ["packages/*"], dependencies: { "some-dep": "^1" } }), + ); + + const generated = generateImportedExtensionPlugin(extDir, join(root, "imported")); + const pkg = JSON.parse(readFileSync(join(generated.path, "package.json"), "utf8")); + assert.ok(!("workspaces" in pkg), "workspaces is stripped from the plugin root copy"); + assert.deepEqual(pkg.dependencies, { "some-dep": "^1" }); + + const plainDir = join(root, "plain-ext"); + mkdirSync(plainDir, { recursive: true }); + writeFileSync(join(plainDir, "index.ts"), "export default function () {}\n"); + writeFileSync(join(plainDir, "package.json"), JSON.stringify({ name: "plain-ext", dependencies: {} })); + const plain = generateImportedExtensionPlugin(plainDir, join(root, "imported")); + assert.deepEqual(JSON.parse(readFileSync(join(plain.path, "package.json"), "utf8")), { + name: "plain-ext", + dependencies: {}, + }, "a package.json without workspaces is copied verbatim"); +}); + +test("default runner caps captured stderr and escalates the timeout kill", async () => { + const { defaultDependencyRunner } = await import("../electron/main/agent-extensions.ts"); + + const flooded = await defaultDependencyRunner( + process.execPath, + ["-e", "process.stderr.write('x'.repeat(40000)); process.exit(0)"], + process.cwd(), + 30_000, + ); + assert.equal(flooded.code, 0); + // Invariant of the rolling cap: after every chunk the buffer is at most + // 2× the keep size, regardless of how the pipe chunks the writes. + assert.ok(flooded.stderr.length <= 16384, "stderr is capped to a bounded tail"); + + const stalled = await defaultDependencyRunner( + process.execPath, + ["-e", "setTimeout(() => {}, 60000)"], + process.cwd(), + 300, + ); + assert.notEqual(stalled.code, 0, "a stalled install is killed"); + assert.match(stalled.stderr, /exceeded 300ms and was terminated/); +}); +test("the default dependency runner isolates npm config sources and proxies", async () => { + const { defaultDependencyRunner } = await import("../electron/main/agent-extensions.ts"); + const result = await defaultDependencyRunner( + process.execPath, + ["-e", "process.stderr.write(JSON.stringify(process.env))"], + process.cwd(), + 30_000, + ); + assert.equal(result.code, 0); + const childEnv = JSON.parse(result.stderr); + assert.notEqual(childEnv.npm_config_userconfig, childEnv.npm_config_globalconfig); + assert.ok(childEnv.npm_config_userconfig.startsWith(tmpdir())); + assert.ok(childEnv.npm_config_globalconfig.startsWith(tmpdir())); + assert.equal(childEnv.npm_config_registry, "https://registry.npmjs.org/"); + assert.equal(childEnv.npm_config_proxy, ""); + assert.equal(childEnv.npm_config_https_proxy, ""); + assert.equal(childEnv.npm_config_noproxy, "*"); + assert.equal(childEnv.NPM_TOKEN, undefined); + assert.equal(childEnv.NODE_AUTH_TOKEN, undefined); +}); + +test("dependency install: skips without a manifest or dependencies, runs npm with pinned flags, surfaces failures", async () => { + const root = mkdtempSync(join(tmpdir(), "pi-ax-deps-")); + const write = (name, json) => { + const dir = join(root, name); + mkdirSync(dir, { recursive: true }); + if (json !== null) writeFileSync(join(dir, "package.json"), json); + return dir; + }; + + const noPackage = write("no-package", null); + assert.deepEqual(await installExtensionDependencies(noPackage), { state: "skipped", reason: "no-package-json" }); + + const noDeps = write("no-deps", JSON.stringify({ name: "x" })); + assert.deepEqual(await installExtensionDependencies(noDeps), { state: "skipped", reason: "no-dependencies" }); + + const badJson = write("bad-json", "{ not json"); + const bad = await installExtensionDependencies(badJson); + assert.equal(bad.state, "failed"); + assert.match(bad.error, /package\.json is not valid JSON/); + + const calls = []; + const installed = write("installed", JSON.stringify({ name: "x", dependencies: { "some-dep": "^1" } })); + const runner = async (command, args, cwd, timeoutMs) => { + calls.push({ command, args, cwd, timeoutMs }); + return { code: 0, stderr: "" }; + }; + assert.deepEqual(await installExtensionDependencies(installed, { runner, timeoutMs: 1234 }), { state: "installed" }); + assert.equal(calls.length, 1); + assert.equal(calls[0].command, "npm"); + assert.deepEqual(calls[0].args, ["install", "--omit=dev", "--legacy-peer-deps", "--no-audit", "--no-fund", "--ignore-scripts"]); + assert.equal(calls[0].cwd, installed, "npm runs inside the plugin directory"); + assert.equal(calls[0].timeoutMs, 1234); + + const failing = write("failing", JSON.stringify({ name: "x", dependencies: { "some-dep": "^1" } })); + const result = await installExtensionDependencies(failing, { + runner: async () => ({ code: 1, stderr: "npm error code ENOTFOUND\nnpm error network unreachable" }), + }); + assert.equal(result.state, "failed"); + assert.match(result.error, /exited 1/); + assert.match(result.error, /ENOTFOUND/); + + const throwing = write("throwing", JSON.stringify({ name: "x", dependencies: { "some-dep": "^1" } })); + const thrown = await installExtensionDependencies(throwing, { + runner: async () => { + throw new Error("npm not found"); + }, + }); + assert.deepEqual(thrown, { state: "failed", error: "npm not found" }); +}); diff --git a/docs/plugin-development.md b/docs/plugin-development.md index 78e607586..c6e4051a2 100644 --- a/docs/plugin-development.md +++ b/docs/plugin-development.md @@ -724,7 +724,16 @@ What to know before you use it: other terminal-only surfaces) are inert and reported in the plugin row's details, never thrown. - **Existing pi extensions** need no changes: Plugins → overflow menu → - "Import pi extension" wraps a file or directory in a generated plugin. + "Import pi extension" wraps a file or directory in a generated plugin. If + the directory ships a `package.json` with `dependencies`, they are + installed into the plugin root before the first load + (`npm install --omit=dev --legacy-peer-deps --no-audit --no-fund + --ignore-scripts`): no third-party install script ever runs, so a native + module that needs one fails to load with a diagnostic — rebuild it against + Electron headers (`npx @electron/rebuild -v `) inside + the plugin directory to fix it. A failed install never blocks the import; + you get a warning toast with the npm error and the row shows the load + error. ## 7. Permission design diff --git a/docs/spec/06-delivery/04-e2e-test-plan.md b/docs/spec/06-delivery/04-e2e-test-plan.md index 70a9146ca..1cc1dde68 100644 --- a/docs/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/spec/06-delivery/04-e2e-test-plan.md @@ -6627,7 +6627,7 @@ and identify the platform validation still needed. | E — Tools & permissions | E2E-008a, E2E-014, E2E-015, E2E-016, E2E-017, E2E-018, E2E-019, E2E-024I, E2E-024K, E2E-040, E2E-049, E2E-074, E2E-093, E2E-097, E2E-099, E2E-100, E2E-101, E2E-102, E2E-102d, E2E-102e, E2E-102g, E2E-103, E2E-105, E2E-106, E2E-107, E2E-111, E2E-112, E2E-113, E2E-114, E2E-115, E2E-116, E2E-119, E2E-121, E2E-122, E2E-142, E2E-145, E2E-147, E2E-155, E2E-158, E2E-166, E2E-181, E2E-PLUGIN-imported-pi-package-skills | | F — Persistence | E2E-020, E2E-021, E2E-021a, E2E-036, E2E-037, E2E-038, E2E-040, E2E-042, E2E-047, E2E-048, E2E-051, E2E-054, E2E-056, E2E-061, E2E-062, E2E-064, E2E-066, E2E-068, E2E-071, E2E-072, E2E-073, E2E-082, E2E-084, E2E-096, E2E-098, E2E-102, E2E-102b, E2E-102c, E2E-102d, E2E-102g, E2E-102i, E2E-103, E2E-AGENTS-001, E2E-061a, E2E-073a, E2E-104, E2E-106, E2E-107, E2E-108, E2E-109, E2E-110, E2E-112, E2E-118, E2E-119, E2E-120, E2E-121, E2E-123, E2E-142, E2E-146, E2E-146a, E2E-148, E2E-151, E2E-158, E2E-160, E2E-168, E2E-171, E2E-177, E2E-178, E2E-183, E2E-186, E2E-005J, E2E-PLUGIN-session-orchestrator-real-workers | | F — Persistence (project ordering) | E2E-251 | -| G — Plugins | E2E-022, E2E-022A, E2E-022B, E2E-022C, E2E-023, E2E-024, E2E-024B, E2E-024C, E2E-024D, E2E-024E, E2E-024W, E2E-024F, E2E-024G, E2E-024H, E2E-024I, E2E-024J, E2E-024K, E2E-024L, E2E-024M, E2E-024N, E2E-024O, E2E-024P, E2E-025, E2E-026, E2E-105, E2E-117, E2E-120, E2E-122, E2E-123, E2E-024Q, E2E-148, E2E-152, E2E-153, E2E-PLUGIN-imported-pi-package-skills | +| G — Plugins | E2E-022, E2E-022A, E2E-022B, E2E-022C, E2E-023, E2E-024, E2E-024B, E2E-024C, E2E-024D, E2E-024E, E2E-024W, E2E-024F, E2E-024G, E2E-024H, E2E-024I, E2E-024J, E2E-024K, E2E-024L, E2E-024M, E2E-024N, E2E-024O, E2E-024P, E2E-025, E2E-026, E2E-105, E2E-117, E2E-120, E2E-122, E2E-123, E2E-024Q, E2E-148, E2E-152, E2E-153, E2E-PLUGIN-imported-pi-package-skills, E2E-PLUGIN-import-extension-installs-dependencies, E2E-PLUGIN-import-extension-reports-missing-dependency | | H — Diagnostics | E2E-027, E2E-031, E2E-034, E2E-042, E2E-096, E2E-098, E2E-104, E2E-107, E2E-108, E2E-109, E2E-110, E2E-113, E2E-115, E2E-116, E2E-118, E2E-121, E2E-146, E2E-146a, E2E-155, E2E-159, E2E-176, E2E-194, E2E-195 | | Security | E2E-028, E2E-029, E2E-030, E2E-024J, E2E-024K, E2E-024M, E2E-049, E2E-068, E2E-086, E2E-102c, E2E-102d, E2E-102e, E2E-105, E2E-106, E2E-107, E2E-108, E2E-109, E2E-110, E2E-112, E2E-113, E2E-115, E2E-116, E2E-117, E2E-119, E2E-121, E2E-122, E2E-123, E2E-142, E2E-148, E2E-151, E2E-153, E2E-158, E2E-187, E2E-196c, E2E-196b, E2E-196 | | Quality | E2E-032, E2E-033, E2E-039, E2E-043, E2E-044, E2E-045, E2E-046, E2E-047, E2E-048, E2E-048A, E2E-049, E2E-050, E2E-053, E2E-055, E2E-056, E2E-057, E2E-058, E2E-059, E2E-060, E2E-061, E2E-062, E2E-063, E2E-064, E2E-065, E2E-066, E2E-067, E2E-068, E2E-069, E2E-070, E2E-071, E2E-072, E2E-073, E2E-074, E2E-075, E2E-076, E2E-077, E2E-078, E2E-079, E2E-080, E2E-081, E2E-082, E2E-083, E2E-084, E2E-085, E2E-086, E2E-092, E2E-093, E2E-094, E2E-095, E2E-096, E2E-097, E2E-098, E2E-099, E2E-100, E2E-101, E2E-102, E2E-102a, E2E-102b, E2E-102c, E2E-102d, E2E-102e, E2E-103, E2E-AGENTS-001, E2E-021a, E2E-024N, E2E-059a, E2E-060b, E2E-060c, E2E-061a, E2E-073a, E2E-111, E2E-114, E2E-117, E2E-118, E2E-119, E2E-120, E2E-122, E2E-123, E2E-142, E2E-143, E2E-144, E2E-145, E2E-146, E2E-147, E2E-148, E2E-150, E2E-151, E2E-153, E2E-155, E2E-158, E2E-159, E2E-160, E2E-161, E2E-162, E2E-163, E2E-168, E2E-172, E2E-173, E2E-174, E2E-011g, E2E-176, E2E-177, E2E-178, E2E-179, E2E-180, E2E-181, E2E-182, E2E-183, E2E-186, E2E-187, E2E-194, E2E-195, E2E-196a, E2E-196b, E2E-196c, E2E-198, E2E-199, E2E-200, E2E-196, E2E-201, E2E-204, E2E-202, E2E-203, E2E-205, E2E-206, E2E-207, E2E-208, E2E-209, E2E-210, E2E-218, E2E-219, E2E-250, E2E-252, E2E-102i, E2E-SUBAGENT-settlement-updates-before-parent-poll, E2E-PLUGIN-imported-pi-package-skills | @@ -6644,6 +6644,8 @@ and identify the platform validation still needed. | Quality (Session Orchestrator) | E2E-PLUGIN-session-orchestrator-real-workers | | C — Conversation & stream (Session list responsiveness) | E2E-SESSION-list-refresh-keeps-desktop-responsive | | Quality (Session list responsiveness) | E2E-SESSION-list-refresh-keeps-desktop-responsive | +| Security (imported extension dependencies) | E2E-PLUGIN-import-extension-installs-dependencies, E2E-PLUGIN-import-extension-reports-missing-dependency | +| Quality (imported extension dependencies) | E2E-PLUGIN-import-extension-installs-dependencies, E2E-PLUGIN-import-extension-reports-missing-dependency | | C — Conversation & stream (Independent session communication) | E2E-SESSION-independent-top-level-communication | | D — Plugin security (Independent session communication) | E2E-SESSION-independent-top-level-communication | | G — Plugins (Independent session communication) | E2E-SESSION-independent-top-level-communication | @@ -6668,7 +6670,7 @@ and identify the platform validation still needed. | Post-MVP | E2E-022A, E2E-022B, E2E-022C, E2E-024I, E2E-024J, E2E-024K, E2E-024L, E2E-024M (plugin roadmap R2/R3/R6) | | Post-baseline local automation | E2E-220 | | Post-MVP remote control | E2E-221, E2E-222, E2E-223, E2E-224, E2E-225, E2E-226, E2E-227, E2E-228, E2E-229, E2E-230, E2E-231, E2E-232 | -| Trusted extensions (R7 v1) | E2E-241, E2E-242, E2E-243, E2E-244, E2E-245, E2E-PLUGIN-imported-pi-package-skills | +| Trusted extensions (R7 v1) | E2E-241, E2E-242, E2E-243, E2E-244, E2E-245, E2E-PLUGIN-imported-pi-package-skills, E2E-PLUGIN-import-extension-installs-dependencies, E2E-PLUGIN-import-extension-reports-missing-dependency | The `US-UI-*` visual scenarios (§UI shell visual scenarios) trace to the Codex parity decisions in [decisions-log §D](../08-meta/decisions-log.md) @@ -10535,6 +10537,46 @@ sample extensions under `apps/desktop/test/fixtures/pi-extensions/`. - **Milestone**: Post-MVP (R7 v1, delivered first as the bundling spike) - **Status**: Unit-covered by `packages/agent-runtime/src/extensions/bundle.test.ts` (esbuild bundle run from a temp directory); packaged-app journey Draft +#### E2E-PLUGIN-import-extension-installs-dependencies: Importing an extension with npm dependencies installs them before first load + +- **Preconditions**: A pi extension directory shipping a `package.json` with + `dependencies` (a pure-JavaScript package is sufficient) and no + `node_modules`; npm reachable; the import confirm accepted. +- **Steps**: 1) Plugins → Import pi extension, pick the directory. 2) + Inspect `plugins/imported//`. 3) Send a prompt that exercises the + extension. +- **Expected**: The plugin root holds the copied `package.json` with any + `workspaces` field stripped and a `node_modules` directory created by + `npm install --omit=dev --legacy-peer-deps --no-audit --no-fund + --ignore-scripts` (no install script ran); the extension row reaches + `loaded` with its tools, commands, and hooks registered, and they take + effect in the turn. +- **Specs linked**: `07-plugins/16-trusted-extensions.md` §3.2, §10.2 +- **Acceptance**: Security, Quality +- **Milestone**: Post-MVP (R7 v1) +- **Status**: Unit-covered by `apps/desktop/test/agent-extensions.test.mjs` + and verified manually with `pi-hermes-memory` through the sidecar bundle + (tools, commands, and hooks registered, zero diagnostics); no CI journey yet + +#### E2E-PLUGIN-import-extension-reports-missing-dependency: A failed dependency install or unloadable dependency is surfaced, never silent + +- **Preconditions**: A pi extension directory whose `package.json` declares + a dependency that cannot install (npm offline or unresolvable); and one + whose dependency installs but fails to load (for example a native module + that needs a build script). +- **Steps**: 1) Import the first directory with npm failing. 2) Inspect the + toast and the plugin row. 3) Import the second directory and start a turn. +- **Expected**: The renderer shows a warning toast carrying the npm stderr + tail; the plugin still registers; the row reports the extension `error` + state with a `load_error` diagnostic; the session and all other extensions + keep working. +- **Specs linked**: `07-plugins/16-trusted-extensions.md` §3.2, §4.4, §10.2 +- **Acceptance**: Security, Quality +- **Milestone**: Post-MVP (R7 v1) +- **Status**: Unit-covered by `apps/desktop/test/agent-extensions.test.mjs` + (skip, failure, and invalid-manifest paths); no CI journey yet + + --- #### E2E-233: Icon-only actions explain their purpose in the active language diff --git a/docs/spec/07-plugins/16-trusted-extensions.md b/docs/spec/07-plugins/16-trusted-extensions.md index f9f98cfe8..3ced6dbaa 100644 --- a/docs/spec/07-plugins/16-trusted-extensions.md +++ b/docs/spec/07-plugins/16-trusted-extensions.md @@ -84,6 +84,16 @@ A package that explicitly declares `pi.skills` and has no `pi.extensions` (or an empty array) is skill-only: incidental scripts, including `index.js`, are copied as resources but never promoted to executable agent extensions. +A directory that ships a `package.json` also has it (plus its lockfile) copied +to the plugin root with any `workspaces` field stripped; if it declares +`dependencies`, main installs them into the plugin root before the first load +with `npm install --omit=dev --legacy-peer-deps --no-audit --no-fund +--ignore-scripts` (bounded time, no third-party install script ever runs, +kernel packages keep resolving through virtual modules). A failed install is +reported to the renderer and never blocks the import — the extension then +reports its own load error. The confirm discloses the npm step alongside the +skills disclosure. + | Source | Becomes | |---|---| | A pi extension directory or file | A local plugin under `plugins/imported`, id `imported.` | @@ -119,10 +129,10 @@ failure. The generated destination must not be inside the selected source. This is an explicit local import, not a pi CLI package manager. It never automatically scans or imports `~/.pi`, does not read the CLI's installed -package registry, and does not run npm installation or package lifecycle -scripts. Full CLI package semantics and dependency installation/resolution -remain separate work (including PR #277); importing a package does not -promise that every third-party extension dependency can execute. +package registry, and does not run npm lifecycle scripts. When dependencies +are declared, the bounded installer accepts only registry version specs and +registry-resolved npm lockfiles; importing a package does not promise that +every third-party extension dependency can execute. ## 4. Loading and runtime diff --git a/docs/zh-CN/plugin-development.md b/docs/zh-CN/plugin-development.md index 00b36d822..5a710e990 100644 --- a/docs/zh-CN/plugin-development.md +++ b/docs/zh-CN/plugin-development.md @@ -635,7 +635,12 @@ export default function (pi) { `registerMessageRenderer`、`navigateTree` 及其他仅终端可用的界面)是空操作,在插件行 的详情里报告,绝不抛出。 - **已有的 pi 扩展**无需修改:插件页 → 溢出菜单 →“导入 pi 扩展”会把文件或目录包成 - 生成的插件。 + 生成的插件。若目录自带声明了 `dependencies` 的 `package.json`,会在首次加载前把 + 依赖安装到插件根(`npm install --omit=dev --legacy-peer-deps --no-audit --no-fund + --ignore-scripts`):绝不运行第三方安装脚本,因此需要构建脚本的原生模块会以诊断的 + 形式加载失败——在该插件目录内用 Electron 头重建(`npx @electron/rebuild -v + `)即可修复。安装失败绝不阻塞导入;你会收到带 npm 错误的警告 + toast,插件行显示加载错误。 ## 7.权限设计 diff --git a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md index 2ca967e07..54012775d 100644 --- a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md @@ -4679,7 +4679,7 @@ IPC 请求无法关闭。 | E——工具和权限 | E2E-008a、E2E-014、E2E-015、E2E-016、E2E-017、E2E-018、E2E-019、E2E-024I、E2E-024K、E2E-040、E2E-049、E2E-074、E2E-093、E2E-097、 E2E-099、E2E-100、E2E-101、E2E-102、E2E-103、E2E-105、E2E-106、E2E-107、E2E-111、E2E-112、E2E-113、E2E-114、E2E-115、E2E-116、 E2E-119、E2E-121、E2E-122、E2E-123、E2E-142、E2E-145、E2E-147、E2E-PLUGIN-imported-pi-package-skills、E2E-166 | | F——坚持 | E2E-020、E2E-021、E2E-036、E2E-037、E2E-038、E2E-040、E2E-042、E2E-047、E2E-048、E2E-051、E2E-054、E2E-056、E2E-061、E2E-062、 E2E-064、E2E-066、E2E-068、E2E-071、E2E-072、E2E-073、E2E-082、E2E-084、E2E-096、E2E-098、E2E-102、E2E-102b、E2E-103、E2E-代理-001、 E2E-061a、E2E-073a、E2E-104、E2E-106、E2E-107、E2E-108、E2E-109、E2E-110、E2E-112、E2E-118、E2E-119、E2E-120、E2E-121、E2E-123、E2E-142、E2E-146、E2E-148、E2E-151、E2E-171、E2E-005J | | F——持久化(项目排序) | E2E-253 | -| G——插件 | E2E-022、E2E-022A、E2E-022B、E2E-022C、E2E-023、E2E-024、E2E-024B、E2E-024C、E2E-024D、E2E-024E、E2E-024W、E2E-024F、E2E-024G、E2E-024H、 E2E-024I、E2E-024J、E2E-024K、E2E-024L、E2E-024M、E2E-024N、E2E-024O、E2E-024P、E2E-025、E2E-026、E2E-105、E2E-117、E2E-120、E2E-122、E2E-123、E2E-148、E2E-153、E2E-PLUGIN-imported-pi-package-skills | +| G——插件 | E2E-022、E2E-022A、E2E-022B、E2E-022C、E2E-023、E2E-024、E2E-024B、E2E-024C、E2E-024D、E2E-024E、E2E-024W、E2E-024F、E2E-024G、E2E-024H、 E2E-024I、E2E-024J、E2E-024K、E2E-024L、E2E-024M、E2E-024N、E2E-024O、E2E-024P、E2E-025、E2E-026、E2E-105、E2E-117、E2E-120、E2E-122、E2E-123、E2E-148、E2E-153、E2E-PLUGIN-imported-pi-package-skills、E2E-PLUGIN-import-extension-installs-dependencies、E2E-PLUGIN-import-extension-reports-missing-dependency | | H——诊断 | E2E-027、E2E-031、E2E-034、E2E-042、E2E-096、E2E-098、E2E-104、E2E-107、E2E-108、E2E-109、E2E-110、E2E-113、E2E-115、E2E-116、 E2E-118、E2E-121、E2E-146、E2E-194、E2E-195 | | 安全性 | E2E-028、E2E-029、E2E-030、E2E-024J、E2E-024K、E2E-024M、E2E-049、E2E-068、E2E-086、E2E-105、E2E-106、E2E-107、E2E-108、E2E-109、 E2E-110、E2E-112、E2E-113、E2E-115、E2E-116、E2E-117、E2E-119、E2E-121、E2E-122、E2E-123、E2E-142、E2E-148、E2E-151、E2E-153 | | 品质 | E2E-032、E2E-033、E2E-039、E2E-043、E2E-044、E2E-045、E2E-046、E2E-047、E2E-048、E2E-048A、E2E-049、E2E-050、E2E-053、E2E-055、 E2E-056、E2E-057、E2E-058、E2E-059、E2E-060、E2E-061、E2E-062、E2E-063、E2E-064、E2E-065、E2E-066、E2E-067、E2E-068、E2E-069、 E2E-070、E2E-071、E2E-072、E2E-073、E2E-074、E2E-075、E2E-076、E2E-077、E2E-078、E2E-079、E2E-080、E2E-081、E2E-082、E2E-083、 E2E-084、E2E-085、E2E-086、E2E-092、E2E-093、E2E-094、E2E-095、E2E-096、E2E-097、E2E-098、E2E-099、E2E-100、E2E-101、E2E-102、 E2E-102a、E2E-102b、E2E-103、E2E-AGENTS-001、E2E-024N、E2E-024O、E2E-059a、E2E-060b、E2E-060c、E2E-060d、E2E-061a、E2E-073a、E2E-111、 E2E-114、E2E-117、E2E-118、E2E-119、E2E-120、E2E-122、E2E-123、E2E-142、E2E-143、E2E-144、E2E-145、E2E-146、E2E-147、E2E-148、E2E-150、E2E-151、E2E-153、E2E-194、E2E-195、E2E-199、E2E-200、E2E-201、E2E-202、E2E-203、E2E-204、E2E-209、E2E-210、E2E-250、E2E-PLUGIN-imported-pi-package-skills | @@ -4696,6 +4696,8 @@ IPC 请求无法关闭。 | 品质(Session Orchestrator) | E2E-PLUGIN-session-orchestrator-real-workers | | C — 对话与流式(会话列表响应性) | E2E-SESSION-list-refresh-keeps-desktop-responsive | | 品质(会话列表响应性) | E2E-SESSION-list-refresh-keeps-desktop-responsive | +| 安全性(导入扩展依赖) | E2E-PLUGIN-import-extension-installs-dependencies、E2E-PLUGIN-import-extension-reports-missing-dependency | +| 品质(导入扩展依赖) | E2E-PLUGIN-import-extension-installs-dependencies、E2E-PLUGIN-import-extension-reports-missing-dependency | | C — 对话与流式(独立会话通信) | E2E-SESSION-independent-top-level-communication | | D — 插件安全(独立会话通信) | E2E-SESSION-independent-top-level-communication | | G — 插件(独立会话通信) | E2E-SESSION-independent-top-level-communication | @@ -4720,7 +4722,7 @@ IPC 请求无法关闭。 | 后MVP | E2E-022A、E2E-022B、E2E-022C、E2E-024I、E2E-024J、E2E-024K、E2E-024L、E2E-024M(插件路线图 R2/R3/R6) | | 基线后本地自动化 | E2E-220 | | MVP 后远程控制 | E2E-221、E2E-222、E2E-223、E2E-224、E2E-225、E2E-226、E2E-227、E2E-228、E2E-229、E2E-230、E2E-231、E2E-232 | -| 受信任扩展(R7 v1) | E2E-241、E2E-242、E2E-243、E2E-244、E2E-245、E2E-PLUGIN-imported-pi-package-skills | +| 受信任扩展(R7 v1) | E2E-241、E2E-242、E2E-243、E2E-244、E2E-245、E2E-PLUGIN-imported-pi-package-skills、E2E-PLUGIN-import-extension-installs-dependencies、E2E-PLUGIN-import-extension-reports-missing-dependency | `US-UI-*` 视觉场景(§UI shell 视觉场景)追踪到 [决策日志 §D](/zh-CN/spec/08-meta/decisions-log) 中的法典平价决策 @@ -6557,6 +6559,38 @@ IPC 请求无法关闭。 - **里程碑**:MVP 后(R7 v1,作为打包 spike 首先交付) - **状态**:由 `packages/agent-runtime/src/extensions/bundle.test.ts` 单元覆盖 (esbuild 打包产物在临时目录运行);打包应用旅程为草稿 +#### E2E-PLUGIN-import-extension-installs-dependencies:导入带 npm 依赖的扩展会在首次加载前安装依赖 + +- **前置条件**:一个自带 `package.json` 且声明了 `dependencies`(纯 JavaScript 包即可)、 + 无 `node_modules` 的 pi 扩展目录;npm 可达;导入确认已接受。 +- **步骤**:1)插件页 → 导入 pi 扩展,选择该目录。2)检查 `plugins/imported//`。 + 3)发送一个会用到该扩展的提示。 +- **预期**:插件根有复制来的 `package.json`(`workspaces` 字段已被剥离)和由 + `npm install --omit=dev --legacy-peer-deps --no-audit --no-fund --ignore-scripts` + 创建的 `node_modules`(没有运行任何安装脚本);扩展行达到 `loaded`,工具、命令与 + hooks 均已注册,并在回合中生效。 +- **链接规格**:`07-plugins/16-trusted-extensions.md` §3.2、§10.2 +- **验收**:安全、质量 +- **里程碑**:MVP 后(R7 v1) +- **状态**:由 `apps/desktop/test/agent-extensions.test.mjs` 单元覆盖,并已用 + `pi-hermes-memory` 经 sidecar 打包产物人工验证(工具、命令与 hooks 注册成功,零 + 诊断);暂无 CI 旅程 + +#### E2E-PLUGIN-import-extension-reports-missing-dependency:依赖安装失败或依赖无法加载会被呈现,绝不静默 + +- **前置条件**:一个 `package.json` 声明了无法安装依赖(npm 离线或无法解析)的 pi 扩展 + 目录;以及一个依赖可安装但无法加载(例如需要构建脚本的原生模块)的扩展目录。 +- **步骤**:1)在 npm 失败的情况下导入第一个目录。2)检查 toast 与插件行。3)导入 + 第二个目录并开始回合。 +- **预期**:渲染层出现携带 npm stderr 尾部的警告 toast;插件仍然注册;该行显示扩展 + `error` 状态与 `load_error` 诊断;会话与其他扩展均不受影响。 +- **链接规格**:`07-plugins/16-trusted-extensions.md` §3.2、§4.4、§10.2 +- **验收**:安全、质量 +- **里程碑**:MVP 后(R7 v1) +- **状态**:由 `apps/desktop/test/agent-extensions.test.mjs`(跳过、失败与无效 manifest + 路径)单元覆盖;暂无 CI 旅程 + + #### E2E-234:工作区安全拒绝名单与忽略层 - **前提条件**:一个项目包含 `.env`、`.env.example`、`server.pem`、`keys/id_rsa`、 diff --git a/docs/zh-CN/spec/07-plugins/16-trusted-extensions.md b/docs/zh-CN/spec/07-plugins/16-trusted-extensions.md index 10a3880fd..5fe3efecc 100644 --- a/docs/zh-CN/spec/07-plugins/16-trusted-extensions.md +++ b/docs/zh-CN/spec/07-plugins/16-trusted-extensions.md @@ -72,6 +72,13 @@ agent 循环上注册工具、命令和事件处理器。`ExtensionAPI` 契约 的包视为仅技能包:包括 `index.js` 在内的附带脚本作为资源复制,不会被提升为可执行的 Agent 扩展。 +目录若自带 `package.json`,会(连同其 lockfile)一并复制到插件根并剥离 `workspaces` 字段; +若声明了 `dependencies`,main 会在首次加载前把依赖安装到插件根,命令为 +`npm install --omit=dev --legacy-peer-deps --no-audit --no-fund --ignore-scripts` +(限时执行、绝不运行第三方安装脚本、内核包继续经 virtual modules 解析)。安装失败会上报 +渲染层且绝不阻塞导入——扩展随后上报自身的 load error。确认对话框会与技能披露一并说明 +npm 安装步骤。 + | 来源 | 结果 | |---|---| | 一个 pi 扩展目录或文件 | `plugins/imported` 下的本地插件,id 为 `imported.` | @@ -97,8 +104,8 @@ Agent 扩展。 会清理部分生成的目录。生成目标不能位于所选源目录内。 这是显式本地导入,不是 pi CLI 包管理器:不会自动扫描或导入 `~/.pi`,不会读取 CLI -已安装包注册表,也不会执行 npm 安装或包生命周期脚本。完整 CLI 包语义及依赖安装、 -解析仍属独立工作(包括 PR #277);导入包不代表其所有第三方扩展依赖都能执行。 +已安装包注册表,也不会执行 npm 生命周期脚本。声明依赖时,有界安装器只接受 registry +版本说明和 registry 来源的 npm lockfile;导入包不代表其所有第三方扩展依赖都能执行。 ## 4. 加载与运行时 diff --git a/packages/i18n/src/locales/de/index.ts b/packages/i18n/src/locales/de/index.ts index 9bbf3d4d2..ee9bbea2d 100644 --- a/packages/i18n/src/locales/de/index.ts +++ b/packages/i18n/src/locales/de/index.ts @@ -1457,9 +1457,10 @@ sklm: { "loadDevDone": "Lokales Plugin geladen", "importExtension": "pi-Erweiterung importieren", "importExtensionDone": "Als Plugin {{id}} importiert", + "importExtensionDepsFailed": "Als Plugin {{id}} importiert, aber die Abhängigkeiten konnten nicht installiert werden: {{error}}", "agentExtension": { "title": "Agent-Erweiterung", - "importConfirm": "Importierte Erweiterungen laufen im Agentenprozess mit denselben Rechten wie die Tools des Agenten. Deklarierte Skills können dem Agenten Anweisungen hinzufügen. Fortfahren?", +"importConfirm": "Importierte Erweiterungen laufen im Agentenprozess mit denselben Rechten wie die Tools des Agenten. Deklarierte Skills können dem Agenten Anweisungen hinzufügen, und deklarierte Abhängigkeiten werden mit npm installiert (Installationsskripte deaktiviert). Fortfahren?", "diagnostics": "Diagnosen", "commandNeedsSession": "Starte zuerst einen Chat, um einen Erweiterungsbefehl auszuführen.", "state": { diff --git a/packages/i18n/src/locales/en/index.ts b/packages/i18n/src/locales/en/index.ts index c34b4e770..c62e81a06 100644 --- a/packages/i18n/src/locales/en/index.ts +++ b/packages/i18n/src/locales/en/index.ts @@ -1474,9 +1474,10 @@ sklm: { loadDevDone: "Local plugin loaded", importExtension: "Import pi extension", importExtensionDone: "Imported as plugin {{id}}", + importExtensionDepsFailed: "Imported as plugin {{id}}, but installing dependencies failed: {{error}}", agentExtension: { title: "Agent extension", - importConfirm: "Imported extensions run inside the agent process with the same access as the agent's own tools. Declared skills can add instructions to the agent. Continue?", +importConfirm: "Imported extensions run inside the agent process with the same access as the agent's own tools. Declared skills can add instructions to the agent, and declared dependencies are installed with npm (install scripts disabled). Continue?", diagnostics: "Diagnostics", commandNeedsSession: "Start a chat first to run an extension command.", state: { diff --git a/packages/i18n/src/locales/es/index.ts b/packages/i18n/src/locales/es/index.ts index cb21b3a22..e239fbc6c 100644 --- a/packages/i18n/src/locales/es/index.ts +++ b/packages/i18n/src/locales/es/index.ts @@ -1457,9 +1457,10 @@ sklm: { "loadDevDone": "Complemento local cargado", "importExtension": "Importar extensión de pi", "importExtensionDone": "Importada como complemento {{id}}", + "importExtensionDepsFailed": "Importada como complemento {{id}}, pero falló la instalación de dependencias: {{error}}", "agentExtension": { "title": "Extensión del agente", - "importConfirm": "Las extensiones importadas se ejecutan en el proceso del agente con el mismo acceso que sus propias herramientas. Las habilidades declaradas pueden añadir instrucciones al agente. ¿Continuar?", +"importConfirm": "Las extensiones importadas se ejecutan en el proceso del agente con el mismo acceso que sus propias herramientas. Las habilidades declaradas pueden añadir instrucciones al agente y las dependencias declaradas se instalan con npm (scripts de instalación desactivados). ¿Continuar?", "diagnostics": "Diagnósticos", "commandNeedsSession": "Inicia un chat antes de ejecutar un comando de extensión.", "state": { diff --git a/packages/i18n/src/locales/fr/index.ts b/packages/i18n/src/locales/fr/index.ts index c6d46b692..66f0d75d2 100644 --- a/packages/i18n/src/locales/fr/index.ts +++ b/packages/i18n/src/locales/fr/index.ts @@ -1457,9 +1457,10 @@ sklm: { "loadDevDone": "Plugin local chargé", "importExtension": "Importer une extension pi", "importExtensionDone": "Importée comme plugin {{id}}", + "importExtensionDepsFailed": "Importée comme plugin {{id}}, mais l'installation des dépendances a échoué : {{error}}", "agentExtension": { "title": "Extension de l'agent", - "importConfirm": "Les extensions importées s’exécutent dans le processus de l’agent avec les mêmes droits que ses outils. Les compétences déclarées peuvent ajouter des instructions à l’agent. Continuer ?", +"importConfirm": "Les extensions importées s’exécutent dans le processus de l’agent avec les mêmes droits que ses outils. Les compétences déclarées peuvent ajouter des instructions à l’agent et les dépendances déclarées sont installées avec npm (scripts d’installation désactivés). Continuer ?", "diagnostics": "Diagnostics", "commandNeedsSession": "Démarrez d'abord une discussion pour exécuter une commande d'extension.", "state": { diff --git a/packages/i18n/src/locales/ko/index.ts b/packages/i18n/src/locales/ko/index.ts index a572c1d71..facf67ecf 100644 --- a/packages/i18n/src/locales/ko/index.ts +++ b/packages/i18n/src/locales/ko/index.ts @@ -1476,9 +1476,10 @@ sklm: { loadDevDone: "로컬 플러그인 불러옴", importExtension: "pi 확장 가져오기", importExtensionDone: "플러그인 {{id}}(으)로 가져왔습니다", + importExtensionDepsFailed: "플러그인 {{id}}(으)로 가져왔지만 의존성 설치에 실패했습니다: {{error}}", agentExtension: { title: "에이전트 확장", - importConfirm: "가져온 확장은 에이전트 프로세스 안에서 에이전트 자체 도구와 같은 권한으로 실행됩니다. 선언된 스킬은 에이전트에 지침을 추가할 수 있습니다. 계속할까요?", +importConfirm: "가져온 확장은 에이전트 프로세스 안에서 에이전트 자체 도구와 같은 권한으로 실행됩니다. 선언된 스킬은 에이전트에 지침을 추가할 수 있고, 선언된 의존성은 npm으로 설치됩니다(설치 스크립트 비활성화). 계속할까요?", diagnostics: "진단", commandNeedsSession: "확장 명령을 실행하려면 먼저 채팅을 시작하세요.", state: { diff --git a/packages/i18n/src/locales/tr/index.ts b/packages/i18n/src/locales/tr/index.ts index a28efb6c8..244227338 100644 --- a/packages/i18n/src/locales/tr/index.ts +++ b/packages/i18n/src/locales/tr/index.ts @@ -1476,9 +1476,10 @@ sklm: { loadDevDone: "Yerel eklenti yüklendi", importExtension: "pi uzantısını içe aktar", importExtensionDone: "{{id}} eklentisi olarak içe aktarıldı", + importExtensionDepsFailed: "{{id}} eklentisi olarak içe aktarıldı, ancak bağımlılıklar yüklenemedi: {{error}}", agentExtension: { title: "Ajan uzantısı", - importConfirm: "İçe aktarılan uzantılar ajan sürecinde, ajanın kendi araçlarıyla aynı erişimle çalışır. Bildirilen beceriler ajana talimatlar ekleyebilir. Devam edilsin mi?", +importConfirm: "İçe aktarılan uzantılar ajan sürecinde, ajanın kendi araçlarıyla aynı erişimle çalışır. Bildirilen beceriler ajana talimatlar ekleyebilir ve bildirilen bağımlılıklar npm ile yüklenir (yükleme betikleri devre dışı). Devam edilsin mi?", diagnostics: "Tanılamalar", commandNeedsSession: "Uzantı komutu çalıştırmak için önce bir sohbet başlatın.", state: { diff --git a/packages/i18n/src/locales/zh-CN/index.ts b/packages/i18n/src/locales/zh-CN/index.ts index d6be454f6..cc0110a85 100644 --- a/packages/i18n/src/locales/zh-CN/index.ts +++ b/packages/i18n/src/locales/zh-CN/index.ts @@ -1464,9 +1464,10 @@ sklm: { loadDevDone: "本地插件已加载", importExtension: "导入 pi 扩展", importExtensionDone: "已导入为插件 {{id}}", + importExtensionDepsFailed: "已导入为插件 {{id}},但依赖安装失败:{{error}}", agentExtension: { title: "Agent 扩展", - importConfirm: "导入的扩展会在 agent 进程内运行,拥有与 agent 自身工具相同的权限;包声明的技能可向 agent 提供指令。继续吗?", + importConfirm: "导入的扩展会在 agent 进程内运行,拥有与 agent 自身工具相同的权限;包声明的技能可向 agent 提供指令,声明的依赖将通过 npm 安装(禁用安装脚本)。继续吗?", diagnostics: "诊断", commandNeedsSession: "请先开始一个对话,再运行扩展命令。", state: { diff --git a/packages/i18n/src/locales/zh-TW/index.ts b/packages/i18n/src/locales/zh-TW/index.ts index 89251cc01..d35100042 100644 --- a/packages/i18n/src/locales/zh-TW/index.ts +++ b/packages/i18n/src/locales/zh-TW/index.ts @@ -1464,9 +1464,10 @@ sklm: { loadDevDone: "本地外掛已載入", importExtension: "匯入 pi 擴充", importExtensionDone: "已匯入為外掛 {{id}}", + importExtensionDepsFailed: "已匯入為外掛 {{id}},但相依套件安裝失敗:{{error}}", agentExtension: { title: "Agent 擴充", - importConfirm: "匯入的擴充會在 agent 程序內執行,擁有與 agent 自身工具相同的權限;套件宣告的技能可向 agent 提供指令。要繼續嗎?", + importConfirm: "匯入的擴充會在 agent 程序內執行,擁有與 agent 自身工具相同的權限;套件宣告的技能可向 agent 提供指令,宣告的相依套件將透過 npm 安裝(停用安裝指令碼)。要繼續嗎?", diagnostics: "診斷", commandNeedsSession: "請先開始一個對話,再執行擴充命令。", state: {