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..1e5554515 100644 --- a/apps/desktop/electron/main/agent-extensions.ts +++ b/apps/desktop/electron/main/agent-extensions.ts @@ -8,8 +8,9 @@ * 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 { assertImportedPackagePath, discoverImportedPackageSkills } from "./imported-package-skills"; import { discoverManualPath } from "@pi-desktop/agent-runtime"; @@ -237,6 +238,127 @@ 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; +} + +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. + const child = spawn(command, args, { + cwd, + shell: process.platform === "win32", + stdio: ["ignore", "ignore", "pipe"], + }); + 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?: Record }; + 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)}`, + }; + } + if (!manifest.dependencies || Object.keys(manifest.dependencies).length === 0) { + return { state: "skipped", reason: "no-dependencies" }; + } + 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 +372,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 +455,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 6bf40c2b7..da8518e65 100644 --- a/apps/desktop/src/lib/api.ts +++ b/apps/desktop/src/lib/api.ts @@ -789,9 +789,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..fbdd3fd52 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 = {}) { @@ -119,3 +120,126 @@ 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("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 2bfea4141..a4e1b7444 100644 --- a/docs/plugin-development.md +++ b/docs/plugin-development.md @@ -723,7 +723,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 870cdadd0..3f2a98b26 100644 --- a/docs/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/spec/06-delivery/04-e2e-test-plan.md @@ -10243,6 +10243,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..ec1b2ff45 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.` | 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 b283ce708..fb26fbd11 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 @@ -6435,6 +6435,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..5547ee3a4 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.` | diff --git a/packages/i18n/src/locales/de/index.ts b/packages/i18n/src/locales/de/index.ts index 0bdecb92b..118b1f225 100644 --- a/packages/i18n/src/locales/de/index.ts +++ b/packages/i18n/src/locales/de/index.ts @@ -1383,9 +1383,10 @@ export const de = { "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 b075356f9..da929e7f7 100644 --- a/packages/i18n/src/locales/en/index.ts +++ b/packages/i18n/src/locales/en/index.ts @@ -1400,9 +1400,10 @@ export const en = { 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 7731c2723..3b2b72f7b 100644 --- a/packages/i18n/src/locales/es/index.ts +++ b/packages/i18n/src/locales/es/index.ts @@ -1383,9 +1383,10 @@ export const es = { "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 35414664e..0a12f4a53 100644 --- a/packages/i18n/src/locales/fr/index.ts +++ b/packages/i18n/src/locales/fr/index.ts @@ -1383,9 +1383,10 @@ export const fr = { "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 a0b58c33a..95f528521 100644 --- a/packages/i18n/src/locales/ko/index.ts +++ b/packages/i18n/src/locales/ko/index.ts @@ -1402,9 +1402,10 @@ export const ko = { 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 6c1b3688f..3d602b9cb 100644 --- a/packages/i18n/src/locales/tr/index.ts +++ b/packages/i18n/src/locales/tr/index.ts @@ -1402,9 +1402,10 @@ export const tr = { 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 2bfa7b6b8..c44cac3ef 100644 --- a/packages/i18n/src/locales/zh-CN/index.ts +++ b/packages/i18n/src/locales/zh-CN/index.ts @@ -1392,9 +1392,10 @@ export const zhCN = { 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 84327a3bd..764c57c82 100644 --- a/packages/i18n/src/locales/zh-TW/index.ts +++ b/packages/i18n/src/locales/zh-TW/index.ts @@ -1392,9 +1392,10 @@ export const zhTW = { loadDevDone: "本地外掛已載入", importExtension: "匯入 pi 擴充", importExtensionDone: "已匯入為外掛 {{id}}", + importExtensionDepsFailed: "已匯入為外掛 {{id}},但相依套件安裝失敗:{{error}}", agentExtension: { title: "Agent 擴充", - importConfirm: "匯入的擴充會在 agent 程序內執行,擁有與 agent 自身工具相同的權限;套件宣告的技能可向 agent 提供指令。要繼續嗎?", + importConfirm: "匯入的擴充會在 agent 程序內執行,擁有與 agent 自身工具相同的權限;套件宣告的技能可向 agent 提供指令,宣告的相依套件將透過 npm 安裝(停用安裝指令碼)。要繼續嗎?", diagnostics: "診斷", commandNeedsSession: "請先開始一個對話,再執行擴充命令。", state: {