diff --git a/packages/boot/plugin-manager/README.i18n.yaml b/packages/boot/plugin-manager/README.i18n.yaml
index 7578bde6df..bbef7fc2d3 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: ebd29c2b513aa085
- zh: 1827a4ff6cee19fd
+ en: b54bd470788e4879
+ zh: 639e0aed43c3255b
/deepseek-ai-dsh-plugin-manager/use-this-package/version-compatibility-and-exemptions:
en: 483285658e267dd5
zh: 846bec2daac59a96
diff --git a/packages/boot/plugin-manager/README.md b/packages/boot/plugin-manager/README.md
index 44dbb9eeef..225bf0932f 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 and names the run reported as ignored. 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 package name in this profile, 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 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.
### Version compatibility and exemptions
diff --git a/packages/boot/plugin-manager/README.zh.md b/packages/boot/plugin-manager/README.zh.md
index c333fdc034..307bb7abc8 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 只在能够交互提示时才把这些条目写入 `pnpm-workspace.yaml`,因此由管理器自行记录本次运行报告的包名;失败的运行会恢复 `package.json` 与 `pnpm-lock.yaml`,但有意不恢复 `pnpm-workspace.yaml`。Web 插件页提供**允许这些脚本并重试**;工具可以在用户于对话中批准这些脚本后,通过 `install_bundle` 的 `approvedBuilds` 代为授权。服务只校验待决定的名字,不核实对话中的批准。授权按包名保存在当前 profile,允许以宿主用户的权限执行命令,并在再次安装失败后保留。只能批准当前未决定的名字;已有的拒绝与通配规则不能通过此操作覆盖。`allowBuilds` 里出现 YAML 锚点或别名时拒绝授权。重试保留原来的启用选择。
+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 锚点或别名时拒绝授权。重试保留原来的启用选择。
### 版本兼容性与豁免
diff --git a/packages/boot/plugin-manager/src/build-approval.ts b/packages/boot/plugin-manager/src/build-approval.ts
index 75600f8c22..6ffabc73f7 100644
--- a/packages/boot/plugin-manager/src/build-approval.ts
+++ b/packages/boot/plugin-manager/src/build-approval.ts
@@ -11,6 +11,9 @@ 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
+
async function readPolicy(dir: string) {
let text: string
try { text = await readFile(join(dir, 'pnpm-workspace.yaml'), 'utf8') }
@@ -37,7 +40,7 @@ async function readPolicy(dir: string) {
}
/**
- * Record the build scripts a failed run reported as ignored, then list every name awaiting a decision.
+ * Record the build scripts a failed run reported as blocked, then list every name 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
@@ -49,7 +52,7 @@ async function readPolicy(dir: string) {
export async function recordPendingBuilds(dir: string, output: string): Promise {
const { document, pending, keys } = await readPolicy(dir)
const known = new Set(keys)
- const added = ignoredBuildNames(output).filter(name => !known.has(name))
+ const added = blockedBuildNames(output).filter(name => !known.has(name))
if (added.length === 0) return pending
for (const name of added) document.setIn(['allowBuilds', name], UNDECIDED_BUILD)
await writeFileAtomic(join(dir, 'pnpm-workspace.yaml'), String(document), { mode: 0o600 })
@@ -57,14 +60,15 @@ export async function recordPendingBuilds(dir: string, output: string): Promise<
}
/**
- * Read the package names a pnpm run reported as ignored build scripts.
+ * Read the package names 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.
*/
-export function ignoredBuildNames(output: string): string[] {
- const reported = IGNORED_BUILDS.exec(output)?.[1]
- if (reported === undefined) return []
- return reported.split(',').map(name => name.trim()).filter(name => name !== '' && !/[*?]/.test(name))
+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))
}
/** Persist approval without running scripts; the caller holds the profile manifest lock.
diff --git a/packages/boot/plugin-manager/src/index.ts b/packages/boot/plugin-manager/src/index.ts
index 68461cae7c..a9e8b56a25 100644
--- a/packages/boot/plugin-manager/src/index.ts
+++ b/packages/boot/plugin-manager/src/index.ts
@@ -927,7 +927,7 @@ export class PluginManager extends TypertRemoteService {
const succeeded = run.exitCode === 0 && run.timedOut !== true
if (succeeded) delete result.failedAt
if (!succeeded) {
- // pnpm-workspace.yaml is not restored, so the names it left undecided, or reported as ignored, are offered for approval.
+ // pnpm-workspace.yaml is not restored, so the names it left undecided, or reported as blocked, are offered for approval.
try { result.pendingBuilds = await recordPendingBuilds(this.profile.dir, run.output) }
catch (error) {
this.ownerContext.logger.warn('Could not read pending build approvals after pnpm failed', error)
diff --git a/packages/boot/plugin-manager/src/install-failure.ts b/packages/boot/plugin-manager/src/install-failure.ts
index 009eeb1ad9..5d2b23ca7c 100644
--- a/packages/boot/plugin-manager/src/install-failure.ts
+++ b/packages/boot/plugin-manager/src/install-failure.ts
@@ -19,7 +19,7 @@ export interface InstallFailureFacts {
/** Patterns in the order they decide: a specific code before the generic network family. */
const LOG_KINDS: readonly [PluginInstallFailureKind, RegExp][] = [
- ['build-blocked', /ERR_PNPM_IGNORED_BUILDS|Ignored build scripts/],
+ ['build-blocked', /ERR_PNPM_IGNORED_BUILDS|Ignored build scripts|ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED/],
['not-found', /ERR_PNPM_FETCH_404|\bE404\b|404 Not Found|Not Found - GET/],
['no-matching-version', /ERR_PNPM_NO_MATCHING_VERSION|\bETARGET\b|No matching version/],
['disk-full', /\bENOSPC\b|no space left on device/i],
diff --git a/packages/boot/plugin-manager/tests/build-approval.spec.ts b/packages/boot/plugin-manager/tests/build-approval.spec.ts
index f7620aee82..e6aeeb3691 100644
--- a/packages/boot/plugin-manager/tests/build-approval.spec.ts
+++ b/packages/boot/plugin-manager/tests/build-approval.spec.ts
@@ -89,6 +89,22 @@ 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 = [
+ '[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])
+ await approveBuilds(dir, [key])
+ expect(parse(readFileSync(filename, 'utf8'))).toMatchObject({ allowBuilds: { [key]: true } })
+})
+
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/install-spec.spec.ts b/packages/boot/plugin-manager/tests/install-spec.spec.ts
index 10a4a76398..26f591cc6c 100644
--- a/packages/boot/plugin-manager/tests/install-spec.spec.ts
+++ b/packages/boot/plugin-manager/tests/install-spec.spec.ts
@@ -64,6 +64,7 @@ describe('classifyInstallFailure', () => {
expect(classifyInstallFailure({ log: 'ENOSPC', timedOut: true })).toBe('timeout')
expect(classifyInstallFailure({ log: '', cause: Object.assign(new Error('spawn pnpm ENOENT'), { code: 'ENOENT' }) })).toBe('pnpm-missing')
expect(classifyInstallFailure({ log: 'ERR_PNPM_IGNORED_BUILDS Ignored build scripts: node-pty' })).toBe('build-blocked')
+ expect(classifyInstallFailure({ log: '[ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED] Failed to prepare git-hosted package' })).toBe('build-blocked')
expect(classifyInstallFailure({ log: 'npm error code E404\nnpm error 404 Not Found - GET https://registry/x' })).toBe('not-found')
expect(classifyInstallFailure({ log: 'ERR_PNPM_NO_MATCHING_VERSION No matching version found for x@9' })).toBe('no-matching-version')
expect(classifyInstallFailure({ log: 'npm error code ETARGET' })).toBe('no-matching-version')
diff --git a/packages/boot/plugin-manager/tests/manager.spec.ts b/packages/boot/plugin-manager/tests/manager.spec.ts
index 83c1a75da5..2b84a456e3 100644
--- a/packages/boot/plugin-manager/tests/manager.spec.ts
+++ b/packages/boot/plugin-manager/tests/manager.spec.ts
@@ -666,6 +666,33 @@ it('runs a real pnpm dependency script only after approval and retry', async ()
expect(readFileSync(built, 'utf8')).toBe('built')
})
+it('offers approval for a real git-hosted package pnpm refused to prepare', async () => {
+ const { manager, dir, profile } = await fixture('startup')
+ const repository = join(profile.cwd, 'repository')
+ mkdirSync(repository)
+ const env = { GIT_CONFIG_GLOBAL: join(profile.cwd, 'git.config'), GIT_CONFIG_NOSYSTEM: '1' }
+ writeFileSync(env.GIT_CONFIG_GLOBAL, '')
+ const git = (args: string[]) => execa('git', args, { cwd: repository, env })
+ await git(['init', '--initial-branch=main'])
+ writeFileSync(join(repository, 'package.json'), JSON.stringify({ name: '@test/git-prepared', version: '1.0.0',
+ scripts: { prepare: 'node build.cjs' }, dsh: { bundle: { patch: './cordis.patch.yml' } } }))
+ writeFileSync(join(repository, 'build.cjs'), 'require("node:fs").writeFileSync("built.txt", "built")\n')
+ writeFileSync(join(repository, 'cordis.patch.yml'), '[]\n')
+ await git(['add', '.'])
+ await git(['-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid', '-c', 'commit.gpgsign=false', 'commit', '-m', 'fixture bundle'])
+ writeFileSync(join(dir, 'package.json'), '{"name":"git-approval-fixture","private":true}\n')
+ const policy = parseDocument(readFileSync(join(dir, 'pnpm-workspace.yaml'), 'utf8'))
+ policy.set('storeDir', join(profile.cwd, 'store'))
+ writeFileSync(join(dir, 'pnpm-workspace.yaml'), String(policy))
+ 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:/)])
+ 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')
+})
+
it.each(['[', 'allowBuilds: false\n'])('preserves pnpm diagnostics when pending approvals cannot be read: %s', async (policy) => {
const { manager, dir } = await fixture()
writeFileSync(join(dir, 'pnpm-workspace.yaml'), policy)