fix(plugin-manager): harden git allowBuilds key parsing
Capture git keys that contain spaces, skip keys whose git URL carries a credential, and isolate Git configuration for the real pnpm test.
This commit is contained in:
1 parent
220301c614
commit
a8bf3e7600
7 files changed
+63
-24
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
<a id="version-compatibility-and-exemptions"></a>
|
||||
### Version compatibility and exemptions
|
||||
@@ -161,6 +161,7 @@ Tool results append to the transcript. Enabling or disabling other tools can cha
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
- 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.
|
||||
|
||||
@@ -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 锚点或别名时拒绝授权。重试保留原来的启用选择。
|
||||
|
||||
<a id="version-compatibility-and-exemptions"></a>
|
||||
### 版本兼容性与豁免
|
||||
@@ -161,6 +161,7 @@ service 运行若在 `idleTimeoutMs` 内没有任何捕获输出即被终止,
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
- Web 一次批准显示出来的整组待决定包,没有逐包选择。
|
||||
- 插件移到更新的提交后,已批准的 git 键仍留在 `pnpm-workspace.yaml` 中;没有任何机制移除过期的批准。
|
||||
- 命令改动锁文件时,pnpm 按默认注册表校验锁文件中的每个 tarball URL。某个包从其他注册表安装后,使用默认注册表安装另一个包,或在重启后删除该包,可能以 `ERR_PNPM_TARBALL_URL_MISMATCH` 失败。
|
||||
- 更新把目标保存为确切版本,失败后的重新安装也把原版本保存为确切版本,即使 profile 原先保存的是范围。有 HMR 时,重新安装的组合包排到选中列表末尾,这可能改变哪个组合包的配置优先。
|
||||
- 更新把移除与安装作为两次 profile 操作运行,其他操作可能在两者之间运行。
|
||||
|
||||
@@ -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<string[]> {
|
||||
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.
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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: { '[email protected]': 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/[email protected]" 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://[email protected]/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:[email protected]/sidechat.git#244b67419a80844f5fb1fd7971109b9c19a20b76',
|
||||
'@scope/sidechat@git+http://[email protected]/sidechat.git#244b67419a80844f5fb1fd7971109b9c19a20b76',
|
||||
'@scope/sidechat@git+ssh://git:[email protected]/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'],
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in new issue
Block a user