diff --git a/packages/boot/plugin-manager/README.i18n.yaml b/packages/boot/plugin-manager/README.i18n.yaml index bbef7fc2d3..b1d0317fd6 100644 --- a/packages/boot/plugin-manager/README.i18n.yaml +++ b/packages/boot/plugin-manager/README.i18n.yaml @@ -15,8 +15,8 @@ en: 87679c51aa3693f5 zh: 11684a48a5806afb /deepseek-ai-dsh-plugin-manager/use-this-package: - en: b54bd470788e4879 - zh: 639e0aed43c3255b + en: 7ec407151aa14244 + zh: 7f09ed0329449c4d /deepseek-ai-dsh-plugin-manager/use-this-package/version-compatibility-and-exemptions: en: 483285658e267dd5 zh: 846bec2daac59a96 @@ -48,8 +48,8 @@ en: c29f60be7f3edc1a zh: d9b9388b96ef95cc /deepseek-ai-dsh-plugin-manager/known-limitations-and-deferred-work: - en: be72520bfbd9e98c - zh: 55cc921d9dbb7cf3 + en: d26075545fe1e990 + zh: 068c07d642843c94 /deepseek-ai-dsh-plugin-manager/known-limitations-and-deferred-work/failure-behavior: en: c670eb2330b441e8 zh: 2c8223e03e1e3ef0 diff --git a/packages/boot/plugin-manager/README.md b/packages/boot/plugin-manager/README.md index 225bf0932f..7d74a95d7c 100644 --- a/packages/boot/plugin-manager/README.md +++ b/packages/boot/plugin-manager/README.md @@ -67,7 +67,7 @@ The browser-safe `@deepseek-ai/dsh-plugin-manager/registry` entry exports `OFFIC `waitForInstall(requestId)` lets a client recover a lost response by waiting for the active installation, including its non-cancellable application phase. It returns the same outcome as the original call, or `null` if the request is not active. Completed results are not retained; `null` establishes neither success nor cancellation. -When pnpm 11 blocks dependency scripts, the failed installation reports every pending package name in the profile under `pendingBuilds`, including names left by earlier attempts, names the run reported as ignored, and the `allowBuilds` key pnpm suggests for a git-hosted package it refused to prepare. pnpm writes those entries into `pnpm-workspace.yaml` only when it can prompt, so the manager records the reported names itself; a failed run restores `package.json` and `pnpm-lock.yaml` but deliberately not `pnpm-workspace.yaml`. The Web plugin page offers **Allow these scripts and retry**; the tool can grant permission on the user's behalf through `approvedBuilds` on `install_bundle`, after the user approves those scripts in the conversation. The service validates pending names; it does not verify conversation approval. Approval persists by the recorded key in this profile, which pins a git-hosted package to its commit, permits commands with the host user's permissions, and survives another installation failure. Only currently undecided names can be approved; existing denials and wildcard rules cannot be overridden through this action. Approval rejects YAML anchors or aliases inside `allowBuilds`. Retry preserves the original activation choice. +When pnpm 11 blocks dependency scripts, the failed installation reports every pending `allowBuilds` key in the profile under `pendingBuilds`, including keys left by earlier attempts, names the run reported as ignored, and the key pnpm suggests for a git-hosted package it refused to prepare. A key whose git URL carries a credential (a password, or a user name over HTTP(S)) is not recorded, so that installation offers only manual approval. pnpm writes those entries into `pnpm-workspace.yaml` only when it can prompt, so the manager records the reported names itself; a failed run restores `package.json` and `pnpm-lock.yaml` but deliberately not `pnpm-workspace.yaml`. The Web plugin page offers **Allow these scripts and retry**; the tool can grant permission on the user's behalf through `approvedBuilds` on `install_bundle`, after the user approves those scripts in the conversation. The service validates pending names; it does not verify conversation approval. Approval persists by the recorded key in this profile, which pins a git-hosted package to its commit, permits commands with the host user's permissions, and survives another installation failure. Only currently undecided names can be approved; existing denials and wildcard rules cannot be overridden through this action. Approval rejects YAML anchors or aliases inside `allowBuilds`. Retry preserves the original activation choice. ### Version compatibility and exemptions @@ -161,6 +161,7 @@ Tool results append to the transcript. Enabling or disabling other tools can cha - Web approves the entire displayed pending group; it has no per-package selection. +- An approved git key stays in `pnpm-workspace.yaml` after the plugin moves to a newer commit; nothing removes stale approvals. - pnpm verifies every lockfile tarball URL against its default registry when a command changes the lockfile. After a package was installed from another registry, installing a different package with the default registry, or removing that package after a restart, can fail with `ERR_PNPM_TARBALL_URL_MISMATCH`. - An update saves the target as an exact version, and a reinstallation after a failed update saves the previous version exactly, even when the profile had saved a range. With HMR the reinstalled bundle joins the end of the selection, which can change which bundle's configuration takes precedence. - An update runs its removal and its installation as two profile operations; another operation can run between them. diff --git a/packages/boot/plugin-manager/README.zh.md b/packages/boot/plugin-manager/README.zh.md index 307bb7abc8..85e62dadc1 100644 --- a/packages/boot/plugin-manager/README.zh.md +++ b/packages/boot/plugin-manager/README.zh.md @@ -67,7 +67,7 @@ HTTP(S) Git URL 指向 pnpm 没有 HTTPS 到 SSH 回退的主机(即 `hosted-g `waitForInstall(requestId)` 让客户端在响应丢失后等待活动安装的结果,包括不可取消的应用阶段。它返回与原调用相同的结果,请求不在活动中时返回 `null`。已完成的结果不保留;`null` 不表示成功或已取消。 -pnpm 11 拦下依赖脚本时,失败的安装在 `pendingBuilds` 里报告 profile 中所有待决定的包名,包括先前尝试留下的、本次运行报告为已忽略的,以及 pnpm 为其拒绝准备的 git 托管包建议的 `allowBuilds` 键。pnpm 只在能够交互提示时才把这些条目写入 `pnpm-workspace.yaml`,因此由管理器自行记录本次运行报告的包名;失败的运行会恢复 `package.json` 与 `pnpm-lock.yaml`,但有意不恢复 `pnpm-workspace.yaml`。Web 插件页提供**允许这些脚本并重试**;工具可以在用户于对话中批准这些脚本后,通过 `install_bundle` 的 `approvedBuilds` 代为授权。服务只校验待决定的名字,不核实对话中的批准。授权按记录的键保存在当前 profile,该键会把 git 托管包固定到其提交,允许以宿主用户的权限执行命令,并在再次安装失败后保留。只能批准当前未决定的名字;已有的拒绝与通配规则不能通过此操作覆盖。`allowBuilds` 里出现 YAML 锚点或别名时拒绝授权。重试保留原来的启用选择。 +pnpm 11 拦下依赖脚本时,失败的安装在 `pendingBuilds` 里报告 profile 中所有待决定的 `allowBuilds` 键,包括先前尝试留下的键、本次运行报告为已忽略的包名,以及 pnpm 为其拒绝准备的 git 托管包建议的键。git URL 携带凭据(密码,或 HTTP(S) 上的用户名)的键不会被记录,因此该安装只提供手动批准。pnpm 只在能够交互提示时才把这些条目写入 `pnpm-workspace.yaml`,因此由管理器自行记录本次运行报告的包名;失败的运行会恢复 `package.json` 与 `pnpm-lock.yaml`,但有意不恢复 `pnpm-workspace.yaml`。Web 插件页提供**允许这些脚本并重试**;工具可以在用户于对话中批准这些脚本后,通过 `install_bundle` 的 `approvedBuilds` 代为授权。服务只校验待决定的名字,不核实对话中的批准。授权按记录的键保存在当前 profile,该键会把 git 托管包固定到其提交,允许以宿主用户的权限执行命令,并在再次安装失败后保留。只能批准当前未决定的名字;已有的拒绝与通配规则不能通过此操作覆盖。`allowBuilds` 里出现 YAML 锚点或别名时拒绝授权。重试保留原来的启用选择。 ### 版本兼容性与豁免 @@ -161,6 +161,7 @@ service 运行若在 `idleTimeoutMs` 内没有任何捕获输出即被终止, - Web 一次批准显示出来的整组待决定包,没有逐包选择。 +- 插件移到更新的提交后,已批准的 git 键仍留在 `pnpm-workspace.yaml` 中;没有任何机制移除过期的批准。 - 命令改动锁文件时,pnpm 按默认注册表校验锁文件中的每个 tarball URL。某个包从其他注册表安装后,使用默认注册表安装另一个包,或在重启后删除该包,可能以 `ERR_PNPM_TARBALL_URL_MISMATCH` 失败。 - 更新把目标保存为确切版本,失败后的重新安装也把原版本保存为确切版本,即使 profile 原先保存的是范围。有 HMR 时,重新安装的组合包排到选中列表末尾,这可能改变哪个组合包的配置优先。 - 更新把移除与安装作为两次 profile 操作运行,其他操作可能在两者之间运行。 diff --git a/packages/boot/plugin-manager/src/build-approval.ts b/packages/boot/plugin-manager/src/build-approval.ts index 6ffabc73f7..bdf0583119 100644 --- a/packages/boot/plugin-manager/src/build-approval.ts +++ b/packages/boot/plugin-manager/src/build-approval.ts @@ -11,8 +11,19 @@ const UNDECIDED_BUILD = 'set this to true or false' /** Where a non-interactive run names the build scripts it skipped. */ const IGNORED_BUILDS = /\bIgnored build scripts: (.+)/ -/** The `allowBuilds` key pnpm suggests when it refuses to prepare a git-hosted package. */ -const GIT_DEP_PREPARE = /ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED[\s\S]*?^allowBuilds:\r?\n[ \t]+(\S+): true$/m +/** The `allowBuilds` key pnpm suggests when it refuses to prepare a git-hosted package; a subdirectory key can contain spaces. */ +const GIT_DEP_PREPARE = /ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED[\s\S]*?^allowBuilds:\r?\n[ \t]+(.+?): true$/m + +/** + * Whether an `allowBuilds` key names a git URL that carries a credential: a password, or any user name over HTTP(S). + * Such a key is not recorded, so the credential stays out of the profile settings and the pending list. + */ +function carriesCredential(key: string): boolean { + const at = key.indexOf('@git+') + if (at === -1) return false + const url = URL.parse(key.slice(at + '@git+'.length).replace(/#.*$/s, '')) + return url !== null && (url.password !== '' || (url.username !== '' && /^https?:$/.test(url.protocol))) +} async function readPolicy(dir: string) { let text: string @@ -40,14 +51,14 @@ async function readPolicy(dir: string) { } /** - * Record the build scripts a failed run reported as blocked, then list every name awaiting a decision. + * Record the build scripts a failed run reported as blocked, then list every `allowBuilds` key awaiting a decision. * * pnpm writes those policy entries itself only when it can prompt, and the * manager always runs it non-interactively, so the run's own report is the * record that survives. * @param dir Current profile directory. * @param output Captured output of the failed run. - * @returns Exact package names awaiting a build decision; wildcard rules are excluded. + * @returns Exact `allowBuilds` keys awaiting a build decision; wildcard rules are excluded. */ export async function recordPendingBuilds(dir: string, output: string): Promise { const { document, pending, keys } = await readPolicy(dir) @@ -60,15 +71,15 @@ export async function recordPendingBuilds(dir: string, output: string): Promise< } /** - * Read the package names a pnpm run reported as ignored build scripts or as a git-hosted package it refused to prepare. + * Read the `allowBuilds` keys a pnpm run reported as ignored build scripts or as a git-hosted package it refused to prepare. * @param output Captured output of the run. - * @returns Exact names in report order; wildcard patterns are excluded. + * @returns Exact keys in report order; wildcard patterns and git keys carrying a credential are excluded. */ export function blockedBuildNames(output: string): string[] { const ignored = IGNORED_BUILDS.exec(output)?.[1]?.split(',') ?? [] const git = GIT_DEP_PREPARE.exec(output)?.[1] return [...ignored, ...git === undefined ? [] : [git]] - .map(name => name.trim()).filter(name => name !== '' && !/[*?]/.test(name)) + .map(name => name.trim()).filter(name => name !== '' && !/[*?]/.test(name) && !carriesCredential(name)) } /** Persist approval without running scripts; the caller holds the profile manifest lock. diff --git a/packages/boot/plugin-manager/src/types.ts b/packages/boot/plugin-manager/src/types.ts index 1c395517f6..265c63d286 100644 --- a/packages/boot/plugin-manager/src/types.ts +++ b/packages/boot/plugin-manager/src/types.ts @@ -170,7 +170,10 @@ export interface ChangeResult { bundle?: string /** The installed bundle's manifest version, when declared; pnpm's `minimumReleaseAge` can make it older than the newest release. */ version?: string - /** Exact package names awaiting explicit script approval in the profile's pnpm settings, read after a failed run. */ + /** + * Exact `allowBuilds` keys awaiting explicit script approval in the profile's pnpm settings, read after a failed run: + * a package name, `name@version`, or a git key pinned to a commit. + */ pendingBuilds?: string[] /** Package script permissions saved before this installation attempt. */ approvedBuilds?: string[] diff --git a/packages/boot/plugin-manager/tests/build-approval.spec.ts b/packages/boot/plugin-manager/tests/build-approval.spec.ts index e6aeeb3691..1d5d3ad41f 100644 --- a/packages/boot/plugin-manager/tests/build-approval.spec.ts +++ b/packages/boot/plugin-manager/tests/build-approval.spec.ts @@ -1,5 +1,5 @@ /** Pending script permissions survive cleanup and preserve unrelated workspace settings. */ -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' import { expect, it, onTestFinished } from 'vitest' @@ -89,22 +89,40 @@ it('records the ignored build scripts a non-interactive run reported', async () expect(parse(readFileSync(filename, 'utf8'))).toMatchObject({ allowBuilds: { 'native@1.0.0': true } }) }) -it.each(['\n', '\r\n'])('records the allowBuilds key pnpm suggests for a git-hosted package it refused to prepare (%j)', async (eol) => { - const { dir, filename } = fixture() - const key = '@scope/sidechat@git+https://example.com/sidechat.git#244b67419a80844f5fb1fd7971109b9c19a20b76' - const output = [ +function gitPrepareRefusal(key: string, eol = '\n') { + return [ '[ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED] Failed to prepare git-hosted package fetched from "https://example.com/sidechat.git": The git-hosted package "@scope/sidechat@0.2.0" needs to execute build scripts but is not in the "allowBuilds" allowlist.', '', 'Add the package to "allowBuilds" in your project\'s pnpm-workspace.yaml to allow it to run scripts. For example:', 'allowBuilds:', ` ${key}: true`, + '', ].join(eol) +} - expect(await recordPendingBuilds(dir, output)).toEqual([key]) +it.each([ + ['LF', '@scope/sidechat@git+https://example.com/sidechat.git#244b67419a80844f5fb1fd7971109b9c19a20b76', '\n'], + ['CRLF', '@scope/sidechat@git+https://example.com/sidechat.git#244b67419a80844f5fb1fd7971109b9c19a20b76', '\r\n'], + ['a subdirectory with a space', '@scope/sidechat@git+file:///repo#244b67419a80844f5fb1fd7971109b9c19a20b76&path:plugin folder', '\n'], + ['an SSH user name', '@scope/sidechat@git+ssh://git@example.com/sidechat.git#244b67419a80844f5fb1fd7971109b9c19a20b76', '\n'], +])('records the allowBuilds key pnpm suggests for a git-hosted package it refused to prepare (%s)', async (_label, key, eol) => { + const { dir, filename } = fixture() + expect(await recordPendingBuilds(dir, gitPrepareRefusal(key, eol))).toEqual([key]) await approveBuilds(dir, [key]) expect(parse(readFileSync(filename, 'utf8'))).toMatchObject({ allowBuilds: { [key]: true } }) }) +it.each([ + '@scope/sidechat@git+https://user:token@example.com/sidechat.git#244b67419a80844f5fb1fd7971109b9c19a20b76', + '@scope/sidechat@git+http://token@example.com/sidechat.git#244b67419a80844f5fb1fd7971109b9c19a20b76', + '@scope/sidechat@git+ssh://git:secret@example.com/sidechat.git#244b67419a80844f5fb1fd7971109b9c19a20b76', +])('does not record a git key that carries a credential: %s', async (key) => { + const { dir, filename } = fixture() + expect(await recordPendingBuilds(dir, gitPrepareRefusal(key))).toEqual([]) + expect(await recordPendingBuilds(dir, `[ERR_PNPM_IGNORED_BUILDS] Ignored build scripts: ${key}\n`)).toEqual([]) + expect(existsSync(filename)).toBe(false) +}) + it.each([ ['no report', 'Progress: resolved 1, reused 1, downloaded 0, added 1, done\n'], ['an empty report', '[ERR_PNPM_IGNORED_BUILDS] Ignored build scripts: \n'], diff --git a/packages/boot/plugin-manager/tests/manager.spec.ts b/packages/boot/plugin-manager/tests/manager.spec.ts index 2b84a456e3..c6fe5f9312 100644 --- a/packages/boot/plugin-manager/tests/manager.spec.ts +++ b/packages/boot/plugin-manager/tests/manager.spec.ts @@ -667,10 +667,13 @@ it('runs a real pnpm dependency script only after approval and retry', async () }) it('offers approval for a real git-hosted package pnpm refused to prepare', async () => { - const { manager, dir, profile } = await fixture('startup') + // Both the fixture's Git and the manager's pnpm child read only this empty configuration. + const env = { GIT_CONFIG_GLOBAL: '', GIT_CONFIG_NOSYSTEM: '1' } + const pnpm = fileURLToPath(new URL('../../../../apps/desktop/node_modules/pnpm/bin/pnpm.mjs', import.meta.url)) + const { manager, dir, profile } = await fixture('startup', false, undefined, {}, { command: process.execPath, args: ['--expose-internals', pnpm], env }) const repository = join(profile.cwd, 'repository') mkdirSync(repository) - const env = { GIT_CONFIG_GLOBAL: join(profile.cwd, 'git.config'), GIT_CONFIG_NOSYSTEM: '1' } + env.GIT_CONFIG_GLOBAL = join(profile.cwd, 'git.config') writeFileSync(env.GIT_CONFIG_GLOBAL, '') const git = (args: string[]) => execa('git', args, { cwd: repository, env }) await git(['init', '--initial-branch=main']) @@ -687,10 +690,12 @@ it('offers approval for a real git-hosted package pnpm refused to prepare', asyn const spec = `git+${pathToFileURL(repository).href}` const blocked = await manager.installBundle(spec, { enabled: false }) expect(blocked, JSON.stringify(blocked)).toMatchObject({ application: 'failed', packageResult: { kind: 'build-blocked' } }) - expect(blocked.pendingBuilds).toEqual([expect.stringMatching(/^@test\/git-prepared@git\+file:/)]) + expect(blocked.pendingBuilds).toEqual([expect.stringMatching(/^@test\/git-prepared@git\+file:.*#[0-9a-f]{40}$/)]) + const built = join(dir, 'node_modules', '@test', 'git-prepared', 'built.txt') + expect(existsSync(built)).toBe(false) const allowed = await manager.installBundle(spec, { enabled: false, approvedBuilds: blocked.pendingBuilds! }) expect(allowed, JSON.stringify(allowed)).toMatchObject({ application: 'restart-required', packageResult: { exitCode: 0 } }) - expect(readFileSync(join(dir, 'node_modules', '@test', 'git-prepared', 'built.txt'), 'utf8')).toBe('built') + expect(readFileSync(built, 'utf8')).toBe('built') }) it.each(['[', 'allowBuilds: false\n'])('preserves pnpm diagnostics when pending approvals cannot be read: %s', async (policy) => {