From 87cd0d8eb90a4e04a6c18a19d8e2746d546e77fc Mon Sep 17 00:00:00 2001 From: fz Date: Fri, 18 Sep 2026 22:19:37 +0800 Subject: [PATCH] fix(skill): validate workspace dependency entry types --- .../tests/profiles/sdk/keyless-smoke.e2e.ts | 25 +++++++++++++++++-- .../README.i18n.yaml | 4 +-- .../tool-workspace-dependencies/README.md | 2 +- .../tool-workspace-dependencies/README.zh.md | 2 +- .../tool-workspace-dependencies/src/index.ts | 20 ++++++++++----- .../tests/tool-workspace-dependencies.spec.ts | 16 ++++++++++++ 6 files changed, 57 insertions(+), 12 deletions(-) diff --git a/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts b/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts index d500fb7bd9..18f13aa540 100644 --- a/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts +++ b/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts @@ -432,12 +432,16 @@ async function officeFixture(root: string, pythonOnly: boolean) { return { source, paths } } -it.each(['unset', 'empty', 'bundled', 'full', 'python-only', 'missing-assets'] as const)('composes Office resources through the SDK profile (%s)', async (mode) => { +it.each(['unset', 'empty', 'bundled', 'full', 'python-only', 'missing-assets', 'wrong-type'] as const)('composes Office resources through the SDK profile (%s)', async (mode) => { const root = await mkdtemp(join(tmpdir(), 'sdk-office-')) onTestFinished(() => rm(root, { recursive: true, force: true })) const enabled = mode !== 'unset' && mode !== 'empty' const { source, paths } = await officeFixture(root, mode === 'python-only') if (mode === 'missing-assets') await rm(join(root, 'resources', 'office-skills'), { recursive: true }) + if (mode === 'wrong-type') { + await rm(paths.python) + await mkdir(paths.python) + } const requests: Record[] = [] const server = createServer((request, response) => { let body = '' @@ -490,7 +494,24 @@ it.each(['unset', 'empty', 'bundled', 'full', 'python-only', 'missing-assets'] a for (const name of ['office-docx', 'office-pptx', 'office-xlsx']) { expect(JSON.stringify(requests[0]!.messages).includes(name)).toBe(enabled && mode !== 'missing-assets') } - if (enabled) { + if (mode === 'wrong-type') { + expect(requests).toHaveLength(2) + const messages = requests[1]!.messages as { content: { type: string; tool_use_id?: string; content?: unknown }[] }[] + const result = messages.flatMap(message => message.content).find(block => block.tool_use_id === 'workspace-dependencies') + expect(JSON.parse(JSON.stringify(result).replaceAll(JSON.stringify(paths.python).slice(1, -1), ''))).toMatchInlineSnapshot(` + { + "content": [ + { + "text": "Error: primary runtime: expected file at ", + "type": "text", + }, + ], + "is_error": true, + "tool_use_id": "workspace-dependencies", + "type": "tool_result", + } + `) + } else if (enabled) { expect(requests).toHaveLength(2) expect(requests[1]!.messages).toEqual(expect.arrayContaining([ expect.objectContaining({ role: 'user', content: expect.arrayContaining([ diff --git a/packages/skill/tool-workspace-dependencies/README.i18n.yaml b/packages/skill/tool-workspace-dependencies/README.i18n.yaml index 2aeb78af8d..e4f60d45f5 100644 --- a/packages/skill/tool-workspace-dependencies/README.i18n.yaml +++ b/packages/skill/tool-workspace-dependencies/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/skill/tool-workspace-dependencies/README.md -README.md: 36d1f0080712d6ed71917ead5097beb6b52c3c66 -README.zh.md: e491f92d329142498993ce7976045a95e7ea16f7 +README.md: 1f7147a1febeb0210befe64dc462e2527013dab3 +README.zh.md: f2a1934256776ef97d414be4709a6b918989ae35 diff --git a/packages/skill/tool-workspace-dependencies/README.md b/packages/skill/tool-workspace-dependencies/README.md index 36d1f00807..1f7147a1fe 100644 --- a/packages/skill/tool-workspace-dependencies/README.md +++ b/packages/skill/tool-workspace-dependencies/README.md @@ -60,7 +60,7 @@ A container can copy both directories into an immutable image layer and set `DSH
Implementation internals — click to expand -`readPrimaryRuntime` and build smoke checks share `parsePrimaryRuntime`. It validates the flat manifest and rejects duplicate normalized distribution names. Legacy `components` metadata is normalized in memory, retaining its consistency checks; a missing legacy distribution map becomes empty. Mixed flat and legacy version fields are rejected. Reads do not rewrite metadata, and equivalent normalized manifests can reuse an installed payload. `workspaceDependencyPaths` derives the platform-specific entries. `installPrimaryRuntime` copies into a staging directory, verifies every declared entry, and swaps it into place while retaining the previous tree on failure; `resolvePrimaryRuntime` verifies the same entries without copying. The tool memoizes the first successful preparation for the plugin's lifetime. +`readPrimaryRuntime` and build smoke checks share `parsePrimaryRuntime`. It validates the flat manifest and rejects duplicate normalized distribution names. Legacy `components` metadata is normalized in memory, retaining its consistency checks; a missing legacy distribution map becomes empty. Mixed flat and legacy version fields are rejected. Reads do not rewrite metadata, and equivalent normalized manifests can reuse an installed payload. `workspaceDependencyPaths` derives the platform-specific entries. `installPrimaryRuntime` copies into a staging directory, requires declared interpreters and scripts to be files and package roots to be directories, and swaps it into place while retaining the previous tree on failure; `resolvePrimaryRuntime` verifies the same entries without copying. The tool memoizes the first successful preparation for the plugin's lifetime. | File | Responsibility | |---|---| diff --git a/packages/skill/tool-workspace-dependencies/README.zh.md b/packages/skill/tool-workspace-dependencies/README.zh.md index e491f92d32..f2a1934256 100644 --- a/packages/skill/tool-workspace-dependencies/README.zh.md +++ b/packages/skill/tool-workspace-dependencies/README.zh.md @@ -60,7 +60,7 @@ kind: "package-reference"
实现细节——点击展开 -`readPrimaryRuntime` 与构建冒烟检查共用 `parsePrimaryRuntime`。它校验扁平清单,拒绝归一化后重名的分发包。旧 `components` 元数据在内存中归一化,并保留旧格式的一致性校验;旧分发包版本表缺失时转换为空表。混用顶层与旧组件版本字段会被拒绝。读取不改写元数据,归一化后等价的清单可复用已安装产物。`workspaceDependencyPaths` 推导各平台的条目路径。`installPrimaryRuntime` 复制到暂存目录、校验每个声明的条目、再换入正式位置,失败时保留旧树;`resolvePrimaryRuntime` 做同样的校验但不复制。工具在插件生命周期内记住首次成功的结果。 +`readPrimaryRuntime` 与构建冒烟检查共用 `parsePrimaryRuntime`。它校验扁平清单,拒绝归一化后重名的分发包。旧 `components` 元数据在内存中归一化,并保留旧格式的一致性校验;旧分发包版本表缺失时转换为空表。混用顶层与旧组件版本字段会被拒绝。读取不改写元数据,归一化后等价的清单可复用已安装产物。`workspaceDependencyPaths` 推导各平台的条目路径。`installPrimaryRuntime` 复制到暂存目录、要求声明的解释器和脚本为文件、库路径为目录、再换入正式位置,失败时保留旧树;`resolvePrimaryRuntime` 做同样的校验但不复制。工具在插件生命周期内记住首次成功的结果。 | 文件 | 职责 | |---|---| diff --git a/packages/skill/tool-workspace-dependencies/src/index.ts b/packages/skill/tool-workspace-dependencies/src/index.ts index 7a9a19d488..973a5220b8 100644 --- a/packages/skill/tool-workspace-dependencies/src/index.ts +++ b/packages/skill/tool-workspace-dependencies/src/index.ts @@ -155,9 +155,17 @@ export function workspaceDependencyPaths(root: string, manifest: PrimaryRuntimeM } } -function payloadEntries(paths: WorkspaceDependencies): string[] { - return [paths.python, paths.node, paths.pnpm, paths.pythonPackages, paths.nodePackages] - .filter((path): path is string => path !== undefined) +async function validatePayloadEntries(paths: WorkspaceDependencies): Promise { + for (const [path, kind] of [ + [paths.python, 'file'], [paths.node, 'file'], [paths.pnpm, 'file'], + [paths.pythonPackages, 'directory'], [paths.nodePackages, 'directory'], + ] as const) { + if (path === undefined) continue + const entry = await stat(path) + if (!(kind === 'file' ? entry.isFile() : entry.isDirectory())) { + throw new Error(`primary runtime: expected ${kind} at ${path}`) + } + } } async function exists(path: string): Promise { @@ -183,7 +191,7 @@ async function compatibleManifest(source: string): Promise { const paths = workspaceDependencyPaths(source, await compatibleManifest(source)) - for (const path of payloadEntries(paths)) await stat(path) + await validatePayloadEntries(paths) return paths } @@ -202,14 +210,14 @@ export async function installPrimaryRuntime(source: string, root: string): Promi if (!rootExists && previousExists) await rename(previous, root) if (await exists(join(root, 'runtime.json')) && JSON.stringify(await readPrimaryRuntime(root)) === JSON.stringify(manifest)) { const paths = workspaceDependencyPaths(root, manifest) - for (const path of payloadEntries(paths)) await stat(path) + await validatePayloadEntries(paths) return paths } const staging = await mkdtemp(join(dirname(root), '.primary-runtime-')) try { await cp(source, staging, { recursive: true, dereference: true }) const paths = workspaceDependencyPaths(staging, manifest) - for (const path of payloadEntries(paths)) await stat(path) + await validatePayloadEntries(paths) await rm(previous, { recursive: true, force: true }) const replacing = await exists(root) if (replacing) await rename(root, previous) diff --git a/packages/skill/tool-workspace-dependencies/tests/tool-workspace-dependencies.spec.ts b/packages/skill/tool-workspace-dependencies/tests/tool-workspace-dependencies.spec.ts index 1b8ad80714..2b9e187207 100644 --- a/packages/skill/tool-workspace-dependencies/tests/tool-workspace-dependencies.spec.ts +++ b/packages/skill/tool-workspace-dependencies/tests/tool-workspace-dependencies.spec.ts @@ -132,6 +132,22 @@ it('refuses linked installation directories without modifying their targets', as expect(await readFile(join(outside, 'keep'), 'utf8')).toBe('untouched') }) +it.each(['in-place', 'copy', 'reuse'] as const)('rejects wrong payload entry types during %s preparation', async (mode) => { + for (const field of ['python', 'node', 'pnpm', 'pythonPackages', 'nodePackages'] as const) { + const { source, root, manifest } = await fixture() + if (mode === 'reuse') await installPrimaryRuntime(source, root) + const paths = workspaceDependencyPaths(mode === 'reuse' ? root : source, manifest) + const path = paths[field]! + const directory = field.endsWith('Packages') + await rm(path, { recursive: true }) + if (directory) await writeFile(path, 'not a library directory') + else await mkdir(path) + const operation = mode === 'in-place' ? resolvePrimaryRuntime(source) : installPrimaryRuntime(source, root) + await expect(operation).rejects.toThrow(`expected ${directory ? 'directory' : 'file'} at`) + if (mode === 'copy') await expect(readFile(join(root, 'runtime.json'))).rejects.toMatchObject({ code: 'ENOENT' }) + } +}) + it('rejects malformed metadata and incompatible targets', async () => { const { source, root, manifest } = await fixture() await writeFile(join(source, 'runtime.json'), JSON.stringify({ ...manifest, arch: process.arch === 'x64' ? 'arm64' : 'x64' }))