From 9d4bd065d66085c7d9eb2acda3b0836c9df20eda Mon Sep 17 00:00:00 2001 From: muzimu217 <1278844978@qq.com> Date: Sat, 12 Sep 2026 04:34:34 -0700 Subject: [PATCH 1/7] feat(plugins): install imported pi extension dependencies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Importing a pi CLI extension stripped node_modules and copied only the sources, so any extension with npm dependencies failed to load (jiti reported "Cannot find module ..." and the whole extension was skipped). The generated plugin now keeps the source package.json (plus lockfile) at the plugin root and installs declared dependencies there before the first load: npm install --omit=dev --legacy-peer-deps --no-audit --no-fund --ignore-scripts. Kernel packages keep resolving through virtual modules, and --ignore-scripts means no third-party install script ever runs; a native module that needs one reports its own load error. A failed install never blocks the import — the renderer shows a warning toast with the npm stderr tail. Verified with pi-hermes-memory: the imported plugin loads in the sidecar runtime with its tools, slash commands, and turn hooks registered (previously a load_error). --- .../electron/main/agent-extensions-ipc.ts | 11 +- .../desktop/electron/main/agent-extensions.ts | 122 +++++++++++++++++- apps/desktop/src/lib/api.ts | 16 ++- apps/desktop/test/agent-extensions.test.mjs | 74 +++++++++++ packages/i18n/src/locales/de/index.ts | 3 +- packages/i18n/src/locales/en/index.ts | 3 +- packages/i18n/src/locales/es/index.ts | 3 +- packages/i18n/src/locales/fr/index.ts | 3 +- packages/i18n/src/locales/ko/index.ts | 3 +- packages/i18n/src/locales/tr/index.ts | 3 +- packages/i18n/src/locales/zh-CN/index.ts | 5 + packages/i18n/src/locales/zh-TW/index.ts | 5 + 12 files changed, 238 insertions(+), 13 deletions(-) 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..f4f20ab95 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,115 @@ 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; + +function dependencyErrorTail(text: string): string { + return text.length > DEPENDENCY_ERROR_TAIL_CHARS + ? `…${text.slice(-DEPENDENCY_ERROR_TAIL_CHARS)}` + : text; +} + +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. + 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"); + }); + const timer = setTimeout(() => { + stderr += `\nnpm install exceeded ${timeoutMs}ms and was terminated`; + child.kill("SIGTERM"); + }, timeoutMs); + child.on("error", (err) => { + clearTimeout(timer); + reject(err); + }); + child.on("close", (code) => { + clearTimeout(timer); + 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 +360,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 +443,10 @@ export function generateImportedExtensionPlugin( "// Generated by PI-Desktop: declarative skills and/or agent extensions.\nmodule.exports = {};\n", "utf8", ); + if (isDirectory) { + for (const file of ["package.json", "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/lib/api.ts b/apps/desktop/src/lib/api.ts index 6d7ae8ffa..f4ba7c29c 100644 --- a/apps/desktop/src/lib/api.ts +++ b/apps/desktop/src/lib/api.ts @@ -795,9 +795,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..43cb086f3 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,76 @@ 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("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/packages/i18n/src/locales/de/index.ts b/packages/i18n/src/locales/de/index.ts index dfe6f07ed..12593b22b 100644 --- a/packages/i18n/src/locales/de/index.ts +++ b/packages/i18n/src/locales/de/index.ts @@ -1410,9 +1410,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 f75a3fbf8..f1be6d672 100644 --- a/packages/i18n/src/locales/en/index.ts +++ b/packages/i18n/src/locales/en/index.ts @@ -1427,9 +1427,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 98323e379..081e1a08b 100644 --- a/packages/i18n/src/locales/es/index.ts +++ b/packages/i18n/src/locales/es/index.ts @@ -1410,9 +1410,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 28953eff0..93c7e913a 100644 --- a/packages/i18n/src/locales/fr/index.ts +++ b/packages/i18n/src/locales/fr/index.ts @@ -1410,9 +1410,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 484ed70ee..debbd535c 100644 --- a/packages/i18n/src/locales/ko/index.ts +++ b/packages/i18n/src/locales/ko/index.ts @@ -1429,9 +1429,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 20cfe3638..f153bce8c 100644 --- a/packages/i18n/src/locales/tr/index.ts +++ b/packages/i18n/src/locales/tr/index.ts @@ -1429,9 +1429,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 7636e38e8..d32f7b354 100644 --- a/packages/i18n/src/locales/zh-CN/index.ts +++ b/packages/i18n/src/locales/zh-CN/index.ts @@ -1419,9 +1419,14 @@ export const zhCN = { loadDevDone: "本地插件已加载", importExtension: "导入 pi 扩展", importExtensionDone: "已导入为插件 {{id}}", + importExtensionDepsFailed: "已导入为插件 {{id}},但依赖安装失败:{{error}}", agentExtension: { title: "Agent 扩展", +<<<<<<< HEAD importConfirm: "导入的扩展会在 agent 进程内运行,拥有与 agent 自身工具相同的权限;包声明的技能可向 agent 提供指令。继续吗?", +======= + importConfirm: "导入的扩展将在 agent 进程内运行,拥有与 agent 自身工具相同的权限。声明的依赖将通过 npm 安装(禁用安装脚本)。继续吗?", +>>>>>>> 8e55819 (feat(plugins): install imported pi extension dependencies) diagnostics: "诊断", commandNeedsSession: "请先开始一个对话,再运行扩展命令。", state: { diff --git a/packages/i18n/src/locales/zh-TW/index.ts b/packages/i18n/src/locales/zh-TW/index.ts index 63af911cd..049fa1bd0 100644 --- a/packages/i18n/src/locales/zh-TW/index.ts +++ b/packages/i18n/src/locales/zh-TW/index.ts @@ -1419,9 +1419,14 @@ export const zhTW = { loadDevDone: "本地外掛已載入", importExtension: "匯入 pi 擴充", importExtensionDone: "已匯入為外掛 {{id}}", + importExtensionDepsFailed: "已匯入為外掛 {{id}},但相依套件安裝失敗:{{error}}", agentExtension: { title: "Agent 擴充", +<<<<<<< HEAD importConfirm: "匯入的擴充會在 agent 程序內執行,擁有與 agent 自身工具相同的權限;套件宣告的技能可向 agent 提供指令。要繼續嗎?", +======= + importConfirm: "匯入的擴充將在 agent 程序內執行,擁有與 agent 自身工具相同的權限。宣告的相依套件將透過 npm 安裝(停用安裝指令碼)。要繼續嗎?", +>>>>>>> 8e55819 (feat(plugins): install imported pi extension dependencies) diagnostics: "診斷", commandNeedsSession: "請先開始一個對話,再執行擴充命令。", state: { From 844def972ab99f1076065913b2fabdfa25b1685f Mon Sep 17 00:00:00 2001 From: muzimu217 <1278844978@qq.com> Date: Sat, 12 Sep 2026 10:11:53 -0700 Subject: [PATCH 2/7] docs(plugins): document imported extension dependency install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pair spec 07-plugins/16 §3.2 with the new import flow (package.json kept at the plugin root, workspaces stripped, pinned npm flags, failure never blocking), add the E2E-PLUGIN-import-extension-* scenarios to the test plan in both locales, spell out the dependency behavior and the Electron-headers rebuild workaround in the plugin development guide, and narrow the README claim to disclose the npm step. --- README.md | 2 +- README.zh-CN.md | 2 +- .../desktop/electron/main/agent-extensions.ts | 43 ++++++++++++++--- .../src/features/plugins/usePluginsPage.ts | 10 +++- apps/desktop/test/agent-extensions.test.mjs | 48 +++++++++++++++++++ docs/plugin-development.md | 11 ++++- docs/spec/06-delivery/04-e2e-test-plan.md | 40 ++++++++++++++++ docs/spec/07-plugins/16-trusted-extensions.md | 10 ++++ docs/zh-CN/plugin-development.md | 7 ++- .../spec/06-delivery/04-e2e-test-plan.md | 32 +++++++++++++ .../spec/07-plugins/16-trusted-extensions.md | 7 +++ 11 files changed, 200 insertions(+), 12 deletions(-) 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.ts b/apps/desktop/electron/main/agent-extensions.ts index f4f20ab95..1e5554515 100644 --- a/apps/desktop/electron/main/agent-extensions.ts +++ b/apps/desktop/electron/main/agent-extensions.ts @@ -254,6 +254,8 @@ export type DependencyCommandRunner = ( 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 @@ -261,7 +263,7 @@ function dependencyErrorTail(text: string): string { : text; } -function defaultDependencyRunner( +export function defaultDependencyRunner( command: string, args: string[], cwd: string, @@ -269,6 +271,8 @@ function defaultDependencyRunner( ): 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", @@ -277,18 +281,26 @@ function defaultDependencyRunner( 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); - child.on("error", (err) => { + const settle = (fn: () => void) => { clearTimeout(timer); - reject(err); + if (killTimer) clearTimeout(killTimer); + fn(); + }; + child.on("error", (err) => { + settle(() => reject(err)); }); child.on("close", (code) => { - clearTimeout(timer); - resolve({ code: code ?? 1, stderr }); + settle(() => resolve({ code: code ?? 1, stderr })); }); }); } @@ -444,8 +456,25 @@ export function generateImportedExtensionPlugin( "utf8", ); if (isDirectory) { - for (const file of ["package.json", "package-lock.json", "npm-shrinkwrap.json"]) { - if (existsSync(join(resolved, file))) copyFileSync(join(resolved, file), join(dir, file)); + 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/test/agent-extensions.test.mjs b/apps/desktop/test/agent-extensions.test.mjs index 43cb086f3..85e051822 100644 --- a/apps/desktop/test/agent-extensions.test.mjs +++ b/apps/desktop/test/agent-extensions.test.mjs @@ -144,6 +144,54 @@ test("importing a directory keeps its package.json at the plugin root and never 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); + assert.ok(flooded.stderr.length <= 8192 + 64, "stderr is capped to a rolling 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) => { 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 6fc05c2a7..e3ebb2d2c 100644 --- a/docs/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/spec/06-delivery/04-e2e-test-plan.md @@ -10325,6 +10325,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 471a33e69..11bd5a5c4 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 @@ -6465,6 +6465,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.` | From 782ae768890840ccbacf009dc2d542918f5c31cc Mon Sep 17 00:00:00 2001 From: muzimu217 <1278844978@qq.com> Date: Sat, 12 Sep 2026 10:20:10 -0700 Subject: [PATCH 3/7] test(plugins): make the stderr cap assertion platform-agnostic Linux pipes deliver the flood in smaller chunks, so the buffer can sit between the keep size and the 2x trim threshold when the child exits. Assert the actual invariant of the rolling cap (bounded by 2x the keep size after every chunk) instead of the single-chunk macOS result. --- apps/desktop/test/agent-extensions.test.mjs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/desktop/test/agent-extensions.test.mjs b/apps/desktop/test/agent-extensions.test.mjs index 85e051822..fbdd3fd52 100644 --- a/apps/desktop/test/agent-extensions.test.mjs +++ b/apps/desktop/test/agent-extensions.test.mjs @@ -180,7 +180,9 @@ test("default runner caps captured stderr and escalates the timeout kill", async 30_000, ); assert.equal(flooded.code, 0); - assert.ok(flooded.stderr.length <= 8192 + 64, "stderr is capped to a rolling tail"); + // 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, From fbf30a4688a94042371dbdd2341d5af83fe936ef Mon Sep 17 00:00:00 2001 From: muzimu217 <1278844978@qq.com> Date: Sat, 12 Sep 2026 10:20:10 -0700 Subject: [PATCH 4/7] chore(i18n): merge importConfirm copy with the 288 stack Both PRs rewrote the import confirmation in every locale; the combined copy discloses the skills addition and the npm dependency install. --- packages/i18n/src/locales/zh-CN/index.ts | 6 +----- packages/i18n/src/locales/zh-TW/index.ts | 6 +----- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/packages/i18n/src/locales/zh-CN/index.ts b/packages/i18n/src/locales/zh-CN/index.ts index d32f7b354..a1963dd11 100644 --- a/packages/i18n/src/locales/zh-CN/index.ts +++ b/packages/i18n/src/locales/zh-CN/index.ts @@ -1422,11 +1422,7 @@ export const zhCN = { importExtensionDepsFailed: "已导入为插件 {{id}},但依赖安装失败:{{error}}", agentExtension: { title: "Agent 扩展", -<<<<<<< HEAD - importConfirm: "导入的扩展会在 agent 进程内运行,拥有与 agent 自身工具相同的权限;包声明的技能可向 agent 提供指令。继续吗?", -======= - importConfirm: "导入的扩展将在 agent 进程内运行,拥有与 agent 自身工具相同的权限。声明的依赖将通过 npm 安装(禁用安装脚本)。继续吗?", ->>>>>>> 8e55819 (feat(plugins): install imported pi extension dependencies) + 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 049fa1bd0..0e50e6cb9 100644 --- a/packages/i18n/src/locales/zh-TW/index.ts +++ b/packages/i18n/src/locales/zh-TW/index.ts @@ -1422,11 +1422,7 @@ export const zhTW = { importExtensionDepsFailed: "已匯入為外掛 {{id}},但相依套件安裝失敗:{{error}}", agentExtension: { title: "Agent 擴充", -<<<<<<< HEAD - importConfirm: "匯入的擴充會在 agent 程序內執行,擁有與 agent 自身工具相同的權限;套件宣告的技能可向 agent 提供指令。要繼續嗎?", -======= - importConfirm: "匯入的擴充將在 agent 程序內執行,擁有與 agent 自身工具相同的權限。宣告的相依套件將透過 npm 安裝(停用安裝指令碼)。要繼續嗎?", ->>>>>>> 8e55819 (feat(plugins): install imported pi extension dependencies) + importConfirm: "匯入的擴充會在 agent 程序內執行,擁有與 agent 自身工具相同的權限;套件宣告的技能可向 agent 提供指令,宣告的相依套件將透過 npm 安裝(停用安裝指令碼)。要繼續嗎?", diagnostics: "診斷", commandNeedsSession: "請先開始一個對話,再執行擴充命令。", state: { From 5236f056baafda83e2e9ff9c7a1525216d70e2e8 Mon Sep 17 00:00:00 2001 From: muzimu217 <1278844978@qq.com> Date: Sun, 13 Sep 2026 05:52:41 -0700 Subject: [PATCH 5/7] fix(plugins): close the npm install boundary and the null-manifest hole MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review on #300: npm now runs with an explicit minimal environment (PATH/HOME/TMPDIR/LANG plus forced ignore-scripts/audit/fund flags) so npm tokens, proxy and SSH configuration cannot leak into the install. Dependency specs are registry-only — file:/link:/git/http(s) specs are rejected before npm runs — and a copied lockfile is kept only when every resolved URL points at registry.npmjs.org, otherwise dropped so npm resolves from package.json. The installer also windows-hides the child. JSON.parse results of null or an array return a failed state instead of throwing past the guard; regression tests cover null, [], non-registry specs, and non-registry lockfile drops. --- .../desktop/electron/main/agent-extensions.ts | 54 +++++++++++++++++++ apps/desktop/test/agent-extensions.test.mjs | 37 +++++++++++++ 2 files changed, 91 insertions(+) diff --git a/apps/desktop/electron/main/agent-extensions.ts b/apps/desktop/electron/main/agent-extensions.ts index 1e5554515..5a48f4dfd 100644 --- a/apps/desktop/electron/main/agent-extensions.ts +++ b/apps/desktop/electron/main/agent-extensions.ts @@ -273,10 +273,23 @@ export function defaultDependencyRunner( // 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 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_ignore_scripts: "true", + npm_config_audit: "false", + npm_config_fund: "false", + npm_config_update_notifier: "false", + }, }); let stderr = ""; child.stderr?.on("data", (chunk: Buffer) => { @@ -334,9 +347,50 @@ export async function installExtensionDependencies( 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" }; + } if (!manifest.dependencies || Object.keys(manifest.dependencies).length === 0) { return { state: "skipped", reason: "no-dependencies" }; } + // Registry-only dependency sources: file:/link:/git*/http(s) specs would + // escape the registry boundary this install enforces. + for (const [name, spec] of Object.entries(manifest.dependencies)) { + const value = String(spec); + if (/^(file:|link:|git|git\+|\/|\.\.?\/)|:\/\//i.test(value) || value.startsWith("http")) { + return { + state: "failed", + error: `dependency ${name} uses a non-registry spec (${value}); only registry versions are installable`, + }; + } + } + // 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 { + packages?: Record; + }; + const resolved = Object.values(lock.packages ?? {}) + .map((entry) => entry.resolved ?? "") + .filter(Boolean); + if (resolved.some((url) => !url.startsWith("https://registry.npmjs.org/"))) { + rmSync(lockPath); + } + } catch { + rmSync(lockPath); + } + } else { + rmSync(lockPath); + } + } try { const result = await (options?.runner ?? defaultDependencyRunner)( "npm", diff --git a/apps/desktop/test/agent-extensions.test.mjs b/apps/desktop/test/agent-extensions.test.mjs index fbdd3fd52..688f27bbe 100644 --- a/apps/desktop/test/agent-extensions.test.mjs +++ b/apps/desktop/test/agent-extensions.test.mjs @@ -28,6 +28,43 @@ 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("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("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"]; From 3262170e0f1eda414a727acd2e9788c391fcbd56 Mon Sep 17 00:00:00 2001 From: vastsa Date: Mon, 14 Sep 2026 02:55:41 +0800 Subject: [PATCH 6/7] fix(plugins): close imported dependency source boundary --- .../desktop/electron/main/agent-extensions.ts | 112 ++++++++++++++---- apps/desktop/test/agent-extensions.test.mjs | 66 +++++++++++ docs/spec/06-delivery/04-e2e-test-plan.md | 6 +- docs/spec/07-plugins/16-trusted-extensions.md | 8 +- .../spec/06-delivery/04-e2e-test-plan.md | 5 +- .../spec/07-plugins/16-trusted-extensions.md | 4 +- 6 files changed, 170 insertions(+), 31 deletions(-) diff --git a/apps/desktop/electron/main/agent-extensions.ts b/apps/desktop/electron/main/agent-extensions.ts index 5a48f4dfd..f60ebccbe 100644 --- a/apps/desktop/electron/main/agent-extensions.ts +++ b/apps/desktop/electron/main/agent-extensions.ts @@ -12,6 +12,7 @@ import { spawn } from "node:child_process"; import { createHash, randomUUID } from "node:crypto"; 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 { @@ -263,6 +264,70 @@ function dependencyErrorTail(text: string): string { : 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[], @@ -275,6 +340,8 @@ export function defaultDependencyRunner( // 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", @@ -285,6 +352,12 @@ export function defaultDependencyRunner( 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", @@ -338,7 +411,11 @@ export async function installExtensionDependencies( if (!existsSync(packageJsonPath)) { return { state: "skipped", reason: "no-package-json" }; } - let manifest: { dependencies?: Record }; + let manifest: { + dependencies?: unknown; + optionalDependencies?: unknown; + overrides?: unknown; + }; try { manifest = JSON.parse(readFileSync(packageJsonPath, "utf8")); } catch (err) { @@ -352,19 +429,17 @@ export async function installExtensionDependencies( if (manifest === null || typeof manifest !== "object" || Array.isArray(manifest)) { return { state: "failed", error: "package.json is not a JSON object" }; } - if (!manifest.dependencies || Object.keys(manifest.dependencies).length === 0) { - return { state: "skipped", reason: "no-dependencies" }; + for (const field of ["dependencies", "optionalDependencies"] as const) { + const error = dependencyMapError(field, manifest[field]); + if (error) return { state: "failed", error }; } - // Registry-only dependency sources: file:/link:/git*/http(s) specs would - // escape the registry boundary this install enforces. - for (const [name, spec] of Object.entries(manifest.dependencies)) { - const value = String(spec); - if (/^(file:|link:|git|git\+|\/|\.\.?\/)|:\/\//i.test(value) || value.startsWith("http")) { - return { - state: "failed", - error: `dependency ${name} uses a non-registry spec (${value}); only registry versions are installable`, - }; - } + 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 @@ -375,15 +450,8 @@ export async function installExtensionDependencies( if (!existsSync(lockPath)) continue; if (name === "package-lock.json" || name === "npm-shrinkwrap.json") { try { - const lock = JSON.parse(readFileSync(lockPath, "utf8")) as { - packages?: Record; - }; - const resolved = Object.values(lock.packages ?? {}) - .map((entry) => entry.resolved ?? "") - .filter(Boolean); - if (resolved.some((url) => !url.startsWith("https://registry.npmjs.org/"))) { - rmSync(lockPath); - } + const lock = JSON.parse(readFileSync(lockPath, "utf8")) as unknown; + if (lockfileHasUnsafeSource(lock)) rmSync(lockPath); } catch { rmSync(lockPath); } diff --git a/apps/desktop/test/agent-extensions.test.mjs b/apps/desktop/test/agent-extensions.test.mjs index 688f27bbe..caf492f41 100644 --- a/apps/desktop/test/agent-extensions.test.mjs +++ b/apps/desktop/test/agent-extensions.test.mjs @@ -53,6 +53,33 @@ test("non-registry dependency specs are rejected before npm runs", async () => { 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" } })); @@ -64,6 +91,25 @@ test("a lockfile with non-registry resolved urls is dropped before install", asy 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(); @@ -230,6 +276,26 @@ test("default runner caps captured stderr and escalates the timeout kill", async 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-")); diff --git a/docs/spec/06-delivery/04-e2e-test-plan.md b/docs/spec/06-delivery/04-e2e-test-plan.md index e3ebb2d2c..17f7ae187 100644 --- a/docs/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/spec/06-delivery/04-e2e-test-plan.md @@ -6537,7 +6537,7 @@ 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 | @@ -6554,6 +6554,8 @@ 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 | | Milestone | Scenarios | |---|---| @@ -6572,7 +6574,7 @@ 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) diff --git a/docs/spec/07-plugins/16-trusted-extensions.md b/docs/spec/07-plugins/16-trusted-extensions.md index ec1b2ff45..3ced6dbaa 100644 --- a/docs/spec/07-plugins/16-trusted-extensions.md +++ b/docs/spec/07-plugins/16-trusted-extensions.md @@ -129,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/spec/06-delivery/04-e2e-test-plan.md b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md index 11bd5a5c4..b9ae63b4e 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 @@ -4636,6 +4636,9 @@ IPC 请求无法关闭。 | 品质(Session Orchestrator) | E2E-PLUGIN-session-orchestrator-real-workers | | C — 对话与流式(会话列表响应性) | E2E-SESSION-list-refresh-keeps-desktop-responsive | | 品质(会话列表响应性) | E2E-SESSION-list-refresh-keeps-desktop-responsive | +| G——插件(导入扩展依赖) | 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 | +| 品质(导入扩展依赖) | E2E-PLUGIN-import-extension-installs-dependencies、E2E-PLUGIN-import-extension-reports-missing-dependency | | 里程碑 | 应用场景 | |---|---| @@ -4654,7 +4657,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) 中的法典平价决策 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 5547ee3a4..5fe3efecc 100644 --- a/docs/zh-CN/spec/07-plugins/16-trusted-extensions.md +++ b/docs/zh-CN/spec/07-plugins/16-trusted-extensions.md @@ -104,8 +104,8 @@ npm 安装步骤。 会清理部分生成的目录。生成目标不能位于所选源目录内。 这是显式本地导入,不是 pi CLI 包管理器:不会自动扫描或导入 `~/.pi`,不会读取 CLI -已安装包注册表,也不会执行 npm 安装或包生命周期脚本。完整 CLI 包语义及依赖安装、 -解析仍属独立工作(包括 PR #277);导入包不代表其所有第三方扩展依赖都能执行。 +已安装包注册表,也不会执行 npm 生命周期脚本。声明依赖时,有界安装器只接受 registry +版本说明和 registry 来源的 npm lockfile;导入包不代表其所有第三方扩展依赖都能执行。 ## 4. 加载与运行时 From 840fe969136d04e82f203445504cad4f6852f8c9 Mon Sep 17 00:00:00 2001 From: vastsa Date: Mon, 14 Sep 2026 03:03:10 +0800 Subject: [PATCH 7/7] fix(docs): synchronize e2e locale matrix --- docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 9827d264a..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,7 +4696,6 @@ IPC 请求无法关闭。 | 品质(Session Orchestrator) | E2E-PLUGIN-session-orchestrator-real-workers | | C — 对话与流式(会话列表响应性) | E2E-SESSION-list-refresh-keeps-desktop-responsive | | 品质(会话列表响应性) | E2E-SESSION-list-refresh-keeps-desktop-responsive | -| G——插件(导入扩展依赖) | 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 | | 品质(导入扩展依赖) | E2E-PLUGIN-import-extension-installs-dependencies、E2E-PLUGIN-import-extension-reports-missing-dependency | | C — 对话与流式(独立会话通信) | E2E-SESSION-independent-top-level-communication |