From 6e49ccad18db47686ca8c5b7c9aae59df5bb1cc9 Mon Sep 17 00:00:00 2001 From: fz Date: Thu, 17 Sep 2026 18:16:47 +0800 Subject: [PATCH 01/12] feat(skill): extract the workspace-dependencies tool into a package Move `load_workspace_dependencies` and the primary-runtime payload logic out of `apps/desktop-host` into `@deepseek-ai/dsh-tool-workspace-dependencies` so profiles other than Desktop can mount the same tool. Desktop keeps its behavior through the package. Two additions for carriers that are not Electron applications: - `root` is optional: without it the payload is validated and used in place (no copy under the Harness home), which suits read-only container layers. - `runtime.json` accepts `linux`, and the `node` / `pnpm` components are optional; the returned paths and the tool output omit what the payload does not ship. The `sdk` profile mounts the tool and `@deepseek-ai/dsh-skill-office` when `DSH_PRIMARY_RUNTIME` names a payload directory (its sibling `office-skills/` carries the skill resources); both rows stay disabled otherwise. Both packages join the Python runtime closure. --- apps/cli/package.json | 2 + apps/desktop-host/package.json | 2 +- apps/desktop-host/src/office.ts | 2 +- .../src/workspace-dependencies.ts | 54 ------- apps/desktop-host/tsconfig.json | 2 +- .../scripts/prepare-primary-runtime.ts | 4 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 22 +++ docs/config-catalog.zh.md | 22 +++ docs/tool-catalog.i18n.yaml | 4 +- docs/tool-catalog.md | 18 +++ docs/tool-catalog.zh.md | 18 +++ package.json | 3 +- packages/bundle/sdk-app/cordis.patch.yml | 19 +++ packages/bundle/sdk-app/package.json | 4 +- .../README.i18n.yaml | 6 + .../tool-workspace-dependencies/README.md | 122 ++++++++++++++++ .../tool-workspace-dependencies/README.zh.md | 122 ++++++++++++++++ .../tool-workspace-dependencies/package.json | 41 ++++++ .../tool-workspace-dependencies/src/index.ts | 133 +++++++++++++++--- .../tests/tool-workspace-dependencies.spec.ts | 44 ++++-- .../tool-workspace-dependencies/tsconfig.json | 21 +++ pnpm-lock.yaml | 49 ++++++- python/sdk-runtime/package.json | 2 + scripts/gen-tool-catalog.ts | 12 ++ .../cordis.snapshot.yml | 2 +- .../session/workspace-dependencies/cordis.yml | 2 +- tsconfig.base.json | 1 + tsconfig.host.json | 1 + 29 files changed, 642 insertions(+), 96 deletions(-) delete mode 100644 apps/desktop-host/src/workspace-dependencies.ts create mode 100644 packages/skill/tool-workspace-dependencies/README.i18n.yaml create mode 100644 packages/skill/tool-workspace-dependencies/README.md create mode 100644 packages/skill/tool-workspace-dependencies/README.zh.md create mode 100644 packages/skill/tool-workspace-dependencies/package.json rename apps/desktop-host/src/primary-runtime.ts => packages/skill/tool-workspace-dependencies/src/index.ts (52%) rename apps/desktop-host/tests/primary-runtime.spec.ts => packages/skill/tool-workspace-dependencies/tests/tool-workspace-dependencies.spec.ts (83%) create mode 100644 packages/skill/tool-workspace-dependencies/tsconfig.json diff --git a/apps/cli/package.json b/apps/cli/package.json index 5204f55a29..97d4bf97f7 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -70,6 +70,8 @@ "@deepseek-ai/dsh-session-reference": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-skill-filesystem": "workspace:^", + "@deepseek-ai/dsh-skill-office": "workspace:^", + "@deepseek-ai/dsh-tool-workspace-dependencies": "workspace:^", "@deepseek-ai/dsh-terminal": "workspace:^", "@deepseek-ai/dsh-terminal-bash": "workspace:^", "@deepseek-ai/dsh-time-context": "workspace:^", diff --git a/apps/desktop-host/package.json b/apps/desktop-host/package.json index c47acf4612..200190c8df 100644 --- a/apps/desktop-host/package.json +++ b/apps/desktop-host/package.json @@ -18,7 +18,7 @@ "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-home-paths": "workspace:^", "@deepseek-ai/dsh-jobs": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-tool-workspace-dependencies": "workspace:^", "@deepseek-ai/dsh-skill-office": "workspace:^" } } diff --git a/apps/desktop-host/src/office.ts b/apps/desktop-host/src/office.ts index 9645435e8c..8420aa0feb 100644 --- a/apps/desktop-host/src/office.ts +++ b/apps/desktop-host/src/office.ts @@ -3,7 +3,7 @@ import { dirname, join } from 'node:path' import type { Context } from '@deepseek-ai/cordis' import * as officeSkills from '@deepseek-ai/dsh-skill-office' -import * as workspaceDependencies from './workspace-dependencies.ts' +import * as workspaceDependencies from '@deepseek-ai/dsh-tool-workspace-dependencies' /** Loader identity for the application-owned Office composition. */ export const name = 'desktop-office' diff --git a/apps/desktop-host/src/workspace-dependencies.ts b/apps/desktop-host/src/workspace-dependencies.ts deleted file mode 100644 index 7444495263..0000000000 --- a/apps/desktop-host/src/workspace-dependencies.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** Desktop tool exposing bundled interpreters without modifying command resolution. */ - -import type { Context } from '@deepseek-ai/cordis' -import { defineTool } from '@deepseek-ai/dsh-tools' -import { installPrimaryRuntime, type WorkspaceDependencies } from './primary-runtime.ts' - -export const name = 'desktop-workspace-dependencies' -export const inject = ['tools'] - -/** Application-selected payload and installation directories. */ -export interface Config { - readonly source: string - readonly root: string -} - -/** - * Register the read-only path query, preparing bundled files on its first invocation. - * @param ctx - Desktop tool registry owner. - * @param config - Application payload and fixed installation paths. - */ -export function apply(ctx: Context, config: Config): void { - let installation: Promise | undefined - ctx.effect(() => async () => { - // Tool execution reports installation failures; disposal only waits for filesystem work to settle. - await installation?.catch(() => undefined) - }) - ctx.tools.register(defineTool({ - name: 'load_workspace_dependencies', - description: 'Get absolute paths to bundled Python, Node.js, pnpm, and library directories, plus bundled Python distribution versions. Python includes numpy, pandas, python-docx, python-pptx, openpyxl, Pillow, lxml, and XlsxWriter. Use these libraries for Office files unless the user or workspace instructions select another environment. Run pnpm with the returned Node executable and pnpm script path. This does not change PATH or package-manager settings.', - parameters: {}, - output: { - schema: { - type: 'object', additionalProperties: false, - properties: { - python: { type: 'string', required: true }, - node: { type: 'string', required: true }, - pnpm: { type: 'string', required: true }, - pythonPackages: { type: 'string', required: true }, - nodePackages: { type: 'string', required: true }, - pythonDistributions: { type: 'object', additionalProperties: true, required: true, description: 'Bundled distribution names and versions recorded in runtime.json; excludes user-installed additions.' }, - }, - }, - render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, undefined, 2) }], - }, - execute: () => { - installation ??= installPrimaryRuntime(config.source, config.root).catch((error: unknown) => { - installation = undefined - throw error - }) - return installation - }, - presentCall: () => ({ card: 'generic', title: 'Load workspace dependencies', kind: 'read' }), - })) -} diff --git a/apps/desktop-host/tsconfig.json b/apps/desktop-host/tsconfig.json index 89780f7b94..81277cb3ff 100644 --- a/apps/desktop-host/tsconfig.json +++ b/apps/desktop-host/tsconfig.json @@ -13,7 +13,7 @@ { "path": "../../packages/jobs/jobs" }, { "path": "../../packages/skill/skill-office" }, { "path": "../../packages/util/home-paths" }, - { "path": "../../packages/core/tools" }, + { "path": "../../packages/skill/tool-workspace-dependencies" }, { "path": "../cli" }, diff --git a/apps/desktop/scripts/prepare-primary-runtime.ts b/apps/desktop/scripts/prepare-primary-runtime.ts index 9163db1234..aa3ac650cb 100644 --- a/apps/desktop/scripts/prepare-primary-runtime.ts +++ b/apps/desktop/scripts/prepare-primary-runtime.ts @@ -9,7 +9,7 @@ import { tmpdir } from 'node:os' import { dirname, join, resolve } from 'node:path' import extractZip from 'extract-zip' import { x as extractTar } from 'tar' -import { workspaceDependencyPaths, type PrimaryRuntimeManifest } from '../../desktop-host/src/primary-runtime.ts' +import { workspaceDependencyPaths, type PrimaryRuntimeManifest } from '../../../packages/skill/tool-workspace-dependencies/src/index.ts' import { resolveDesktopBuildTarget, resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs' import { scrubWindowsSigningEnvironment } from './windows-sign.mjs' import lock from './primary-runtime-lock.json' with { type: 'json' } @@ -159,6 +159,8 @@ export function smokePrimaryRuntime(root: string): void { if (manifest.platform !== process.platform || manifest.arch !== process.arch) return if (manifest.pythonPackages === undefined) throw new Error('primary runtime: missing Python distribution versions; prepare the payload before running its smoke checks.') const entries = workspaceDependencyPaths(root, manifest) + // The Desktop payload always ships Node.js and pnpm; only other carriers may omit them. + if (entries.node === undefined || entries.pnpm === undefined) throw new Error('primary runtime: the Desktop payload must declare node and pnpm components.') const options = { stdio: 'inherit', timeout: 120_000, env: scrubWindowsSigningEnvironment(process.env) } as const execFileSync(entries.python, ['-I', '-c', 'import decimal, xml.parsers.expat, lzma, uuid, numpy, pandas; assert numpy.arange(4).sum() == 6; assert pandas.DataFrame({"n": [1, 2]}).n.sum() == 3'], options) execFileSync(entries.python, ['-I', '-B', join(import.meta.dirname, 'smoke-primary-runtime.py'), JSON.stringify(manifest.pythonPackages), diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 7da9acd525..27d34f7af2 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.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 docs/config-catalog.md -config-catalog.md: 631ce6a34b120f44534a7935ff976662287fa879 -config-catalog.zh.md: 8d72786a79ceff85e35aff72fa6622af7373a3b4 +config-catalog.md: 64ef9c99d2cc98690097d848ab7cfb899c6bb474 +config-catalog.zh.md: 76faa711e53011ba34d599f59f616280a4660ad0 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 631ce6a34b..64ef9c99d2 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -3462,6 +3462,28 @@ export interface Config { Source: [`packages/workflow/tool-workflow/src/index.ts:32`](../packages/workflow/tool-workflow/src/index.ts) + + +## `@deepseek-ai/dsh-tool-workspace-dependencies` + +Requires: `tools` + +```ts config-catalog +/** Payload location and optional installation directory. */ +export interface Config { + /** Payload directory carrying `runtime.json` and `dependencies/`. */ + readonly source: string + /** + * Installation directory under the Harness home. When set, the payload is copied there on the + * first call (the Desktop behavior); when omitted, the payload is used in place without copying, + * which suits read-only carriers such as container image layers. + */ + readonly root?: string +} +``` + +Source: [`packages/skill/tool-workspace-dependencies/src/index.ts:14`](../packages/skill/tool-workspace-dependencies/src/index.ts) + ## `@deepseek-ai/dsh-tools` diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 8d72786a79..76faa711e5 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -3464,6 +3464,28 @@ export interface Config { 来源:[`packages/workflow/tool-workflow/src/index.ts:32`](../packages/workflow/tool-workflow/src/index.ts) + + +## `@deepseek-ai/dsh-tool-workspace-dependencies` + +依赖:`tools` + +```ts config-catalog +/** Payload location and optional installation directory. */ +export interface Config { + /** Payload directory carrying `runtime.json` and `dependencies/`. */ + readonly source: string + /** + * Installation directory under the Harness home. When set, the payload is copied there on the + * first call (the Desktop behavior); when omitted, the payload is used in place without copying, + * which suits read-only carriers such as container image layers. + */ + readonly root?: string +} +``` + +来源:[`packages/skill/tool-workspace-dependencies/src/index.ts:14`](../packages/skill/tool-workspace-dependencies/src/index.ts) + ## `@deepseek-ai/dsh-tools` diff --git a/docs/tool-catalog.i18n.yaml b/docs/tool-catalog.i18n.yaml index 22a62072ae..95707a37cf 100644 --- a/docs/tool-catalog.i18n.yaml +++ b/docs/tool-catalog.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 docs/tool-catalog.md -tool-catalog.md: cd7805d3104120a04e21363cb75c6dcb8456f5e0 -tool-catalog.zh.md: 52752836f49c8fb745a69f10c4f8cd1ef79a871d +tool-catalog.md: bdc3a5af4b1bf723879c423fe201576ca3772e64 +tool-catalog.zh.md: bc445ea8d6b0a5778e4bd191693ff01bd6781d73 diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index cd7805d310..bdc3a5af4b 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -43,6 +43,7 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-experimental-tool-agent-team` | `interrupt_agent`, `list_agents`, `send_message`, `spawn_teammate`, `team_task_create`, `team_task_get`, `team_task_list`, `team_task_update`, `wait_agent` | `ctx.tools`, `ctx.systemPrompt`, `ctx.agentTeams`, `an exact live Team member Agent` | `tool/call`, `team/member`, `team/message/queued`, `team/message/delivered`, `team/task`, `tool/result` | - | All nine tools are scoped to implicit Team Leads and durable teammates. The shipped dsh-base bundle keeps the package disabled; the documented Agent Teams profile patch enables it while disabling the legacy continuable-child control names. | | `@deepseek-ai/dsh-tool-todo` | `todo_write` | `ctx.tools`, `owning Agent session` | `tool/call`, `todo/write`, `tool/result` | - | todo_write is session-owned state; UIs render the latest todo/write event as a checklist. `allowParallelInProgress` is required with no default, so the catalog states its choice: `true`, whose description invites several `in_progress` items. A deployment choosing `false` receives the same tool with a description asking for exactly one active task. | | `@deepseek-ai/dsh-tool-workflow` | `workflow` | `ctx.tools`, `ctx.workflowEngine`, `ctx.systemPrompt`, `a calling Agent (exec.agent parents the script children)` | `tool/call`, `tool/result` | - | - | +| `@deepseek-ai/dsh-tool-workspace-dependencies` | `load_workspace_dependencies` | `ctx.tools` | `tool/call`, `tool/result` | - | - | | `@deepseek-ai/dsh-tool-web` | `web_fetch`, `web_search` | `ctx.tools`, `ctx.web`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | web_search and web_fetch keep provider selection behind ctx.web so model-visible schemas stay stable across backend swaps. | @@ -2438,6 +2439,23 @@ Constraints: concurrency and total-agent caps apply; no filesystem, network, tim Source: [`packages/workflow/tool-workflow/src/index.ts`](../packages/workflow/tool-workflow/src/index.ts) + + +## `@deepseek-ai/dsh-tool-workspace-dependencies` + +### `load_workspace_dependencies` + +Get absolute paths to bundled Python, Node.js, pnpm, and library directories, plus bundled Python distribution versions. Python includes numpy, pandas, python-docx, python-pptx, openpyxl, Pillow, lxml, and XlsxWriter. Use these libraries for Office files unless the user or workspace instructions select another environment. Run pnpm with the returned Node executable and pnpm script path. This does not change PATH or package-manager settings. + +```json +{ + "type": "object", + "properties": {} +} +``` + +Source: [`packages/skill/tool-workspace-dependencies/src/index.ts`](../packages/skill/tool-workspace-dependencies/src/index.ts) + ## `@deepseek-ai/dsh-tool-web` diff --git a/docs/tool-catalog.zh.md b/docs/tool-catalog.zh.md index 52752836f4..bc445ea8d6 100644 --- a/docs/tool-catalog.zh.md +++ b/docs/tool-catalog.zh.md @@ -47,6 +47,7 @@ | `@deepseek-ai/dsh-experimental-tool-agent-team` | `interrupt_agent`、`list_agents`、`send_message`、`spawn_teammate`、`team_task_create`、`team_task_get`、`team_task_list`、`team_task_update`、`wait_agent` | `ctx.tools`、`ctx.systemPrompt`、`ctx.agentTeams`、`an exact live Team member Agent` | `tool/call`、`team/member`、`team/message/queued`、`team/message/delivered`、`team/task`、`tool/result` | - | 这 9 个工具限定于隐式 Team Lead 与持久 teammate 作用域。随产品发布的 dsh-base bundle 默认禁用该包;文档中的 Agent Teams profile patch 会启用它,并禁用旧 continuable child 的同名控制工具。 | | `@deepseek-ai/dsh-tool-todo` | `todo_write` | `ctx.tools`、`owning Agent session` | `tool/call`、`todo/write`、`tool/result` | - | todo_write 是会话所有的状态;UI 将最新的 todo/write 事件渲染为检查清单。`allowParallelInProgress` 是没有默认值的必填项,因此本目录明确选择 `true`,对应描述允许同时存在多个 `in_progress` 项。选择 `false` 的部署会获得同一工具,但描述会要求只能有 1 个活动任务。 | | `@deepseek-ai/dsh-tool-workflow` | `workflow` | `ctx.tools`、`ctx.workflowEngine`、`ctx.systemPrompt`、`a calling Agent (exec.agent parents the script children)` | `tool/call`、`tool/result` | - | - | +| `@deepseek-ai/dsh-tool-workspace-dependencies` | `load_workspace_dependencies` | `ctx.tools` | `tool/call`, `tool/result` | - | - | | `@deepseek-ai/dsh-tool-web` | `web_fetch`、`web_search` | `ctx.tools`、`ctx.web`、`ctx.systemPrompt` | `tool/call`、`tool/result` | - | web_search 和 web_fetch 将提供方选择置于 ctx.web 之后,使模型可见 schema 在更换后端时保持稳定。 | @@ -2446,6 +2447,23 @@ todo_write 是会话所有的状态;UI 将最新的 todo/write 事件渲染为 来源:[`packages/workflow/tool-workflow/src/index.ts`](../packages/workflow/tool-workflow/src/index.ts) + + +## `@deepseek-ai/dsh-tool-workspace-dependencies` + +### `load_workspace_dependencies` + +获取随包附带的 Python、Node.js、pnpm 和库目录的绝对路径,以及随包 Python 发行版的版本。Python 含 numpy、pandas、python-docx、python-pptx、openpyxl、Pillow、lxml 与 XlsxWriter。除非用户或工作区指令选择了别的环境,Office 文件请使用这些库。用返回的 Node 可执行文件和 pnpm 脚本路径运行 pnpm。本工具不改 PATH,也不改包管理器设置。 + +```json +{ + "type": "object", + "properties": {} +} +``` + +来源:[`packages/skill/tool-workspace-dependencies/src/index.ts`](../packages/skill/tool-workspace-dependencies/src/index.ts) + ## `@deepseek-ai/dsh-tool-web` diff --git a/package.json b/package.json index bb4d9c3286..52cbabab43 100644 --- a/package.json +++ b/package.json @@ -240,6 +240,7 @@ "typescript": "^6.0.3", "vite-tsconfig-paths": "^6.1.1", "vitest": "^4.1.8", - "@deepseek-ai/dsh-skill-office": "workspace:^" + "@deepseek-ai/dsh-skill-office": "workspace:^", + "@deepseek-ai/dsh-tool-workspace-dependencies": "workspace:^" } } diff --git a/packages/bundle/sdk-app/cordis.patch.yml b/packages/bundle/sdk-app/cordis.patch.yml index 91c67debc1..e80f962953 100644 --- a/packages/bundle/sdk-app/cordis.patch.yml +++ b/packages/bundle/sdk-app/cordis.patch.yml @@ -23,3 +23,22 @@ - id: hmr disabled: true + +# Bundled script runtime and Office skills, the composition Desktop mounts as +# `desktop-office`. A carrier that ships the primary-runtime payload (for +# example a container image layer) names it with DSH_PRIMARY_RUNTIME; its +# sibling `office-skills/` directory carries the skill resources. Unset, both +# rows stay disabled and the profile is unchanged. +- insert: + - id: workspace-dependencies + name: '@deepseek-ai/dsh-tool-workspace-dependencies' + disabled: !!js "!process.env.DSH_PRIMARY_RUNTIME" + config: + source: !!js process.env.DSH_PRIMARY_RUNTIME ?? '' + + - id: skill-office + name: '@deepseek-ai/dsh-skill-office' + disabled: !!js "!process.env.DSH_PRIMARY_RUNTIME" + config: + # The payload's parent directory plus office-skills, without a path module in the expression scope. + assetRoot: !!js "process.env.DSH_PRIMARY_RUNTIME ? process.env.DSH_PRIMARY_RUNTIME.replace(/[\\\\/]+[^\\\\/]+[\\\\/]*$/, '') + '/office-skills' : ''" diff --git a/packages/bundle/sdk-app/package.json b/packages/bundle/sdk-app/package.json index 8d87fb7abe..362c579846 100644 --- a/packages/bundle/sdk-app/package.json +++ b/packages/bundle/sdk-app/package.json @@ -37,7 +37,9 @@ "@deepseek-ai/dsh-cmdline": "workspace:^", "@deepseek-ai/dsh-sdk-jsonrpc-server": "workspace:^", "@deepseek-ai/schemastery": "workspace:^", - "commander": "^15.0.0" + "commander": "^15.0.0", + "@deepseek-ai/dsh-skill-office": "workspace:^", + "@deepseek-ai/dsh-tool-workspace-dependencies": "workspace:^" }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:^" diff --git a/packages/skill/tool-workspace-dependencies/README.i18n.yaml b/packages/skill/tool-workspace-dependencies/README.i18n.yaml new file mode 100644 index 0000000000..397d6f1a5b --- /dev/null +++ b/packages/skill/tool-workspace-dependencies/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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: d39437c039ff479900f8192e666ab67f2dbedc89 +README.zh.md: c45cd21e810eb8a6a2c3e8bf22da4a5bdc8089ff diff --git a/packages/skill/tool-workspace-dependencies/README.md b/packages/skill/tool-workspace-dependencies/README.md new file mode 100644 index 0000000000..d39437c039 --- /dev/null +++ b/packages/skill/tool-workspace-dependencies/README.md @@ -0,0 +1,122 @@ +--- +description: "The load_workspace_dependencies tool: absolute paths into a bundled Python, Node.js, and pnpm payload, used in place or installed under the Harness home." +kind: "package-reference" +--- + +# @deepseek-ai/dsh-tool-workspace-dependencies + +English | [中文](README.zh.md) + +## Summary + +Deployments that ship their own script runtimes (Desktop's primary runtime, or a container image layer) mount this tool so the agent can ask where the bundled Python, Node.js, and pnpm live instead of discovering a system interpreter. The tool returns absolute paths and recorded distribution versions; it changes neither `PATH` nor package-manager settings. The payload is either copied under the Harness home on first use (Desktop) or used where it lies (read-only carriers). + +## Table of Contents + +- [Use this package](#use-this-package) +- [Understand the implementation](#understand-the-implementation) +- [Further Exploration](#further-exploration) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Use this package + +Mount the plugin beside the tool registry with the payload directory. The bundled Office skills (`@deepseek-ai/dsh-skill-office`) reference this tool by name for their default interpreter. + +### Minimal configuration + +```yaml +- name: '@deepseek-ai/dsh-tool-workspace-dependencies' + config: + source: /path/to/primary-runtime +``` + +| Field | Default | Meaning | +|---|---|---| +| `source` | required | Payload directory carrying `runtime.json` and `dependencies/`. | +| `root` | unset | Installation directory under the Harness home. Set: the payload is copied there on the first call and reused while `runtime.json` is unchanged. Unset: the payload is validated and used in place; nothing is copied. | + +### Payload layout + +`runtime.json` records `desktopVersion`, `platform` (`win32`, `darwin`, or `linux`), `arch`, the `components` versions (`python`, `numpy`, `pandas`; `node` and `pnpm` are optional), and optionally `payloadDigest` and `pythonPackages`. Entries live under `dependencies/`: `python/bin/python3` (`python/python.exe` on Windows) with `site-packages` beneath it, and, when declared, `node/bin/node` with `node/node_modules` and `pnpm/bin/pnpm.mjs`. A manifest whose platform or architecture differs from the running process is rejected. + +The `sdk` profile mounts this tool and the Office skills when `DSH_PRIMARY_RUNTIME` names a payload directory whose sibling `office-skills/` carries the skill resources; both rows stay disabled otherwise. + +----- + + +## Understand the implementation + +
+Implementation internals — click to expand + +`readPrimaryRuntime` validates the manifest and rejects duplicate normalized distribution names and numpy/pandas versions that disagree with the components table. `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. + +| File | Responsibility | +|---|---| +| [`src/index.ts`](src/index.ts) | Manifest validation, path derivation, in-place and installed preparation, tool registration. | +| — | No runtime invariant companion is published: the payload manifest is validated on every preparation, and the tool registry owns registration lifecycle. | + +
+ +----- + + +## Further Exploration + +- [Office skills](../skill-office/README.md) — the workflows that call this tool for their interpreter. +- [Tool registry](../../core/tools/README.md) — registration and schemas. + +----- + + +## Model Experience + +### Tool schema + +#### What the model sees + +The model sees the generated [`load_workspace_dependencies` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-workspace-dependencies). + +#### Token effect + +Fixed schema cost per request where the tool is visible; the description names the bundled Office libraries so the model can choose the interpreter without loading a skill first. + +#### KV Cache effect + +Prefix-stable while the tool definition and visibility are unchanged. + +### Tool result + +#### What the model sees + +One JSON object with absolute `python` and `pythonPackages` paths, `pythonDistributions` from `runtime.json`, and `node`, `nodePackages`, and `pnpm` when the payload declares them. Repeated calls return the same object. + +#### Token effect + +A few hundred characters per call; paths dominate. + +#### KV Cache effect + +Append-only tool result in the turn history; no prompt section is added. + +## Known Limitations and Deferred Work + + + +- The payload build (`apps/desktop/scripts/prepare-primary-runtime.ts`) still targets Desktop; other carriers assemble the same layout themselves. +- Windows payloads used in place must already be executable from their carrier; the in-place mode performs no permission repair. + + +### Dev Note + +
+Working context for maintainers — click to expand + +Extracted from `apps/desktop-host` so the SDK profile can mount the same tool from a container image layer. The in-place mode and the optional Node.js/pnpm components exist for that carrier. + +
diff --git a/packages/skill/tool-workspace-dependencies/README.zh.md b/packages/skill/tool-workspace-dependencies/README.zh.md new file mode 100644 index 0000000000..c45cd21e81 --- /dev/null +++ b/packages/skill/tool-workspace-dependencies/README.zh.md @@ -0,0 +1,122 @@ +--- +description: "load_workspace_dependencies 工具:返回随包附带的 Python、Node.js 与 pnpm 的绝对路径,payload 可原地使用或安装到 Harness home。" +kind: "package-reference" +--- + +# @deepseek-ai/dsh-tool-workspace-dependencies + +[English](README.md) | 中文 + +## 概述 + +自带脚本运行时的部署(Desktop 的 primary runtime,或容器镜像层)挂载本工具,让 agent 询问随包的 Python、Node.js 与 pnpm 在哪里,而不是自行寻找系统解释器。工具返回绝对路径与记录的发行版版本,不改 `PATH`,不改包管理器设置。payload 或在首次调用时复制到 Harness home 下(Desktop),或原地使用(只读载体)。 + +## 目录 + +- [使用本包](#use-this-package) +- [了解实现](#understand-the-implementation) +- [延伸阅读](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与暂缓事项](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用本包 + +与工具注册表一起挂载,给出 payload 目录。内置 Office skills(`@deepseek-ai/dsh-skill-office`)按名字引用本工具取默认解释器。 + +### 最小配置 + +```yaml +- name: '@deepseek-ai/dsh-tool-workspace-dependencies' + config: + source: /path/to/primary-runtime +``` + +| 字段 | 默认值 | 含义 | +|---|---|---| +| `source` | 必填 | 含 `runtime.json` 与 `dependencies/` 的 payload 目录。 | +| `root` | 未设置 | Harness home 下的安装目录。设置时首次调用把 payload 复制过去,`runtime.json` 不变则复用;未设置时校验后原地使用,不复制。 | + +### payload 布局 + +`runtime.json` 记录 `desktopVersion`、`platform`(`win32`、`darwin` 或 `linux`)、`arch`、`components` 各版本(`python`、`numpy`、`pandas`;`node` 与 `pnpm` 可选),以及可选的 `payloadDigest` 与 `pythonPackages`。条目位于 `dependencies/`:`python/bin/python3`(Windows 为 `python/python.exe`)及其下的 `site-packages`;声明了才有的 `node/bin/node`、`node/node_modules` 与 `pnpm/bin/pnpm.mjs`。平台或架构与当前进程不符的清单被拒绝。 + +`sdk` profile 在 `DSH_PRIMARY_RUNTIME` 指向一个 payload 目录、且其同级 `office-skills/` 带有 skill 资源时挂载本工具与 Office skills;否则两行都保持禁用。 + +----- + + +## 了解实现 + +
+实现细节——点击展开 + +`readPrimaryRuntime` 校验清单,拒绝归一化后重名的发行版以及与 components 表不一致的 numpy/pandas 版本。`workspaceDependencyPaths` 推导各平台的条目路径。`installPrimaryRuntime` 复制到暂存目录、校验每个声明的条目、再换入正式位置,失败时保留旧树;`resolvePrimaryRuntime` 做同样的校验但不复制。工具在插件生命周期内记住首次成功的结果。 + +| 文件 | 职责 | +|---|---| +| [`src/index.ts`](src/index.ts) | 清单校验、路径推导、原地与安装两种准备方式、工具注册。 | +| — | 不发布运行时不变量伴随入口:每次准备都校验 payload 清单,工具注册表负责注册生命周期。 | + +
+ +----- + + +## 延伸阅读 + +- [Office skills](../skill-office/README.zh.md)——调用本工具取解释器的工作流。 +- [工具注册表](../../core/tools/README.zh.md)——注册与 schema。 + +----- + + +## 模型体验 + +### 工具 schema + +#### 模型看到什么 + +模型看到生成的 [`load_workspace_dependencies` schema](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tool-workspace-dependencies)。 + +#### Token 影响 + +工具可见的每个请求固定一份 schema 开销;描述里列出了随包的 Office 库,模型不必先加载 skill 就能选择解释器。 + +#### KV 缓存影响 + +工具定义与可见性不变时前缀稳定。 + +### 工具结果 + +#### 模型看到什么 + +一个 JSON 对象:绝对路径 `python` 与 `pythonPackages`、来自 `runtime.json` 的 `pythonDistributions`,以及 payload 声明了才有的 `node`、`nodePackages` 与 `pnpm`。重复调用返回同一对象。 + +#### Token 影响 + +每次调用几百个字符,以路径为主。 + +#### KV 缓存影响 + +作为工具结果追加进轮次历史;不增加提示词分区。 + +## 已知限制与暂缓事项 + + + +- payload 的构建脚本(`apps/desktop/scripts/prepare-primary-runtime.ts`)仍只面向 Desktop;其它载体按同一布局自行组装。 +- Windows 上原地使用的 payload 须在载体里已可执行;原地模式不修复权限。 + + +### 开发备注 + +
+维护者的工作上下文——点击展开 + +从 `apps/desktop-host` 抽出,让 SDK profile 能从容器镜像层挂载同一个工具。原地模式与可选的 Node.js/pnpm 组件都是为这种载体加的。 + +
diff --git a/packages/skill/tool-workspace-dependencies/package.json b/packages/skill/tool-workspace-dependencies/package.json new file mode 100644 index 0000000000..b3f26fcf86 --- /dev/null +++ b/packages/skill/tool-workspace-dependencies/package.json @@ -0,0 +1,41 @@ +{ + "name": "@deepseek-ai/dsh-tool-workspace-dependencies", + "description": "The load_workspace_dependencies tool: absolute paths into a bundled Python, Node.js, and pnpm payload", + "version": "0.1.6-alpha.1", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/skill/tool-workspace-dependencies" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts" + ], + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/cordis-plugin-include": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^" + } +} diff --git a/apps/desktop-host/src/primary-runtime.ts b/packages/skill/tool-workspace-dependencies/src/index.ts similarity index 52% rename from apps/desktop-host/src/primary-runtime.ts rename to packages/skill/tool-workspace-dependencies/src/index.ts index 130e75d9cc..4069234c3e 100644 --- a/apps/desktop-host/src/primary-runtime.ts +++ b/packages/skill/tool-workspace-dependencies/src/index.ts @@ -1,9 +1,28 @@ -/** Offline installation and absolute paths for Desktop's bundled script dependencies. */ +/** Model-facing query for a bundled Python, Node.js, and pnpm payload, in place or installed under the Harness home. */ import { cp, lstat, mkdir, mkdtemp, readFile, rename, rm, stat } from 'node:fs/promises' import { dirname, join } from 'node:path' +import type { Context } from '@deepseek-ai/cordis' +import { defineTool } from '@deepseek-ai/dsh-tools' -/** Versions recorded by the Desktop build, independent of user-installed packages. */ +/** Cordis plugin identity. */ +export const name = 'tool-workspace-dependencies' +/** Registry the tool registers into. */ +export const inject = ['tools'] + +/** Payload location and optional installation directory. */ +export interface Config { + /** Payload directory carrying `runtime.json` and `dependencies/`. */ + readonly source: string + /** + * Installation directory under the Harness home. When set, the payload is copied there on the + * first call (the Desktop behavior); when omitted, the payload is used in place without copying, + * which suits read-only carriers such as container image layers. + */ + readonly root?: string +} + +/** Versions recorded by the payload build, independent of user-installed packages. */ export interface PrimaryRuntimeManifest { readonly desktopVersion: string readonly platform: string @@ -14,8 +33,10 @@ export interface PrimaryRuntimeManifest { readonly pythonPackages?: Readonly> readonly components: { readonly python: string - readonly node: string - readonly pnpm: string + /** Absent when the payload ships no Node.js. */ + readonly node?: string + /** Absent when the payload ships no pnpm. */ + readonly pnpm?: string readonly numpy: string readonly pandas: string } @@ -24,14 +45,21 @@ export interface PrimaryRuntimeManifest { /** Absolute entry points and bundled versions; pnpm runs through the returned Node executable. */ export interface WorkspaceDependencies { readonly python: string - readonly node: string - readonly pnpm: string + readonly node?: string + readonly pnpm?: string readonly pythonPackages: string - readonly nodePackages: string + readonly nodePackages?: string /** Locked distribution versions; excludes packages users add to the installed environment. */ readonly pythonDistributions: Readonly> } +const VERSION = /^\d+\.\d+\.\d+(?:[-+][\w.-]+)?$/u +const PLATFORMS = ['win32', 'darwin', 'linux'] + +function isVersion(value: unknown): boolean { + return typeof value === 'string' && VERSION.test(value) +} + /** * Read build metadata, rejecting duplicate normalized names and conflicting component/distribution versions. * @param root - Installed or bundled primary runtime directory. @@ -41,12 +69,13 @@ export async function readPrimaryRuntime(root: string): Promise - const components = record.components + const components = record.components as Record | null | undefined const packages = record.pythonPackages if (typeof record.desktopVersion !== 'string' || record.desktopVersion.length === 0 - || !['win32', 'darwin'].includes(String(record.platform)) || !['x64', 'arm64'].includes(String(record.arch)) + || !PLATFORMS.includes(String(record.platform)) || !['x64', 'arm64'].includes(String(record.arch)) || typeof components !== 'object' || components === null - || !['python', 'node', 'pnpm', 'numpy', 'pandas'].every(key => /^\d+\.\d+\.\d+(?:[-+][\w.-]+)?$/u.test(String((components as Record)[key]))) + || !['python', 'numpy', 'pandas'].every(key => isVersion(components[key])) + || !['node', 'pnpm'].every(key => components[key] === undefined || isVersion(components[key])) || (record.payloadDigest !== undefined && (typeof record.payloadDigest !== 'string' || !/^[a-f0-9]{64}$/u.test(record.payloadDigest))) || (packages !== undefined && (typeof packages !== 'object' || packages === null || Array.isArray(packages) || !Object.entries(packages).every(([name, version]) => /^[A-Za-z0-9][A-Za-z0-9._-]*$/u.test(name) @@ -68,23 +97,32 @@ export async function readPrimaryRuntime(root: string): Promise path !== undefined) +} + async function exists(path: string): Promise { try { if ((await lstat(path)).isSymbolicLink()) throw new Error(`primary runtime: installation path is a filesystem link: ${path}`) @@ -95,15 +133,31 @@ async function exists(path: string): Promise { } } +async function compatibleManifest(source: string): Promise { + const manifest = await readPrimaryRuntime(source) + if (manifest.platform !== process.platform || manifest.arch !== process.arch) throw new Error('primary runtime: incompatible platform or architecture') + return manifest +} + +/** + * Use a payload where it lies: validate its metadata and entries, copying nothing. + * @param source - Payload directory, typically a read-only carrier. + * @returns Paths into the payload itself. + */ +export async function resolvePrimaryRuntime(source: string): Promise { + const paths = workspaceDependencyPaths(source, await compatibleManifest(source)) + for (const path of payloadEntries(paths)) await stat(path) + return paths +} + /** * Install the application-owned payload locally, retaining a complete previous tree on copy failure. - * @param source - Payload carried by the current Desktop installation. + * @param source - Payload carried by the current installation. * @param root - Fixed primary runtime directory under the Harness home. * @returns Paths into the installed payload; no PATH or package-manager configuration is changed. */ export async function installPrimaryRuntime(source: string, root: string): Promise { - const manifest = await readPrimaryRuntime(source) - if (manifest.platform !== process.platform || manifest.arch !== process.arch) throw new Error('primary runtime: incompatible platform or architecture') + const manifest = await compatibleManifest(source) await mkdir(dirname(root), { recursive: true }) const previous = `${root}.previous` await exists(previous) @@ -111,14 +165,14 @@ export async function installPrimaryRuntime(source: string, root: string): Promi if (!await exists(root) && await exists(previous)) 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 [paths.python, paths.node, paths.pnpm, paths.pythonPackages, paths.nodePackages]) await stat(path) + for (const path of payloadEntries(paths)) await stat(path) 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 [paths.python, paths.node, paths.pnpm, paths.pythonPackages, paths.nodePackages]) await stat(path) + for (const path of payloadEntries(paths)) await stat(path) await rm(previous, { recursive: true, force: true }) const replacing = await exists(root) if (replacing) await rename(root, previous) @@ -132,3 +186,44 @@ export async function installPrimaryRuntime(source: string, root: string): Promi } return workspaceDependencyPaths(root, manifest) } + +/** + * Register the read-only path query; the first invocation prepares (or merely validates) the payload. + * @param ctx - Tool registry owner. + * @param config - Payload location and optional installation directory. + */ +export function apply(ctx: Context, config: Config): void { + let preparation: Promise | undefined + ctx.effect(() => async () => { + // Tool execution reports preparation failures; disposal only waits for filesystem work to settle. + await preparation?.catch(() => undefined) + }) + ctx.tools.register(defineTool({ + name: 'load_workspace_dependencies', + description: 'Get absolute paths to bundled Python, Node.js, pnpm, and library directories, plus bundled Python distribution versions. Python includes numpy, pandas, python-docx, python-pptx, openpyxl, Pillow, lxml, and XlsxWriter. Use these libraries for Office files unless the user or workspace instructions select another environment. Run pnpm with the returned Node executable and pnpm script path. This does not change PATH or package-manager settings.', + parameters: {}, + output: { + schema: { + type: 'object', additionalProperties: false, + properties: { + python: { type: 'string', required: true }, + node: { type: 'string', description: 'Absent when the payload ships no Node.js.' }, + pnpm: { type: 'string', description: 'Absent when the payload ships no pnpm.' }, + pythonPackages: { type: 'string', required: true }, + nodePackages: { type: 'string', description: 'Absent when the payload ships no Node.js.' }, + pythonDistributions: { type: 'object', additionalProperties: true, required: true, description: 'Bundled distribution names and versions recorded in runtime.json; excludes user-installed additions.' }, + }, + }, + render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, undefined, 2) }], + }, + execute: () => { + preparation ??= (config.root === undefined ? resolvePrimaryRuntime(config.source) : installPrimaryRuntime(config.source, config.root)) + .catch((error: unknown) => { + preparation = undefined + throw error + }) + return preparation + }, + presentCall: () => ({ card: 'generic', title: 'Load workspace dependencies', kind: 'read' }), + })) +} diff --git a/apps/desktop-host/tests/primary-runtime.spec.ts b/packages/skill/tool-workspace-dependencies/tests/tool-workspace-dependencies.spec.ts similarity index 83% rename from apps/desktop-host/tests/primary-runtime.spec.ts rename to packages/skill/tool-workspace-dependencies/tests/tool-workspace-dependencies.spec.ts index cd1cff25c9..30b39ce477 100644 --- a/apps/desktop-host/tests/primary-runtime.spec.ts +++ b/packages/skill/tool-workspace-dependencies/tests/tool-workspace-dependencies.spec.ts @@ -10,8 +10,8 @@ import AgentRegistry from '@deepseek-ai/dsh-agent' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime from '@deepseek-ai/dsh-tools' import { ToolCallId } from '@deepseek-ai/dsh-llm' -import { installPrimaryRuntime, readPrimaryRuntime, workspaceDependencyPaths, type PrimaryRuntimeManifest } from '../src/primary-runtime.ts' -import * as workspaceDependencies from '../src/workspace-dependencies.ts' +import * as workspaceDependencies from '../src/index.ts' +import { installPrimaryRuntime, readPrimaryRuntime, resolvePrimaryRuntime, workspaceDependencyPaths, type PrimaryRuntimeManifest } from '../src/index.ts' const roots: string[] = [] afterEach(async () => { await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))) }) @@ -22,22 +22,23 @@ async function fixture() { const source = join(directory, 'resources') const root = join(directory, 'home', 'dsh-runtimes', 'dsh-primary-runtime') const manifest: PrimaryRuntimeManifest = { - desktopVersion: '1.0.0', platform: process.platform === 'win32' ? 'win32' : 'darwin', arch: process.arch, + desktopVersion: '1.0.0', platform: process.platform, arch: process.arch, components: { python: '3.12.14', node: '24.21.0', pnpm: '11.7.0', numpy: '2.3.5', pandas: '3.0.1' }, pythonPackages: { 'python-docx': '1.2.0', 'python-pptx': '1.0.2', openpyxl: '3.1.5' }, } const paths = workspaceDependencyPaths(source, manifest) for (const path of [paths.python, paths.node, paths.pnpm]) { + if (path === undefined) continue await mkdir(dirname(path), { recursive: true }) await writeFile(path, 'interpreter') } await mkdir(paths.pythonPackages, { recursive: true }) - await mkdir(paths.nodePackages, { recursive: true }) + if (paths.nodePackages !== undefined) await mkdir(paths.nodePackages, { recursive: true }) await writeFile(join(source, 'runtime.json'), JSON.stringify(manifest)) return { source, root, manifest, directory } } -it.each(['win32', 'darwin'])('returns %s interpreter and package paths', (platform) => { +it.each(['win32', 'darwin', 'linux'])('returns %s interpreter and package paths', (platform) => { const manifest: PrimaryRuntimeManifest = { desktopVersion: '1', platform, arch: 'x64', components: { python: '3.12.14', node: '24.21.0', pnpm: '11.7.0', numpy: '2.3.5', pandas: '3.0.1' } } const paths = workspaceDependencyPaths('/runtime', manifest) expect(paths.pythonDistributions).toEqual({}) @@ -153,8 +154,8 @@ it.each([ await expect(readPrimaryRuntime(source)).rejects.toThrow('invalid metadata') }) -it.skipIf(process.platform === 'linux')('loads the real tool through Cordis, exposes installed paths, and unregisters on disposal', async () => { - const { source, root, manifest, directory } = await fixture() +it('loads the real tool through Cordis, exposes payload paths in place, and unregisters on disposal', async () => { + const { source, manifest, directory } = await fixture() const ctx = new Context() try { ctx.baseUrl = pathToFileURL(directory).href + '/' @@ -171,7 +172,7 @@ it.skipIf(process.platform === 'linux')('loads the real tool through Cordis, exp }, } as unknown as NonNullable const config = join(directory, 'cordis.yml') - await writeFile(config, `- name: agents\n- name: systemPrompt\n- name: tools\n- name: dependencies\n config:\n source: ${JSON.stringify(source)}\n root: ${JSON.stringify(root)}\n`) + await writeFile(config, `- name: agents\n- name: systemPrompt\n- name: tools\n- name: dependencies\n config:\n source: ${JSON.stringify(source)}\n`) await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(config).href } }) await ctx.loader.await() for (const entry of ctx.loader.entries()) await entry.fiber?.await() @@ -180,7 +181,7 @@ it.skipIf(process.platform === 'linux')('loads the real tool through Cordis, exp const results = await Promise.all(['first', 'second'].map(id => ctx.tools.execute({ ...request, callId: ToolCallId(id) }))) for (const result of results) { expect(result.isError).toBe(false) - expect(result.content).toEqual([{ type: 'text', text: JSON.stringify(workspaceDependencyPaths(root, manifest), undefined, 2) }]) + expect(result.content).toEqual([{ type: 'text', text: JSON.stringify(workspaceDependencyPaths(source, manifest), undefined, 2) }]) } expect(process.env).toEqual(environment) const entry = [...ctx.loader.entries()].find(entry => entry.options.name === 'dependencies') @@ -191,3 +192,28 @@ it.skipIf(process.platform === 'linux')('loads the real tool through Cordis, exp await ctx.fiber.dispose() } }) + +it('uses a payload in place without copying and tolerates a payload without Node.js or pnpm', async () => { + const { source, directory } = await fixture() + const manifest: PrimaryRuntimeManifest = { + desktopVersion: '1.0.0', platform: process.platform, arch: process.arch, + components: { python: '3.12.14', numpy: '2.3.5', pandas: '3.0.1' }, + pythonPackages: { 'python-docx': '1.2.0' }, + } + await writeFile(join(source, 'runtime.json'), JSON.stringify(manifest)) + const paths = await resolvePrimaryRuntime(source) + expect(paths).toEqual({ + python: join(source, 'dependencies', 'python', ...(process.platform === 'win32' ? ['python.exe'] : ['bin', 'python3'])), + pythonPackages: join(source, 'dependencies', 'python', ...(process.platform === 'win32' ? ['Lib'] : ['lib', 'python3.12']), 'site-packages'), + pythonDistributions: { 'python-docx': '1.2.0' }, + }) + await expect(readFile(join(directory, 'home', 'dsh-runtimes', 'dsh-primary-runtime', 'runtime.json'))).rejects.toMatchObject({ code: 'ENOENT' }) + await rm(paths.python) + await expect(resolvePrimaryRuntime(source)).rejects.toMatchObject({ code: 'ENOENT' }) +}) + +it('rejects a payload whose Node.js or pnpm version is malformed', async () => { + const { source, manifest } = await fixture() + await writeFile(join(source, 'runtime.json'), JSON.stringify({ ...manifest, components: { ...manifest.components, node: 'latest' } })) + await expect(readPrimaryRuntime(source)).rejects.toThrow('invalid metadata') +}) diff --git a/packages/skill/tool-workspace-dependencies/tsconfig.json b/packages/skill/tool-workspace-dependencies/tsconfig.json new file mode 100644 index 0000000000..28eabaa205 --- /dev/null +++ b/packages/skill/tool-workspace-dependencies/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../core/tools" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 99e2ff0442..29df5ca749 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -54,6 +54,9 @@ importers: '@deepseek-ai/dsh-tool-session-query': specifier: workspace:^ version: link:packages/session-query/tool-session-query + '@deepseek-ai/dsh-tool-workspace-dependencies': + specifier: workspace:^ + version: link:packages/skill/tool-workspace-dependencies '@deepseek-ai/dsh-web-fetch-http': specifier: workspace:^ version: link:packages/web/web-fetch-http @@ -297,6 +300,9 @@ importers: '@deepseek-ai/dsh-skill-filesystem': specifier: workspace:^ version: link:../../packages/skill/skill-filesystem + '@deepseek-ai/dsh-skill-office': + specifier: workspace:^ + version: link:../../packages/skill/skill-office '@deepseek-ai/dsh-terminal': specifier: workspace:^ version: link:../../packages/terminal/terminal @@ -369,6 +375,9 @@ importers: '@deepseek-ai/dsh-tool-workflow': specifier: workspace:^ version: link:../../packages/workflow/tool-workflow + '@deepseek-ai/dsh-tool-workspace-dependencies': + specifier: workspace:^ + version: link:../../packages/skill/tool-workspace-dependencies '@deepseek-ai/dsh-web-app': specifier: workspace:^ version: link:../../packages/bundle/web-app @@ -617,9 +626,9 @@ importers: '@deepseek-ai/dsh-skill-office': specifier: workspace:^ version: link:../../packages/skill/skill-office - '@deepseek-ai/dsh-tools': + '@deepseek-ai/dsh-tool-workspace-dependencies': specifier: workspace:^ - version: link:../../packages/core/tools + version: link:../../packages/skill/tool-workspace-dependencies apps/web: devDependencies: @@ -1942,6 +1951,12 @@ importers: '@deepseek-ai/dsh-sdk-jsonrpc-server': specifier: workspace:^ version: link:../../sdk/server + '@deepseek-ai/dsh-skill-office': + specifier: workspace:^ + version: link:../../skill/skill-office + '@deepseek-ai/dsh-tool-workspace-dependencies': + specifier: workspace:^ + version: link:../../skill/tool-workspace-dependencies '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery @@ -10299,6 +10314,30 @@ importers: specifier: workspace:^ version: link:../../core/tools + packages/skill/tool-workspace-dependencies: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-include': + specifier: workspace:^ + version: link:../../../vendor/include + '@deepseek-ai/cordis-plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + packages/spill/spill: devDependencies: '@deepseek-ai/cordis': @@ -12615,6 +12654,9 @@ importers: '@deepseek-ai/dsh-skill-filesystem': specifier: workspace:^ version: link:../../packages/skill/skill-filesystem + '@deepseek-ai/dsh-skill-office': + specifier: workspace:^ + version: link:../../packages/skill/skill-office '@deepseek-ai/dsh-spill': specifier: workspace:^ version: link:../../packages/spill/spill @@ -12714,6 +12756,9 @@ importers: '@deepseek-ai/dsh-tool-workflow': specifier: workspace:^ version: link:../../packages/workflow/tool-workflow + '@deepseek-ai/dsh-tool-workspace-dependencies': + specifier: workspace:^ + version: link:../../packages/skill/tool-workspace-dependencies '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../packages/core/tools diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index 244cf218db..16b4ba9886 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -86,6 +86,8 @@ "@deepseek-ai/dsh-shell-env": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-skill-filesystem": "workspace:^", + "@deepseek-ai/dsh-skill-office": "workspace:^", + "@deepseek-ai/dsh-tool-workspace-dependencies": "workspace:^", "@deepseek-ai/dsh-spill": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-subagent-acp": "workspace:^", diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index d2c49aafc3..a301c69a22 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -75,6 +75,7 @@ import WorkflowEngine from '@deepseek-ai/dsh-workflow' import type { WorkflowRun, WorkflowStartRequest } from '@deepseek-ai/dsh-workflow' import * as ToolRalph from '@deepseek-ai/dsh-tool-ralph' import * as ToolWorkflow from '@deepseek-ai/dsh-tool-workflow' +import * as ToolWorkspaceDependencies from '@deepseek-ai/dsh-tool-workspace-dependencies' import { githubSlug } from './verify-md-links.ts' /** Attachment seam marker that makes the attachments-conditional `read_image` schema harvestable. */ @@ -637,6 +638,17 @@ const TOOL_PACKAGES: ToolPackage[] = [ await ctx.plugin(ToolWorkflow) }, }, + { + pkg: '@deepseek-ai/dsh-tool-workspace-dependencies', + dir: 'tool-workspace-dependencies', + source: 'packages/skill/tool-workspace-dependencies/src/index.ts', + requires: ['ctx.tools'], + writes: ['tool/call', 'tool/result'], + async mount(ctx) { + // Schema harvest never prepares a payload; the directory need not exist. + await ctx.plugin(ToolWorkspaceDependencies, { source: resolve(root, '.tmp/tool-catalog/primary-runtime') }) + }, + }, { pkg: '@deepseek-ai/dsh-tool-web', dir: 'tool-web', diff --git a/snapshots/session/workspace-dependencies/cordis.snapshot.yml b/snapshots/session/workspace-dependencies/cordis.snapshot.yml index 0c8ada9cd0..6b88b3b3fe 100644 --- a/snapshots/session/workspace-dependencies/cordis.snapshot.yml +++ b/snapshots/session/workspace-dependencies/cordis.snapshot.yml @@ -46,7 +46,7 @@ - insert: - id: workspace-dependencies - name: ../../../apps/desktop-host/src/workspace-dependencies.ts + name: '@deepseek-ai/dsh-tool-workspace-dependencies' config: source: !!js process.cwd() + "/runtime-source" root: !!js process.cwd() + "/runtime-installed" diff --git a/snapshots/session/workspace-dependencies/cordis.yml b/snapshots/session/workspace-dependencies/cordis.yml index 00aae51666..295a5cc683 100644 --- a/snapshots/session/workspace-dependencies/cordis.yml +++ b/snapshots/session/workspace-dependencies/cordis.yml @@ -1,6 +1,6 @@ - insert: - id: workspace-dependencies - name: ../../../apps/desktop-host/src/workspace-dependencies.ts + name: '@deepseek-ai/dsh-tool-workspace-dependencies' config: source: !!js process.cwd() + '/runtime-source' root: !!js process.cwd() + '/runtime-installed' diff --git a/tsconfig.base.json b/tsconfig.base.json index 6ec529b39d..6bcc0d7a8d 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -481,6 +481,7 @@ "@deepseek-ai/dsh-tool-web": ["./packages/web/tool-web/src"], "@deepseek-ai/dsh-tool-workflow": ["./packages/workflow/tool-workflow/src"], "@deepseek-ai/dsh-tool-workflow/invariant": ["./packages/workflow/tool-workflow/src/invariant.ts"], + "@deepseek-ai/dsh-tool-workspace-dependencies": ["./packages/skill/tool-workspace-dependencies/src"], "@deepseek-ai/dsh-tools": ["./packages/core/tools/src"], "@deepseek-ai/dsh-tools/invariant": ["./packages/core/tools/src/invariant.ts"], "@deepseek-ai/dsh-user-approval": ["./packages/interaction/user-approval/src"], diff --git a/tsconfig.host.json b/tsconfig.host.json index 2b98af6f58..d3acdfc928 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -161,6 +161,7 @@ "references": [ { "path": "./packages/client/connection/tsconfig.host.json" }, { "path": "./packages/skill/skill-office" }, + { "path": "./packages/skill/tool-workspace-dependencies" }, { "path": "./packages/browser-use/browser-use" }, { "path": "./packages/experimental/browser-use-runtime" }, { "path": "./packages/experimental/browser-use-playwright-mcp" }, From 8fad9ccd8c8cc7e7d20775ebb167dbec8927f856 Mon Sep 17 00:00:00 2001 From: fz Date: Thu, 17 Sep 2026 21:10:12 +0800 Subject: [PATCH 02/12] feat(skill): complete shared Office runtime builds for SDK carriers --- ...2026-09-17-shared-office-runtime.i18n.yaml | 6 + .../2026-09-17-shared-office-runtime.md | 29 +++ .../2026-09-17-shared-office-runtime.zh.md | 29 +++ ...30-generated-third-party-notices.i18n.yaml | 4 +- ...026-07-30-generated-third-party-notices.md | 2 +- ...-07-30-generated-third-party-notices.zh.md | 2 +- .github/workflows/ci.yml | 3 + THIRD_PARTY_NOTICES.md | 2 +- apps/cli/tests/desktop-host.e2e.ts | 3 +- .../tests/profiles/sdk/keyless-smoke.e2e.ts | 104 ++++++++- apps/desktop/README.i18n.yaml | 4 +- apps/desktop/README.md | 2 +- apps/desktop/README.zh.md | 2 +- .../scripts/prepare-primary-runtime.ts | 158 +------------ .../tests/primary-runtime-preparation.spec.ts | 137 +----------- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 3 + docs/module-graph.zh.md | 3 + lefthook.yml | 2 +- package.json | 6 + packages/bundle/sdk-app/README.i18n.yaml | 4 +- packages/bundle/sdk-app/README.md | 2 + packages/bundle/sdk-app/README.zh.md | 2 + packages/bundle/sdk-app/cordis.patch.yml | 9 +- .../core/tools/tests/gen-tool-catalog.spec.ts | 2 +- packages/skill/README.i18n.yaml | 4 +- packages/skill/README.md | 1 + packages/skill/README.zh.md | 1 + .../README.i18n.yaml | 4 +- .../tool-workspace-dependencies/README.md | 16 +- .../tool-workspace-dependencies/README.zh.md | 16 +- .../tool-workspace-dependencies/src/index.ts | 5 +- .../tests/tool-workspace-dependencies.spec.ts | 146 +++++++++++- pnpm-lock.yaml | 12 + scripts/gen-third-party-notices.spec.ts | 2 +- scripts/gen-third-party-notices.ts | 4 +- .../primary-runtime/lock.json | 24 ++ scripts/primary-runtime/prepare.spec.ts | 135 +++++++++++ scripts/primary-runtime/prepare.ts | 210 ++++++++++++++++++ .../primary-runtime/smoke.py | 0 40 files changed, 779 insertions(+), 325 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-09-17-shared-office-runtime.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-09-17-shared-office-runtime.md create mode 100644 .agents/notes/implemented/architecture/2026-09-17-shared-office-runtime.zh.md rename apps/desktop/scripts/primary-runtime-lock.json => scripts/primary-runtime/lock.json (82%) create mode 100644 scripts/primary-runtime/prepare.spec.ts create mode 100644 scripts/primary-runtime/prepare.ts rename apps/desktop/scripts/smoke-primary-runtime.py => scripts/primary-runtime/smoke.py (100%) diff --git a/.agents/notes/implemented/architecture/2026-09-17-shared-office-runtime.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-17-shared-office-runtime.i18n.yaml new file mode 100644 index 0000000000..303cd65bd0 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-17-shared-office-runtime.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 .agents/notes/implemented/architecture/2026-09-17-shared-office-runtime.md +2026-09-17-shared-office-runtime.md: 28daa6d3809705098722c41847fab9e3ee255bfe +2026-09-17-shared-office-runtime.zh.md: 09cc54e254546aa2787c37ab695c922ee86f0d66 diff --git a/.agents/notes/implemented/architecture/2026-09-17-shared-office-runtime.md b/.agents/notes/implemented/architecture/2026-09-17-shared-office-runtime.md new file mode 100644 index 0000000000..28daa6d380 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-17-shared-office-runtime.md @@ -0,0 +1,29 @@ +# Agent Note: Shared Office runtime for Desktop and SDK + +Status: implemented + +English | [中文](2026-09-17-shared-office-runtime.zh.md) + +## Problem + +SDK deployments need the same Office authoring libraries as Desktop while keeping interpreter payloads in read-only container image layers. A Desktop-owned query and builder require downstream carriers to duplicate dependency locks and path conventions. + +## Decision + +[`tool-workspace-dependencies`](../../../../packages/skill/tool-workspace-dependencies/README.md) owns manifest validation, interpreter paths, installation and the model query. It stays in the skill group beside its Office workflow consumer: the payload supplies those workflows, while the package exposes no workspace entity service. Desktop imports this package and retains copy-on-first-use installation. The SDK profile enables the query and Office skills only when its carrier sets `DSH_PRIMARY_RUNTIME`; it resolves the payload path and reads the payload in place. The common base and `sdk-minimal` do not enable Office implicitly. + +The [shared build entry](../../../../scripts/primary-runtime/prepare.ts) owns the download lock, extraction and native smoke checks for Desktop targets and GNU/Linux x64. Desktop supplies output paths and its release version, requires Node.js and pnpm, and applies its signing-specific subprocess environment. Other carriers choose their output directory and may build a Python-only payload. The existing `desktopVersion` manifest field retains its name and records the carrier release; `payloadDigest` distinguishes locked inputs and component selection. + +## Alternatives considered + +**Keep implementation in Desktop.** This leaves SDK and container builders depending on application packaging and signing code despite needing only interpreters, Office resources and a query. + +**Enable Office in the common base.** A base deployment has no guaranteed bundled interpreter or resources. Explicit SDK carrier configuration makes that requirement observable without changing unrelated profiles. + +**Always copy to the Harness home.** Copying duplicates image-layer content, requires a writable destination and cannot serve the intended immutable carrier deployment. + +## Consequences + +Successful tool preparation is shared by concurrent calls for one plugin lifetime. Failed preparation is retryable; disposal removes registration and waits for outstanding filesystem work. Copy mode verifies a staged replacement and restores the preceding installation after a failed publication. In-place mode performs no payload writes. Paths are absolute, and runtime metadata and declared entries are checked before results are returned. + +SDK configuration changes require restart. Missing Office resources produce a startup warning and leave the optional skill provider inactive; runtime metadata and interpreter availability are validated on the first tool call. Linux ARM64 and musl builds remain outside the lock. Cross-target assembly verifies archive hashes but requires execution on the target host; the Linux artifact CI lane owns native Linux checks. Unit coverage pins manifest, installation and lifecycle behavior; SDK profile tests observe optional tools, skill discovery and results, and the recorded workspace-dependencies scenario pins model-visible validation errors. diff --git a/.agents/notes/implemented/architecture/2026-09-17-shared-office-runtime.zh.md b/.agents/notes/implemented/architecture/2026-09-17-shared-office-runtime.zh.md new file mode 100644 index 0000000000..09cc54e254 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-17-shared-office-runtime.zh.md @@ -0,0 +1,29 @@ +# Agent Note: Desktop 与 SDK 共享 Office 运行时 + +Status: implemented + +[English](2026-09-17-shared-office-runtime.md) | 中文 + +## 问题 + +SDK 部署需要与 Desktop 相同的 Office 创作库,同时将解释器 payload 保存在只读容器镜像层中。由 Desktop 持有查询工具和构建器,会要求下游载体重复维护依赖锁与路径约定。 + +## 决策 + +[`tool-workspace-dependencies`](../../../../packages/skill/tool-workspace-dependencies/README.zh.md) 持有清单校验、解释器路径、安装逻辑和模型查询。它保留在 skill 组中,与 Office 工作流消费者相邻:payload 服务于这些工作流,本包不提供 workspace 实体服务。Desktop 引用本包并保留首次使用时复制安装。SDK profile 仅在载体设置 `DSH_PRIMARY_RUNTIME` 时启用查询和 Office skills;它解析 payload 路径并原位读取。公共 base 与 `sdk-minimal` 不会隐式启用 Office。 + +[共享构建入口](../../../../scripts/primary-runtime/prepare.ts) 持有 Desktop 目标与 GNU/Linux x64 的下载锁、解压和本机冒烟检查。Desktop 提供输出路径和发布版本,要求包含 Node.js 与 pnpm,并应用签名专用子进程环境。其他载体选择自己的输出目录,也可构建 Python-only payload。现有 `desktopVersion` 清单字段保留名字并记录载体发布版本;`payloadDigest` 区分锁定输入与组件选择。 + +## 考虑过的替代方案 + +**将实现保留在 Desktop。** SDK 和容器构建器只需要解释器、Office 资源和查询,却仍会依赖应用打包及签名代码。 + +**在公共 base 中启用 Office。** base 部署不保证提供内置解释器或资源。显式 SDK 载体配置可以让该要求可见,并保持其他 profile 的行为。 + +**总是复制到 Harness home。** 复制会重复镜像层内容、要求目标可写,也无法满足预期的不可变载体部署。 + +## 影响 + +同一插件生命周期内的并发调用共享成功的工具准备结果。准备失败后可重试;卸载移除工具注册并等待未完成的文件系统工作。复制模式验证暂存替换内容,并在发布失败后恢复原安装。原位模式不写入 payload。路径为绝对路径,返回结果前会检查运行时元数据和所声明的条目。 + +SDK 配置变更需要重启。缺少 Office 资源会产生启动警告,并使可选的 skill 提供方保持未激活;运行时元数据和解释器是否存在在首次工具调用时校验。Linux ARM64 与 musl 构建仍不在锁文件范围内。跨目标组装校验归档哈希,但需要在目标主机执行;Linux 产物 CI 负责本机 Linux 检查。单元覆盖固定清单、安装与生命周期行为;SDK profile 测试观察可选工具、skill 发现与结果,workspace-dependencies 录制场景固定模型可见的校验错误。 diff --git a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml index 11b7e048f6..888d5b333c 100644 --- a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.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 .agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md -2026-07-30-generated-third-party-notices.md: 2b0bd012c23c873b3003916491daad467cf757b3 -2026-07-30-generated-third-party-notices.zh.md: 0c7b4df8edb60f2f70781e179edc7377da930128 +2026-07-30-generated-third-party-notices.md: c4458fac7de4d9e9b63b23855f95aaf69eb89f64 +2026-07-30-generated-third-party-notices.zh.md: f26d0d001ed06dd5bea00bbc5e3400b31b254081 diff --git a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md index 2b0bd012c2..c4458fac7d 100644 --- a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md +++ b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md @@ -12,7 +12,7 @@ A hand-written inventory answers none of those durably. Roughly a hundred rows o ## Decision -[`THIRD_PARTY_NOTICES.md`](../../../../THIRD_PARTY_NOTICES.md) is generated by [`scripts/gen-third-party-notices.ts`](../../../../scripts/gen-third-party-notices.ts) from the workspace manifests, `vendor/README.md`, the `pyproject.toml` files, `pnpm-workspace.yaml`, and the [Desktop runtime lock](../../../../apps/desktop/scripts/primary-runtime-lock.json). The root README pair links the file from its License section. +[`THIRD_PARTY_NOTICES.md`](../../../../THIRD_PARTY_NOTICES.md) is generated by [`scripts/gen-third-party-notices.ts`](../../../../scripts/gen-third-party-notices.ts) from the workspace manifests, `vendor/README.md`, the `pyproject.toml` files, `pnpm-workspace.yaml`, and the [Desktop runtime lock](../../../../scripts/primary-runtime/lock.json). The root README pair links the file from its License section. **Freshness is maintained, not merely enforced.** A pre-commit job regenerates the file and stages it whenever a generator input is staged — any manifest, a workspace declaration, the root or Desktop runtime lock file, `vendor/README.md`, a `pyproject.toml`, the generator itself, or the script holding the build-time pin — so an unrelated dependency edit never has to come back and rerun a generator. The committed bytes are then asserted inside [`scripts/gen-third-party-notices.spec.ts`](../../../../scripts/gen-third-party-notices.spec.ts), which the test lane already runs — the check adds no gate process, no scheduler slot, and no separate CI step. `pnpm run verify-third-party-notices` remains available for a standalone check. diff --git a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md index 0c7b4df8ed..f26d0d001e 100644 --- a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md +++ b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -[`THIRD_PARTY_NOTICES.md`](../../../../THIRD_PARTY_NOTICES.md) 由 [`scripts/gen-third-party-notices.ts`](../../../../scripts/gen-third-party-notices.ts) 依据各工作区 manifest、`vendor/README.md`、`pyproject.toml`、`pnpm-workspace.yaml` 与 [Desktop 运行时锁文件](../../../../apps/desktop/scripts/primary-runtime-lock.json)生成。根 README 双语两侧都从「许可证」一节链到该文件。 +[`THIRD_PARTY_NOTICES.md`](../../../../THIRD_PARTY_NOTICES.md) 由 [`scripts/gen-third-party-notices.ts`](../../../../scripts/gen-third-party-notices.ts) 依据各工作区 manifest、`vendor/README.md`、`pyproject.toml`、`pnpm-workspace.yaml` 与 [Desktop 运行时锁文件](../../../../scripts/primary-runtime/lock.json)生成。根 README 双语两侧都从「许可证」一节链到该文件。 **新鲜度会得到维护,而非仅靠校验。** 只要暂存了生成器的任一输入——任何 manifest、工作区声明、根锁文件或 Desktop 运行时锁文件、`vendor/README.md`、某个 `pyproject.toml`、生成器自身,或持有构建期 pin 的脚本——pre-commit 任务就会重新生成该文件并将其暂存,改依赖的人不必事后再折返跑一次生成器。已提交的字节随后由 [`scripts/gen-third-party-notices.spec.ts`](../../../../scripts/gen-third-party-notices.spec.ts) 断言,而测试 lane 本就会跑这个文件——这项校验不增加门禁进程、不占调度位、也不新增 CI 步骤。需要单独校验时,`pnpm run verify-third-party-notices` 仍然可用。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2139231c06..25ead2d3fe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -357,6 +357,9 @@ jobs: if: vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' run: pnpm --filter @deepseek-ai/dsh-web-frontend exec playwright install chromium + - name: Build and execute the Linux Office runtime + run: CI=true pnpm run prepare:primary-runtime --target linux-x64 --output "$RUNNER_TEMP/office-runtime" --cache "$RUNNER_TEMP/office-runtime-downloads" + - name: Run compatibility, snapshot, and artifact gates run: pnpm run check:ci:consumers diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 7eb0c36e18..b2d8c6da7c 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -246,7 +246,7 @@ Direct dependencies of the `pyproject.toml` manifests, plus `uv` as the developm ## Desktop bundled Python distributions -The [Desktop runtime lock](apps/desktop/scripts/primary-runtime-lock.json) records each distribution version and the wheel download hashes. The table includes every entry in `pythonPackages`, including transitive dependencies. Wheel extraction preserves distribution metadata and the license and notice files supplied by each archive. Project licenses below do not enumerate the separate licenses of native libraries bundled inside wheels. +The [Desktop runtime lock](scripts/primary-runtime/lock.json) records each distribution version and the wheel download hashes. The table includes every entry in `pythonPackages`, including transitive dependencies. Wheel extraction preserves distribution metadata and the license and notice files supplied by each archive. Project licenses below do not enumerate the separate licenses of native libraries bundled inside wheels. | Distribution | Locked version | Project license | | --- | --- | --- | diff --git a/apps/cli/tests/desktop-host.e2e.ts b/apps/cli/tests/desktop-host.e2e.ts index a7326e5860..09c32d9bc5 100644 --- a/apps/cli/tests/desktop-host.e2e.ts +++ b/apps/cli/tests/desktop-host.e2e.ts @@ -13,7 +13,7 @@ it.each([false, true])('settles startup after parent IPC disconnect (boot failur const modules = join(root, 'node_modules', '@deepseek-ai') const hostDirectory = fileURLToPath(new URL('../../desktop-host/', import.meta.url)) const manifest = JSON.parse(readFileSync(join(hostDirectory, 'package.json'), 'utf8')) as { dependencies: Record } - const stubbed = new Set(['@deepseek-ai/dsh-app-boot', '@deepseek-ai/dsh', '@deepseek-ai/dsh-home-paths', '@deepseek-ai/dsh-tools']) + const stubbed = new Set(['@deepseek-ai/dsh-app-boot', '@deepseek-ai/dsh', '@deepseek-ai/dsh-home-paths']) for (const name of Object.keys(manifest.dependencies)) { const destination = join(root, 'node_modules', name) mkdirSync(dirname(destination), { recursive: true }) @@ -22,7 +22,6 @@ it.each([false, true])('settles startup after parent IPC disconnect (boot failur } for (const [name, source] of [ ['dsh-home-paths', `export const resolveDshHome = () => ${JSON.stringify(root)}`], - ['dsh-tools', 'export const defineTool = value => value'], ] as const) { writeFileSync(join(modules, name, 'package.json'), '{"type":"module","exports":"./index.js"}') writeFileSync(join(modules, name, 'index.js'), source) diff --git a/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts b/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts index 3601745871..14f640c63d 100644 --- a/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts +++ b/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts @@ -1,12 +1,14 @@ import { createServer } from 'node:http' -import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' +import { cp, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import { promisify } from 'node:util' import { zstdDecompress } from 'node:zlib' import { execa } from 'execa' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, onTestFinished } from 'vitest' +import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' +import { workspaceDependencyPaths, type PrimaryRuntimeManifest } from '@deepseek-ai/dsh-tool-workspace-dependencies' const binScript = fileURLToPath(new URL('../../../src/bin.ts', import.meta.url)) const repoRoot = fileURLToPath(new URL('../../../../../', import.meta.url)) @@ -407,3 +409,99 @@ describe('Python SDK dsh profile keyless smoke', () => { } }, 30_000) }) + +/** Materialize a native-layout payload whose paths the query can validate without executing binaries. */ +async function officeFixture(root: string, pythonOnly: boolean) { + const source = join(root, 'resources', 'primary-runtime') + const manifest: PrimaryRuntimeManifest = { + desktopVersion: '1.0.0', platform: process.platform, arch: process.arch, + components: { python: '3.12.14', numpy: '2.3.5', pandas: '3.0.1', + ...(pythonOnly ? {} : { node: '24.21.0', pnpm: '11.7.0' }) }, + pythonPackages: { 'python-docx': '1.2.0', 'python-pptx': '1.0.2', openpyxl: '3.1.5' }, + } + const paths = workspaceDependencyPaths(source, manifest) + for (const file of [paths.python, paths.node, paths.pnpm]) { + if (file === undefined) continue + await mkdir(dirname(file), { recursive: true }) + await writeFile(file, 'fixture interpreter') + } + await mkdir(paths.pythonPackages, { recursive: true }) + if (paths.nodePackages !== undefined) await mkdir(paths.nodePackages, { recursive: true }) + await writeFile(join(source, 'runtime.json'), JSON.stringify(manifest)) + await cp(join(repoRoot, 'packages/skill/skill-office/assets'), join(root, 'resources', 'office-skills'), { recursive: true }) + return { source, paths } +} + +it.each(['unset', 'full', 'python-only', 'missing-assets'] 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 { source, paths } = await officeFixture(root, mode === 'python-only') + if (mode === 'missing-assets') await rm(join(root, 'resources', 'office-skills'), { recursive: true }) + const requests: Record[] = [] + const server = createServer((request, response) => { + let body = '' + request.setEncoding('utf8').on('data', (chunk: string) => { body += chunk }) + request.on('end', () => { + requests.push(JSON.parse(body) as Record) + const query = requests.length === 1 && mode !== 'unset' + response.writeHead(200, { 'content-type': 'text/event-stream' }) + response.end(messagesResponse(query + ? { type: 'tool_use', id: 'workspace-dependencies', name: 'load_workspace_dependencies', input: {} } + : { type: 'text', text: 'done' }, query ? 'tool_use' : 'end_turn')) + }) + }) + onTestFinished(() => new Promise((resolve) => { server.close(() => { resolve() }) })) + await new Promise((resolve) => { server.listen(0, '127.0.0.1', resolve) }) + const address = server.address() + if (address === null || typeof address === 'string') throw new Error('model fixture did not bind') + const home = join(root, 'home') + const launch = resolveExampleLaunch({ + srcBin: binScript, tsconfigPath: join(repoRoot, 'tsconfig.json'), sourceImport: 'tsx/esm', + configArgs: ['--profile', 'sdk'], + env: { DSH_HOME: home, DSH_PRIMARY_RUNTIME: mode === 'unset' ? undefined : source + '/', + DSH_PERMISSION_MODE: 'danger-full-access', DSH_TELEMETRY_DISABLED: '1', + DEEPSEEK_API_KEY: 'local-fixture', DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}` }, + }) + const child = execa(launch.command, launch.args, { cwd: repoRoot, env: launch.env, timeout: 60_000, reject: false }) + onTestFinished(async () => { child.kill('SIGKILL'); await child }) + let buffer = '', stderr = '' + const lines: string[] = [] + child.stdout.on('data', (chunk: Buffer) => { + buffer += chunk.toString() + const parts = buffer.split('\n') + buffer = parts.pop() ?? '' + lines.push(...parts) + }) + child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString() }) + const send = (id: number, method: string, params?: object) => { + child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`) + return waitForLine(lines, value => value.id === id, () => stderr) + } + expect(await send(1, 'initialize', { cwd: root, provider: 'deepseek-official', model: 'deepseek-v4-pro' })).toHaveProperty('result') + await send(2, 'session/prompt', { sessionId: 'office', contentBlocks: [{ type: 'text', text: 'Query workspace dependencies.' }] }) + await waitForLine(lines, (value) => { + const params = value.params as { sessionId?: string; event?: { type?: string } } | undefined + return value.method === 'session.event' && params?.sessionId === 'office' && params.event?.type === 'turn/end' + }, () => stderr) + const names = (requests[0]!.tools as { name: string }[]).map(value => value.name) + expect(names.includes('load_workspace_dependencies')).toBe(mode !== 'unset') + for (const name of ['office-docx', 'office-pptx', 'office-xlsx']) { + expect(JSON.stringify(requests[0]!.messages).includes(name)).toBe(mode === 'full' || mode === 'python-only') + } + if (mode !== 'unset') { + expect(requests).toHaveLength(2) + expect(requests[1]!.messages).toEqual(expect.arrayContaining([ + expect.objectContaining({ role: 'user', content: expect.arrayContaining([ + expect.objectContaining({ type: 'tool_result', tool_use_id: 'workspace-dependencies', + content: [{ type: 'text', text: JSON.stringify(paths, undefined, 2) }] }), + ]) as unknown }), + ])) + } + if (mode === 'missing-assets') expect(stderr).toContain('check_office.py') + await send(3, 'shutdown') + const exit = await child + expect(exit.timedOut).toBe(false) + expect(exit.signal).toBeUndefined() + expect(exit.exitCode, stderr).toBe(0) + await expect(readFile(join(home, 'dsh-runtimes', 'dsh-primary-runtime', 'runtime.json'))).rejects.toMatchObject({ code: 'ENOENT' }) +}) diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 3341693119..a8137ddc21 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/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 apps/desktop/README.md -README.md: 14bbdb7ec0fddd47821c12ffe46549dd29abe0de -README.zh.md: 1e704900743ec6f339efdf849e60767c961d07ee +README.md: d6980c890901ae16e21a586105f71f3214ca5e30 +README.zh.md: da67581fe609115f03cf3bfb3a518c2db5e3b320 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 14bbdb7ec0..d6980c8909 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -28,7 +28,7 @@ The payload follows the Desktop release. `runtime.json` records the Desktop vers The private Desktop `runtime/bin` directory is added only to package-installation processes, not the Host PATH inherited by PTC and agent shells. This tool does not change PATH, environment variables or user package-manager configuration. pnpm retains its own defaults and user settings for global packages, executable entries and its store, including native errors when the environment does not support global installation. There is no separate dependency updater. [The primary-runtime decision](../../.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.md) records these choices. -Node prepares the bundled interpreters and Python libraries without a system Python or pip. [The download lock](scripts/primary-runtime-lock.json) pins interpreter archives, Python distribution versions and target-specific wheel URLs and hashes; pnpm follows the Desktop build dependency lock. Wheel filenames and distribution versions must agree for every target. Preserve key order within the selected target, wheel records and distribution map, plus wheel-entry order; these affect payload identity, while top-level lock key order does not. Library wheels unpack into site-packages, retaining auxiliary files under each wheel's `.data/scripts` directory without generating command wrappers. Other installation schemes are rejected. Native-target checks verify the locked wheel set and versions, permit the interpreter's bundled pip, and check the Python version, Office document read/write operations and dependency completeness without writing bytecode after staging cleanup and again after macOS signing. The standalone Node executable receives the JIT entitlement required by V8. Cross-target execution and signed installation require the target release host. Both `dev:desktop` and `start:desktop` prepare `.desktop-build/targets//runtime/primary-runtime` before launching Electron; first use may download locked dependencies. An unfinished preparation cannot report a successful launcher exit. +Node prepares the bundled interpreters and Python libraries without a system Python or pip. [The download lock](../../scripts/primary-runtime/lock.json) pins interpreter archives, Python distribution versions and target-specific wheel URLs and hashes; pnpm follows the Desktop build dependency lock. Wheel filenames and distribution versions must agree for every target. Preserve key order within the selected target, wheel records and distribution map, plus wheel-entry order; these affect payload identity, while top-level lock key order does not. Library wheels unpack into site-packages, retaining auxiliary files under each wheel's `.data/scripts` directory without generating command wrappers. Other installation schemes are rejected. Native-target checks verify the locked wheel set and versions, permit the interpreter's bundled pip, and check the Python version, Office document read/write operations and dependency completeness without writing bytecode after staging cleanup and again after macOS signing. The standalone Node executable receives the JIT entitlement required by V8. Cross-target execution and signed installation require the target release host. Both `dev:desktop` and `start:desktop` prepare `.desktop-build/targets//runtime/primary-runtime` before launching Electron; first use may download locked dependencies. An unfinished preparation cannot report a successful launcher exit. | Decision | Why | Direct consequence | |---|---|---| diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 1e70490074..da67581fe6 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -28,7 +28,7 @@ Desktop 默认注册 `office-docx`、`office-pptx` 和 `office-xlsx`。这些技 Desktop 私有的 `runtime/bin` 目录仅添加到包安装进程,不进入 PTC 和 agent shell 从 Host 继承的 PATH。该工具不修改 PATH、环境变量或用户包管理器配置。pnpm 的全局包、命令入口和 store 保留自身默认值及用户设置,包括环境不支持全局安装时的原生错误。不提供独立依赖更新器。[第一方 Runtime 决策](../../.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.zh.md)记录这些选择。 -Node 准备内置解释器和 Python 库,无需系统 Python 或 pip。[下载锁](scripts/primary-runtime-lock.json)固定解释器压缩包、Python 分发包版本及目标平台 wheel 的 URL 和哈希;pnpm 使用 Desktop 构建依赖锁。每个目标的 wheel 文件名必须与分发包版本一致。所选目标、wheel 记录及分发包映射内部的键顺序,以及 wheel 条目顺序都会影响产物身份,编辑时须保留;锁文件顶层键的顺序不影响该身份。库 wheel 解压到 site-packages,各 wheel 的 `.data/scripts` 目录保留辅助文件,不生成命令行包装器。其他安装方案会被拒绝。本机目标检查在清理暂存目录后以及 macOS 签名后验证锁定 wheel 的集合与版本,允许解释器自带的 pip,并检查 Python 版本、Office 文档读写和依赖完整性,不写入字节码。独立 Node 可执行文件获得 V8 所需的 JIT 权限。跨目标执行和签名安装需要对应的发布主机。`dev:desktop` 和 `start:desktop` 都会在启动 Electron 前准备 `.desktop-build/targets//runtime/primary-runtime`;首次准备可能需要下载锁定的依赖。准备未完成时,启动命令不能报告成功退出。 +Node 准备内置解释器和 Python 库,无需系统 Python 或 pip。[下载锁](../../scripts/primary-runtime/lock.json)固定解释器压缩包、Python 分发包版本及目标平台 wheel 的 URL 和哈希;pnpm 使用 Desktop 构建依赖锁。每个目标的 wheel 文件名必须与分发包版本一致。所选目标、wheel 记录及分发包映射内部的键顺序,以及 wheel 条目顺序都会影响产物身份,编辑时须保留;锁文件顶层键的顺序不影响该身份。库 wheel 解压到 site-packages,各 wheel 的 `.data/scripts` 目录保留辅助文件,不生成命令行包装器。其他安装方案会被拒绝。本机目标检查在清理暂存目录后以及 macOS 签名后验证锁定 wheel 的集合与版本,允许解释器自带的 pip,并检查 Python 版本、Office 文档读写和依赖完整性,不写入字节码。独立 Node 可执行文件获得 V8 所需的 JIT 权限。跨目标执行和签名安装需要对应的发布主机。`dev:desktop` 和 `start:desktop` 都会在启动 Electron 前准备 `.desktop-build/targets//runtime/primary-runtime`;首次准备可能需要下载锁定的依赖。准备未完成时,启动命令不能报告成功退出。 | 决策 | 原因 | 直接结果 | |---|---|---| diff --git a/apps/desktop/scripts/prepare-primary-runtime.ts b/apps/desktop/scripts/prepare-primary-runtime.ts index aa3ac650cb..9baff6254a 100644 --- a/apps/desktop/scripts/prepare-primary-runtime.ts +++ b/apps/desktop/scripts/prepare-primary-runtime.ts @@ -1,157 +1,26 @@ -/** Prepare pinned, relocatable script interpreters without installing into the build host. */ +/** Desktop resource locations and signing-aware verification for the shared runtime builder. */ -import { execFileSync } from 'node:child_process' -import { createHash } from 'node:crypto' -import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { cp } from 'node:fs/promises' -import { createRequire } from 'node:module' -import { tmpdir } from 'node:os' -import { dirname, join, resolve } from 'node:path' -import extractZip from 'extract-zip' -import { x as extractTar } from 'tar' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { preparePrimaryRuntime as preparePayload, smokePrimaryRuntime as smokePayload } from '../../../scripts/primary-runtime/prepare.ts' import { workspaceDependencyPaths, type PrimaryRuntimeManifest } from '../../../packages/skill/tool-workspace-dependencies/src/index.ts' import { resolveDesktopBuildTarget, resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs' import { scrubWindowsSigningEnvironment } from './windows-sign.mjs' -import lock from './primary-runtime-lock.json' with { type: 'json' } /** - * Download or reuse an archive only when its bytes match the release lock. - * @param url - Locked archive URL. - * @param sha256 - Expected SHA-256 digest. - * @param cache - Download cache directory. - * @returns Verified local archive path. - */ -export async function downloadPrimaryRuntimeAsset(url: string, sha256: string, cache: string): Promise { - const destination = join(cache, sha256) - let bytes: Buffer - try { bytes = readFileSync(destination) } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error - const response = await fetch(url) - if (!response.ok) throw new Error(`primary runtime download: ${String(response.status)} ${url}`) - bytes = Buffer.from(await response.arrayBuffer()) - } - if (createHash('sha256').update(bytes).digest('hex') !== sha256) throw new Error(`primary runtime download: checksum mismatch for ${url}`) - writeFileSync(destination, bytes) - return destination -} - -async function pythonArchive(target: keyof typeof lock.targets, cache: string): Promise { - const artifact = lock.targets[target] - const filename = `cpython-${lock.pythonVersion}+${lock.pythonRelease}-${artifact.pythonTarget}-install_only_stripped.tar.gz` - return downloadPrimaryRuntimeAsset(`https://github.com/astral-sh/python-build-standalone/releases/download/${lock.pythonRelease}/${encodeURIComponent(filename)}`, artifact.pythonSha256, cache) -} - -/** - * Identify the inputs that assemble one target's payload, excluding unrelated target locks. - * @param target - Desktop target whose archives are installed. - * @param runtimeLock - Locked interpreter and wheel inputs. - * @param pnpmVersion - Package-manager version copied into the payload. - * @returns SHA-256 payload identity for installation reuse. - */ -export function primaryRuntimePayloadDigest(target: keyof typeof lock.targets, runtimeLock: typeof lock, pnpmVersion: string): string { - const { pythonVersion, pythonRelease, nodeVersion, wheels, pythonPackages } = runtimeLock - // Identity preserves key order within the selected target, wheel records and distribution map, plus wheel-entry order. - // Bump format when extraction or assembly changes payload bytes without changing locked inputs. - return createHash('sha256').update(JSON.stringify({ - format: 2, target, pythonVersion, pythonRelease, nodeVersion, - artifact: runtimeLock.targets[target], wheels, pythonPackages, pnpm: pnpmVersion, - })).digest('hex') -} - -/** - * Unpack a locked library wheel, retaining auxiliary scripts in its distribution data directory. - * @param archive - Hash-verified wheel archive. - * @param destination - Absolute site-packages directory. - * @returns Resolves after extraction without command wrappers; rejects other wheel installation schemes. - */ -export async function unpackPrimaryRuntimeWheel(archive: string, destination: string): Promise { - await extractZip(archive, { - dir: destination, - onEntry: (entry) => { - const [directory, scheme] = entry.fileName.split('/') - if (directory?.endsWith('.data') && scheme !== '' && scheme !== 'scripts') { - throw new Error(`primary runtime: wheel requires unsupported installation paths: ${entry.fileName}`) - } - }, - }) -} - -/** - * Copy the skill package's complete asset tree to ordinary filesystem resources. - * @param source - The package's assets directory. - * @param destination - Desktop runtime resource directory outside ASAR. - * @returns Resolves after replacing the external assets with the complete package tree. - */ -export async function prepareOfficeSkillAssets(source: string, destination: string): Promise { - rmSync(destination, { recursive: true, force: true }) - await cp(source, destination, { recursive: true, dereference: true }) -} - -/** - * Materialize the selected Desktop target's primary runtime in its build resources. + * Prepare Desktop resources for its selected packaging target. * @param options - Signed Windows packaging defers execution until its supervised signing stage. - * @returns Resolves after materialization and, unless deferred, native-target execution checks. + * @returns Resolves after preparation and, unless deferred, native-target execution checks. */ export async function preparePrimaryRuntime(options: { deferSmoke?: boolean } = {}): Promise { - const target = resolveDesktopBuildTarget() const paths = resolveDesktopTargetBuildPaths() - const artifact = lock.targets[target] - mkdirSync(paths.runtime, { recursive: true }) - mkdirSync(paths.downloads, { recursive: true }) - const staging = mkdtempSync(join(tmpdir(), 'dsh-primary-')) - try { - const output = join(staging, 'payload') - const dependencies = join(output, 'dependencies') - mkdirSync(dependencies, { recursive: true }) - const nodeFilename = `node-v${lock.nodeVersion}-${artifact.nodeArchive}` - const nodeArchive = await downloadPrimaryRuntimeAsset(`https://nodejs.org/dist/v${lock.nodeVersion}/${nodeFilename}`, artifact.nodeSha256, paths.downloads) - const unpackedNode = join(staging, 'node') - mkdirSync(unpackedNode) - if (target === 'win-x64') await extractZip(nodeArchive, { dir: unpackedNode }) - else await extractTar({ file: nodeArchive, cwd: unpackedNode }) - const nodeSource = join(unpackedNode, nodeFilename.replace(/\.(?:zip|tar\.gz)$/u, '')) - mkdirSync(join(dependencies, 'node', 'bin'), { recursive: true }) - mkdirSync(join(dependencies, 'node', 'node_modules')) - writeFileSync(join(dependencies, 'node', 'node_modules', 'README.txt'), 'Reserved for bundled Node packages. pnpm uses its default installation directories.\n') - cpSync(join(nodeSource, ...(target === 'win-x64' ? ['node.exe'] : ['bin', 'node'])), - join(dependencies, 'node', 'bin', target === 'win-x64' ? 'node.exe' : 'node')) - cpSync(join(nodeSource, 'LICENSE'), join(dependencies, 'node', 'LICENSE')) - await extractTar({ file: await pythonArchive(target, paths.downloads), cwd: dependencies }) - const require = createRequire(import.meta.url) - const pnpmManifest = require.resolve('pnpm') - const pnpm = JSON.parse(readFileSync(pnpmManifest, 'utf8')) as { version: string } - await cp(dirname(pnpmManifest), join(dependencies, 'pnpm'), { recursive: true, dereference: true }) - const desktop = JSON.parse(readFileSync(join(import.meta.dirname, '..', 'package.json'), 'utf8')) as { version: string } - const manifest: PrimaryRuntimeManifest = { - desktopVersion: desktop.version, - platform: target === 'win-x64' ? 'win32' : 'darwin', - arch: target === 'mac-arm64' ? 'arm64' : 'x64', - payloadDigest: primaryRuntimePayloadDigest(target, lock, pnpm.version), - pythonPackages: lock.pythonPackages, - components: { - python: lock.pythonVersion, node: lock.nodeVersion, pnpm: pnpm.version, - numpy: lock.pythonPackages.numpy, pandas: lock.pythonPackages.pandas, - }, - } - const entries = workspaceDependencyPaths(output, manifest) - for (const wheel of [...artifact.wheels, ...lock.wheels]) { - await unpackPrimaryRuntimeWheel(await downloadPrimaryRuntimeAsset(wheel.url, wheel.sha256, paths.downloads), entries.pythonPackages) - } - writeFileSync(join(output, 'runtime.json'), `${JSON.stringify(manifest, undefined, 2)}\n`) - const destination = join(paths.runtime, 'primary-runtime') - rmSync(destination, { recursive: true, force: true }) - await cp(output, destination, { recursive: true, dereference: true }) - } finally { - rmSync(staging, { recursive: true, force: true }) - } - const hostRequire = createRequire(resolve(import.meta.dirname, '..', '..', 'desktop-host', 'package.json')) - await prepareOfficeSkillAssets(join(dirname(hostRequire.resolve('@deepseek-ai/dsh-skill-office/package.json')), 'assets'), - join(paths.runtime, 'office-skills')) + const { version } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { version: string } + await preparePayload({ target: resolveDesktopBuildTarget(), output: paths.runtime, cache: paths.downloads, version }) if (!options.deferSmoke) smokePrimaryRuntime(join(paths.runtime, 'primary-runtime')) } /** - * Execute the native payload's interpreters, package manager and Python libraries. + * Verify Desktop's complete payload after preparation or platform signing. * @param root - Final payload directory, including any platform signatures. */ export function smokePrimaryRuntime(root: string): void { @@ -159,15 +28,8 @@ export function smokePrimaryRuntime(root: string): void { if (manifest.platform !== process.platform || manifest.arch !== process.arch) return if (manifest.pythonPackages === undefined) throw new Error('primary runtime: missing Python distribution versions; prepare the payload before running its smoke checks.') const entries = workspaceDependencyPaths(root, manifest) - // The Desktop payload always ships Node.js and pnpm; only other carriers may omit them. if (entries.node === undefined || entries.pnpm === undefined) throw new Error('primary runtime: the Desktop payload must declare node and pnpm components.') - const options = { stdio: 'inherit', timeout: 120_000, env: scrubWindowsSigningEnvironment(process.env) } as const - execFileSync(entries.python, ['-I', '-c', 'import decimal, xml.parsers.expat, lzma, uuid, numpy, pandas; assert numpy.arange(4).sum() == 6; assert pandas.DataFrame({"n": [1, 2]}).n.sum() == 3'], options) - execFileSync(entries.python, ['-I', '-B', join(import.meta.dirname, 'smoke-primary-runtime.py'), JSON.stringify(manifest.pythonPackages), - manifest.components.python, join(dirname(root), 'office-skills', 'scripts', 'check_office.py')], options) - execFileSync(entries.python, ['-I', '-B', '-m', 'pip', 'check'], options) - execFileSync(entries.node, ['-e', `if (process.versions.node !== ${JSON.stringify(manifest.components.node)}) process.exit(1)`], options) - execFileSync(entries.node, [entries.pnpm, '--version'], options) + smokePayload(root, scrubWindowsSigningEnvironment(process.env)) } if (import.meta.main) await preparePrimaryRuntime() diff --git a/apps/desktop/tests/primary-runtime-preparation.spec.ts b/apps/desktop/tests/primary-runtime-preparation.spec.ts index 2e0f802af5..f0c08054f3 100644 --- a/apps/desktop/tests/primary-runtime-preparation.spec.ts +++ b/apps/desktop/tests/primary-runtime-preparation.spec.ts @@ -1,131 +1,18 @@ -import { createHash } from 'node:crypto' -import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +/** Desktop carriers keep a complete interpreter payload even when SDK carriers choose Python-only. */ + +import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { basename, join } from 'node:path' -import { zipSync } from 'fflate' +import { join } from 'node:path' import { expect, it } from 'vitest' -import { downloadPrimaryRuntimeAsset, prepareOfficeSkillAssets, primaryRuntimePayloadDigest, smokePrimaryRuntime, unpackPrimaryRuntimeWheel } from '../scripts/prepare-primary-runtime.ts' -import lock from '../scripts/primary-runtime-lock.json' with { type: 'json' } +import { smokePrimaryRuntime } from '../scripts/prepare-primary-runtime.ts' -const libraryWheel = Buffer.from('UEsDBAoAAAAAAASeLl0sYMPjDAAAAAwAAAAJAAAAc2FtcGxlLnB5c2FtcGxlID0gNDIKUEsBAh4DCgAAAAAABJ4uXSxgw+MMAAAADAAAAAkAAAAAAAAAAQAAAKSBAAAAAHNhbXBsZS5weVBLBQYAAAAAAQABADcAAAAzAAAAAAA=', 'base64') -const relocatedWheel = Buffer.from('UEsDBAoAAAAAAASeLl3x0Nj9FAAAABQAAAAeAAAAc2FtcGxlLTEuMC5kYXRhL3NjcmlwdHMvc2FtcGxlcmVxdWlyZXMgcmVsb2NhdGlvbgpQSwECHgMKAAAAAAAEni5d8dDY/RQAAAAUAAAAHgAAAAAAAAABAAAApIEAAAAAc2FtcGxlLTEuMC5kYXRhL3NjcmlwdHMvc2FtcGxlUEsFBgAAAAABAAEATAAAAFAAAAAAAA==', 'base64') -const externalLibraryWheel = Buffer.from('UEsDBBQAAAAAAAAAIVyBOE8OHAAAABwAAAAhAAAAc2FtcGxlLTEuMC5kYXRhL3B1cmVsaWIvc2FtcGxlLnB5cmVxdWlyZXMgbGlicmFyeSByZWxvY2F0aW9uClBLAQIUAxQAAAAAAAAAIVyBOE8OHAAAABwAAAAhAAAAAAAAAAAAAACAAQAAAABzYW1wbGUtMS4wLmRhdGEvcHVyZWxpYi9zYW1wbGUucHlQSwUGAAAAAAEAAQBPAAAAWwAAAAAA', 'base64') - -it.each(Object.entries(lock.targets))('records every locked wheel distribution and version for %s', (_target, artifact) => { - const normalize = (name: string): string => name.toLowerCase().replace(/[-_.]+/gu, '-') - const distributions = [...artifact.wheels, ...lock.wheels].map(({ url }) => { - const [name, version] = basename(new URL(url).pathname).split('-') - return [normalize(name!), version] as const - }) - const declared = Object.entries(lock.pythonPackages).map(([name, version]) => [normalize(name), version] as const) - expect(new Set(distributions.map(([name]) => name)).size).toBe(distributions.length) - expect(new Set(declared.map(([name]) => name)).size).toBe(declared.length) - expect(Object.fromEntries(distributions)).toEqual(Object.fromEntries(declared)) -}) - -it('keeps a target payload identity independent of other target archives', () => { - const changed = structuredClone(lock) - changed.targets['win-x64'].wheels[0]!.sha256 = 'a'.repeat(64) - expect(primaryRuntimePayloadDigest('mac-arm64', changed, '11.7.0')).toBe(primaryRuntimePayloadDigest('mac-arm64', lock, '11.7.0')) - expect(primaryRuntimePayloadDigest('win-x64', changed, '11.7.0')).not.toBe(primaryRuntimePayloadDigest('win-x64', lock, '11.7.0')) -}) - -it('invalidates payload identity for shared wheels, package versions and package-manager changes', () => { - const wheel = structuredClone(lock), distribution = structuredClone(lock) - wheel.wheels[0]!.sha256 = 'a'.repeat(64) - distribution.pythonPackages['python-docx'] = '1.2.1' - const original = primaryRuntimePayloadDigest('mac-arm64', lock, '11.7.0') - expect(primaryRuntimePayloadDigest('mac-arm64', wheel, '11.7.0')).not.toBe(original) - expect(primaryRuntimePayloadDigest('mac-arm64', distribution, '11.7.0')).not.toBe(original) - expect(primaryRuntimePayloadDigest('mac-arm64', lock, '11.7.1')).not.toBe(original) -}) - -it('reports missing distribution metadata before trying to execute a stale native payload', async () => { - const root = await mkdtemp(join(tmpdir(), 'desktop-stale-runtime-')) +it('rejects a Python-only native Desktop payload before executing interpreters', async () => { + const root = await mkdtemp(join(tmpdir(), 'desktop-python-only-')) try { - await writeFile(join(root, 'runtime.json'), JSON.stringify({ platform: process.platform, arch: process.arch })) - expect(() => { smokePrimaryRuntime(root) }).toThrow('missing Python distribution versions; prepare the payload') - } finally { await rm(root, { recursive: true, force: true }) } -}) - -it('extracts a hash-verified cached library without a Python installer or network request', async () => { - const root = await mkdtemp(join(tmpdir(), 'desktop-wheel-')) - try { - const hash = createHash('sha256').update(libraryWheel).digest('hex') - const archive = join(root, hash) - await writeFile(archive, libraryWheel) - expect(await downloadPrimaryRuntimeAsset('https://unused.invalid/library.whl', hash, root)).toBe(archive) - await unpackPrimaryRuntimeWheel(archive, join(root, 'site-packages')) - expect(await readFile(join(root, 'site-packages/sample.py'), 'utf8')).toBe('sample = 42\n') - await writeFile(archive, 'corrupt archive') - await expect(downloadPrimaryRuntimeAsset('https://unused.invalid/library.whl', hash, root)).rejects.toThrow('checksum mismatch') - } finally { - await rm(root, { recursive: true, force: true }) - } -}) - -it('fully extracts a large deflate-compressed wheel entry', async () => { - const root = await mkdtemp(join(tmpdir(), 'desktop-large-wheel-')) - try { - const expected = Buffer.alloc(1024 * 1024) - // Seeded xorshift bytes keep the compressed entry larger than the stream buffers. - let state = 26 - for (let index = 0; index < expected.length; index++) { - state ^= state << 13 - state ^= state >>> 17 - state ^= state << 5 - expected[index] = state & 255 - } - const archive = join(root, 'large.whl') - await writeFile(archive, zipSync({ 'large.bin': expected }, { level: 6 })) - await unpackPrimaryRuntimeWheel(archive, root) - expect(await readFile(join(root, 'large.bin'))).toEqual(expected) - } finally { - await rm(root, { recursive: true, force: true }) - } -}) - -it('retains auxiliary wheel scripts without generating command wrappers', async () => { - const root = await mkdtemp(join(tmpdir(), 'desktop-wheel-')) - try { - const archive = join(root, 'relocated.whl') - await writeFile(archive, relocatedWheel) - await unpackPrimaryRuntimeWheel(archive, join(root, 'site-packages')) - expect(await readFile(join(root, 'site-packages/sample-1.0.data/scripts/sample'), 'utf8')).toBe('requires relocation\n') - } finally { - await rm(root, { recursive: true, force: true }) - } -}) - -it('rejects library files requiring an unsupported installation scheme', async () => { - const root = await mkdtemp(join(tmpdir(), 'desktop-wheel-')) - try { - const archive = join(root, 'relocated.whl') - await writeFile(archive, externalLibraryWheel) - await expect(unpackPrimaryRuntimeWheel(archive, join(root, 'site-packages'))).rejects.toThrow('unsupported installation paths') - } finally { - await rm(root, { recursive: true, force: true }) - } -}) - -it('copies complete Office resources outside the application archive and removes obsolete assets', async () => { - const root = await mkdtemp(join(tmpdir(), 'desktop-office-assets-')) - try { - const source = join(root, 'package', 'assets') - const destination = join(root, 'Contents', 'Resources', 'runtime', 'office-skills') - await mkdir(join(source, 'scripts'), { recursive: true }) - await writeFile(join(source, 'scripts', 'check_office.py'), 'print("checker")\n') - for (const name of ['office-docx', 'office-pptx', 'office-xlsx']) { - await mkdir(join(source, name)) - await writeFile(join(source, name, 'SKILL.md'), `# ${name}\n`) - } - await prepareOfficeSkillAssets(source, destination) - await writeFile(join(destination, 'obsolete.py'), 'old helper') - await prepareOfficeSkillAssets(source, destination) - for (const name of ['office-docx', 'office-pptx', 'office-xlsx']) { - expect(await readFile(join(destination, name, 'SKILL.md'), 'utf8')).toBe(`# ${name}\n`) - } - expect(await readFile(join(destination, 'scripts', 'check_office.py'), 'utf8')).toBe('print("checker")\n') - await expect(readFile(join(destination, 'obsolete.py'))).rejects.toMatchObject({ code: 'ENOENT' }) + await writeFile(join(root, 'runtime.json'), JSON.stringify({ + platform: process.platform, arch: process.arch, pythonPackages: {}, + components: { python: '3.12.14', numpy: '2.3.5', pandas: '3.0.1' }, + })) + expect(() => { smokePrimaryRuntime(root) }).toThrow('Desktop payload must declare node and pnpm') } finally { await rm(root, { recursive: true, force: true }) } }) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index a71977cacf..f1f14cf878 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.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 docs/module-graph.md -module-graph.md: 8ec3673173c299fdd69134af1002ba157d6db870 -module-graph.zh.md: 1dd43514acff155c92c139dca7ad519e137e2a03 +module-graph.md: 29caf0ba5f4b00e6ef58a8687445d2b22713cf88 +module-graph.zh.md: 7a21ed9ba9a8893bb6e72317e04b15d84dd0ca03 diff --git a/docs/module-graph.md b/docs/module-graph.md index 8ec3673173..29caf0ba5f 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -65,6 +65,7 @@ flowchart TD pkg_skill_filesystem["skill-filesystem"] pkg_skill_office["skill-office"] pkg_tool_skill["tool-skill"] + pkg_tool_workspace_dependencies["tool-workspace-dependencies"] end subgraph group_subagent["packages/subagent"] pkg_subagent["subagent"] @@ -777,6 +778,7 @@ flowchart TD pkg_tool_skill --> pkg_llm pkg_tool_skill --> pkg_skill pkg_tool_skill --> pkg_tools + pkg_tool_workspace_dependencies --> pkg_tools pkg_tool_web --> pkg_llm pkg_tool_web --> pkg_system_prompt pkg_tool_web --> pkg_tools @@ -1508,6 +1510,7 @@ flowchart TD | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) | | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | +| [`tool-workspace-dependencies`](../packages/skill/tool-workspace-dependencies) | `skill` | [`tools`](../packages/core/tools) | | [`tool-web`](../packages/web/tool-web) | `web` | [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) | | [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`tools`](../packages/core/tools) | | [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 1dd43514ac..7a21ed9ba9 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -67,6 +67,7 @@ flowchart TD pkg_skill_filesystem["skill-filesystem"] pkg_skill_office["skill-office"] pkg_tool_skill["tool-skill"] + pkg_tool_workspace_dependencies["tool-workspace-dependencies"] end subgraph group_subagent["packages/subagent"] pkg_subagent["subagent"] @@ -779,6 +780,7 @@ flowchart TD pkg_tool_skill --> pkg_llm pkg_tool_skill --> pkg_skill pkg_tool_skill --> pkg_tools + pkg_tool_workspace_dependencies --> pkg_tools pkg_tool_web --> pkg_llm pkg_tool_web --> pkg_system_prompt pkg_tool_web --> pkg_tools @@ -1510,6 +1512,7 @@ flowchart TD | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) | | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | +| [`tool-workspace-dependencies`](../packages/skill/tool-workspace-dependencies) | `skill` | [`tools`](../packages/core/tools) | | [`tool-web`](../packages/web/tool-web) | `web` | [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) | | [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`tools`](../packages/core/tools) | | [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | diff --git a/lefthook.yml b/lefthook.yml index 04106e1fd1..1d5c8898c6 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -28,7 +28,7 @@ pre-commit: # lefthook only inspects files present on disk — so that one case still # falls through to the freshness assertion in the test lane. - name: third-party notices (staged) - glob: '{package.json,*/package.json,*/*/package.json,*/*/*/package.json,*/*/*/*/package.json,pnpm-workspace.yaml,*/*/pnpm-workspace.yaml,pnpm-lock.yaml,vendor/README.md,python/*/pyproject.toml,apps/desktop/scripts/primary-runtime-lock.json,scripts/gen-third-party-notices.ts,scripts/browser-bundled-externals.ts,scripts/build-exe-for-python-sdk.ts,tsconfig.base*.json,packages/*/*/src/**/*,packages/*/*/tsdown.config.ts,packages/client/tsdown.client.ts,apps/*/src/**/*,apps/*/vite.config.ts}' + glob: '{package.json,*/package.json,*/*/package.json,*/*/*/package.json,*/*/*/*/package.json,pnpm-workspace.yaml,*/*/pnpm-workspace.yaml,pnpm-lock.yaml,vendor/README.md,python/*/pyproject.toml,scripts/primary-runtime/lock.json,scripts/gen-third-party-notices.ts,scripts/browser-bundled-externals.ts,scripts/build-exe-for-python-sdk.ts,tsconfig.base*.json,packages/*/*/src/**/*,packages/*/*/tsdown.config.ts,packages/client/tsdown.client.ts,apps/*/src/**/*,apps/*/vite.config.ts}' run: node_modules/.bin/tsx scripts/gen-third-party-notices.ts && git add THIRD_PARTY_NOTICES.md - name: whitespace (staged) diff --git a/package.json b/package.json index 52cbabab43..e7310d9553 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "website" ], "scripts": { + "prepare:primary-runtime": "tsx scripts/primary-runtime/prepare.ts", "build": "tsx scripts/build.ts", "build:bench": "npm run build:native-system && npm run build:lib && tsdown --config benchmarks/tsdown.config.ts", "build:official": "tsx scripts/build.ts --profile official", @@ -89,6 +90,7 @@ "check:ci:windows-complete": "tsx scripts/run-gates.ts ci-windows-complete", "check:ci:windows-observational": "tsx scripts/run-gates.ts ci-windows-observational", "check:node-compat": "tsx scripts/run-gates.ts node-compat", + "pnpm": "11.7.0", "publint": "tsx scripts/publint-all.ts", "doc-typecheck": "npm run build:lib:host && npm run doc-typecheck:contracts-ready", "doc-typecheck:contracts-ready": "tsx scripts/doc-typecheck.ts", @@ -219,7 +221,9 @@ "@yarnpkg/cli-dist": "4.17.1", "eslint-plugin-sonarjs": "^4.1.0", "execa": "^10.0.0", + "extract-zip": "^2.0.1", "fast-check": "^4.8.0", + "fflate": "^0.8.2", "istanbul-lib-report": "^3.0.1", "js-yaml": "^4.2.0", "jscpd": "^5.0.12", @@ -232,9 +236,11 @@ "micromark-extension-gfm": "^3.0.0", "oxlint": "1.76.0", "oxlint-tsgolint": "7.0.2001", + "pnpm": "11.7.0", "publint": "^0.3.21", "smol-toml": "^1.7.1", "spdx-expression-parse": "^5.0.0", + "tar": "^7.5.0", "tsdown": "^0.22.2", "tsx": "^4.22.4", "typescript": "^6.0.3", diff --git a/packages/bundle/sdk-app/README.i18n.yaml b/packages/bundle/sdk-app/README.i18n.yaml index ac62ef092e..616f7466f0 100644 --- a/packages/bundle/sdk-app/README.i18n.yaml +++ b/packages/bundle/sdk-app/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/bundle/sdk-app/README.md -README.md: 0bac09b5a7e355434e35b04f4e9abd3e816505af -README.zh.md: e0e0d7e1e147c0a2dfaed7014ea0b3a0a9028f2b +README.md: acf038281ea56b435abfcaeb7e8afddcbfbfb7e4 +README.zh.md: 8e5c3f994b3d435bcfe6997f6bddc9d917f7307a diff --git a/packages/bundle/sdk-app/README.md b/packages/bundle/sdk-app/README.md index 0bac09b5a7..acf038281e 100644 --- a/packages/bundle/sdk-app/README.md +++ b/packages/bundle/sdk-app/README.md @@ -33,6 +33,8 @@ The startup provider binds stdin EOF to the launcher's bounded successful shutdo The SDK uses the base `read`, `write`, and `edit` defaults. To add `str_replace_editor`, use the explicit insertion patch in the [base configuration guide](../base/README.md#use-this-package). The standalone `sdk-minimal` profile owns its separate tool selection. +A deployment that sets `DSH_PRIMARY_RUNTIME` enables the [bundled Office runtime query and skills](../../skill/tool-workspace-dependencies/README.md#use-this-package). Unset or empty configuration leaves both disabled. The carrier supplies a native payload and sibling `office-skills/`; the tool reads them in place, including from a read-only image layer. Missing skill resources produce a startup warning and leave Office skills unavailable. Invalid runtime metadata or missing interpreter entries fail the tool call. + ----- diff --git a/packages/bundle/sdk-app/README.zh.md b/packages/bundle/sdk-app/README.zh.md index e0e0d7e1e1..8e5c3f994b 100644 --- a/packages/bundle/sdk-app/README.zh.md +++ b/packages/bundle/sdk-app/README.zh.md @@ -33,6 +33,8 @@ kind: "package-bundle" SDK 使用 base 默认提供的 `read`、`write` 和 `edit`。要添加 `str_replace_editor`,请使用 [base 配置指南](../base/README.zh.md#use-this-package)中的显式插入 patch。独立的 `sdk-minimal` profile 自行决定其工具选择。 +部署设置 `DSH_PRIMARY_RUNTIME` 后会启用[内置 Office 运行时查询与 skills](../../skill/tool-workspace-dependencies/README.zh.md#use-this-package)。未设置或为空时两者都禁用。载体提供本机 payload 与同级 `office-skills/`;工具原位读取,也支持只读镜像层。缺少 skill 资源会产生启动警告并使 Office skills 不可用。无效运行时元数据或缺失解释器条目会导致工具调用失败。 + ----- diff --git a/packages/bundle/sdk-app/cordis.patch.yml b/packages/bundle/sdk-app/cordis.patch.yml index e80f962953..94331513cf 100644 --- a/packages/bundle/sdk-app/cordis.patch.yml +++ b/packages/bundle/sdk-app/cordis.patch.yml @@ -27,18 +27,17 @@ # Bundled script runtime and Office skills, the composition Desktop mounts as # `desktop-office`. A carrier that ships the primary-runtime payload (for # example a container image layer) names it with DSH_PRIMARY_RUNTIME; its -# sibling `office-skills/` directory carries the skill resources. Unset, both -# rows stay disabled and the profile is unchanged. +# sibling `office-skills/` directory carries the skill resources. Missing resources +# produce a startup warning and leave the Office skill provider inactive. Unset, both rows stay disabled. - insert: - id: workspace-dependencies name: '@deepseek-ai/dsh-tool-workspace-dependencies' disabled: !!js "!process.env.DSH_PRIMARY_RUNTIME" config: - source: !!js process.env.DSH_PRIMARY_RUNTIME ?? '' + source: !!js "process.getBuiltinModule('node:path').resolve(process.env.DSH_PRIMARY_RUNTIME ?? '')" - id: skill-office name: '@deepseek-ai/dsh-skill-office' disabled: !!js "!process.env.DSH_PRIMARY_RUNTIME" config: - # The payload's parent directory plus office-skills, without a path module in the expression scope. - assetRoot: !!js "process.env.DSH_PRIMARY_RUNTIME ? process.env.DSH_PRIMARY_RUNTIME.replace(/[\\\\/]+[^\\\\/]+[\\\\/]*$/, '') + '/office-skills' : ''" + assetRoot: !!js "process.getBuiltinModule('node:path').resolve(process.env.DSH_PRIMARY_RUNTIME ?? '', '..', 'office-skills')" diff --git a/packages/core/tools/tests/gen-tool-catalog.spec.ts b/packages/core/tools/tests/gen-tool-catalog.spec.ts index ba76ed68e4..0519f3b9cb 100644 --- a/packages/core/tools/tests/gen-tool-catalog.spec.ts +++ b/packages/core/tools/tests/gen-tool-catalog.spec.ts @@ -31,7 +31,7 @@ describe('gen-tool-catalog collectToolCatalog', () => { 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'glob', 'grep', 'interrupt_agent', 'interrupt_agent', 'job_kill', 'job_list', 'job_output', 'list_agents', 'list_agents', 'list_mcp_resource_templates', 'list_mcp_resources', - 'list_subagent_models', 'lsp', 'plugin_manager', 'present', 'pwsh', 'pwsh', 'ralph', + 'list_subagent_models', 'load_workspace_dependencies', 'lsp', 'plugin_manager', 'present', 'pwsh', 'pwsh', 'ralph', 'read', 'read_image', 'read_mcp_resource', 'run_code', 'schedule_create', 'schedule_delete', 'schedule_list', 'send_message', 'send_message', 'session_event_read', 'session_event_search', 'session_event_trace', 'session_search', 'session_trace', 'skill', 'spawn_teammate', diff --git a/packages/skill/README.i18n.yaml b/packages/skill/README.i18n.yaml index b147ecb55a..bcc2766dbf 100644 --- a/packages/skill/README.i18n.yaml +++ b/packages/skill/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/README.md -README.md: 9546dfbfb2081480a1af8078d65481d0fc080ae3 -README.zh.md: eb1231b6fc7e110afdcc22fb25db3dd55b830092 +README.md: 75261bdca06b658f04fac17a3eab4306dbe5cecd +README.zh.md: 7d1c38a4fb456f26bf038c8ddfe6f8182d0058f8 diff --git a/packages/skill/README.md b/packages/skill/README.md index 9546dfbfb2..75261bdca0 100644 --- a/packages/skill/README.md +++ b/packages/skill/README.md @@ -29,6 +29,7 @@ The skill family lets agents and users discover and load reusable task instructi | [`skill-badge/`](skill-badge/README.md) | Bundles the official "powered by dsh" badge skill, disabled by default | registers on `ctx.skills` | | [`skill-office/`](skill-office/README.md) | Bundles Word, PowerPoint, and Excel workflows with structural file checks | registers on `ctx.skills` | | [`tool-skill/`](tool-skill/README.md) | Publishes the session skill catalog and the model-facing `skill` loader tool | registers on `ctx.tools` | +| [`tool-workspace-dependencies/`](tool-workspace-dependencies/README.md) | Reports bundled Office interpreter paths and versions for Desktop and SDK carriers | registers on `ctx.tools` | ----- diff --git a/packages/skill/README.zh.md b/packages/skill/README.zh.md index eb1231b6fc..7d1c38a4fb 100644 --- a/packages/skill/README.zh.md +++ b/packages/skill/README.zh.md @@ -29,6 +29,7 @@ skill 家族让 agent 和用户仅在需要时发现并加载可复用的任务 | [`skill-badge/`](skill-badge/README.zh.md) | 随包附带官方「powered by dsh」徽章 skill,默认禁用 | 注册到 `ctx.skills` | | [`skill-office/`](skill-office/README.zh.md) | 随包提供 Word、PowerPoint 和 Excel 工作流及文件结构检查 | 注册到 `ctx.skills` | | [`tool-skill/`](tool-skill/README.zh.md) | 发布会话 skill 目录与面向模型的 `skill` 加载工具 | 注册到 `ctx.tools` | +| [`tool-workspace-dependencies/`](tool-workspace-dependencies/README.zh.md) | 为 Desktop 与 SDK 载体报告内置 Office 解释器路径和版本 | 注册到 `ctx.tools` | ----- diff --git a/packages/skill/tool-workspace-dependencies/README.i18n.yaml b/packages/skill/tool-workspace-dependencies/README.i18n.yaml index 397d6f1a5b..9664f745e2 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: d39437c039ff479900f8192e666ab67f2dbedc89 -README.zh.md: c45cd21e810eb8a6a2c3e8bf22da4a5bdc8089ff +README.md: 1464e231980bc24559d5dfc2635d51e57bcfc116 +README.zh.md: 48520369dde7cb0a5fcfb8d53af687ce651c47d5 diff --git a/packages/skill/tool-workspace-dependencies/README.md b/packages/skill/tool-workspace-dependencies/README.md index d39437c039..1464e23198 100644 --- a/packages/skill/tool-workspace-dependencies/README.md +++ b/packages/skill/tool-workspace-dependencies/README.md @@ -37,14 +37,20 @@ Mount the plugin beside the tool registry with the payload directory. The bundle | Field | Default | Meaning | |---|---|---| -| `source` | required | Payload directory carrying `runtime.json` and `dependencies/`. | -| `root` | unset | Installation directory under the Harness home. Set: the payload is copied there on the first call and reused while `runtime.json` is unchanged. Unset: the payload is validated and used in place; nothing is copied. | +| `source` | required | Absolute payload directory carrying `runtime.json` and `dependencies/`. | +| `root` | unset | Absolute installation directory under the Harness home. Set: the payload is copied there on the first call and reused while `runtime.json` is unchanged. Unset: the payload is validated and used in place; nothing is copied. | ### Payload layout `runtime.json` records `desktopVersion`, `platform` (`win32`, `darwin`, or `linux`), `arch`, the `components` versions (`python`, `numpy`, `pandas`; `node` and `pnpm` are optional), and optionally `payloadDigest` and `pythonPackages`. Entries live under `dependencies/`: `python/bin/python3` (`python/python.exe` on Windows) with `site-packages` beneath it, and, when declared, `node/bin/node` with `node/node_modules` and `pnpm/bin/pnpm.mjs`. A manifest whose platform or architecture differs from the running process is rejected. -The `sdk` profile mounts this tool and the Office skills when `DSH_PRIMARY_RUNTIME` names a payload directory whose sibling `office-skills/` carries the skill resources; both rows stay disabled otherwise. +The `sdk` profile enables this tool and the Office skills when `DSH_PRIMARY_RUNTIME` is nonempty. The profile resolves that path from the launch directory and loads sibling `office-skills/` resources. Missing skill resources produce a startup warning and leave Office skills unavailable; invalid or incomplete runtime payloads fail the first tool call. Unset or empty configuration disables both rows. Profile patches can override either row, and changes require restarting the SDK process. + +### Build a carrier payload + +From a repository checkout with dependencies installed, `CI=true pnpm run prepare:primary-runtime --target linux-x64 --output /tmp/dsh-office` writes `primary-runtime/` and `office-skills/`. The shared [download lock](../../../scripts/primary-runtime/lock.json) also covers `mac-arm64`, `mac-x64`, and `win-x64`. `--python-only` omits Node.js and pnpm; `--cache` selects the hash-verified archive cache. The entry executes interpreter and Office read/write checks only for a native target. Cross-target builds require those checks on the target host before deployment. + +A container can copy both directories into an immutable image layer and set `DSH_PRIMARY_RUNTIME` to the absolute `primary-runtime/` path. The SDK queries that payload in place. Desktop uses the same builder and retains its Harness-home installation and signing checks. ----- @@ -108,7 +114,7 @@ Append-only tool result in the turn history; no prompt section is added. -- The payload build (`apps/desktop/scripts/prepare-primary-runtime.ts`) still targets Desktop; other carriers assemble the same layout themselves. +- The Linux build target is GNU/Linux x64; Linux ARM64 and musl payloads are not locked. - Windows payloads used in place must already be executable from their carrier; the in-place mode performs no permission repair. @@ -117,6 +123,6 @@ Append-only tool result in the turn history; no prompt section is added.
Working context for maintainers — click to expand -Extracted from `apps/desktop-host` so the SDK profile can mount the same tool from a container image layer. The in-place mode and the optional Node.js/pnpm components exist for that carrier. +Placement and carrier choices are recorded in the [shared-runtime Agent Note](../../../.agents/notes/implemented/architecture/2026-09-17-shared-office-runtime.md).
diff --git a/packages/skill/tool-workspace-dependencies/README.zh.md b/packages/skill/tool-workspace-dependencies/README.zh.md index c45cd21e81..48520369dd 100644 --- a/packages/skill/tool-workspace-dependencies/README.zh.md +++ b/packages/skill/tool-workspace-dependencies/README.zh.md @@ -37,14 +37,20 @@ kind: "package-reference" | 字段 | 默认值 | 含义 | |---|---|---| -| `source` | 必填 | 含 `runtime.json` 与 `dependencies/` 的 payload 目录。 | -| `root` | 未设置 | Harness home 下的安装目录。设置时首次调用把 payload 复制过去,`runtime.json` 不变则复用;未设置时校验后原地使用,不复制。 | +| `source` | 必填 | 含 `runtime.json` 与 `dependencies/` 的 payload 绝对目录。 | +| `root` | 未设置 | Harness home 下的绝对安装目录。设置时首次调用把 payload 复制过去,`runtime.json` 不变则复用;未设置时校验后原地使用,不复制。 | ### payload 布局 `runtime.json` 记录 `desktopVersion`、`platform`(`win32`、`darwin` 或 `linux`)、`arch`、`components` 各版本(`python`、`numpy`、`pandas`;`node` 与 `pnpm` 可选),以及可选的 `payloadDigest` 与 `pythonPackages`。条目位于 `dependencies/`:`python/bin/python3`(Windows 为 `python/python.exe`)及其下的 `site-packages`;声明了才有的 `node/bin/node`、`node/node_modules` 与 `pnpm/bin/pnpm.mjs`。平台或架构与当前进程不符的清单被拒绝。 -`sdk` profile 在 `DSH_PRIMARY_RUNTIME` 指向一个 payload 目录、且其同级 `office-skills/` 带有 skill 资源时挂载本工具与 Office skills;否则两行都保持禁用。 +`DSH_PRIMARY_RUNTIME` 非空时,`sdk` profile 启用本工具和 Office skills。profile 从启动目录解析该路径,并加载同级 `office-skills/` 资源。缺少 skill 资源会产生启动警告并使 Office skills 不可用;无效或不完整的运行时 payload 会在首次工具调用时失败。未设置或为空时,两行都禁用。profile patch 可以覆盖任意一行,配置变更需要重启 SDK 进程。 + +### 构建载体 payload + +在已安装依赖的仓库 checkout 中运行 `CI=true pnpm run prepare:primary-runtime --target linux-x64 --output /tmp/dsh-office`,会生成 `primary-runtime/` 和 `office-skills/`。[共享下载锁](../../../scripts/primary-runtime/lock.json)还覆盖 `mac-arm64`、`mac-x64` 和 `win-x64`。`--python-only` 省略 Node.js 和 pnpm;`--cache` 选择经过哈希校验的归档缓存。入口仅对本机目标执行解释器与 Office 读写检查。跨目标构建必须在部署前到目标主机执行这些检查。 + +容器可将这两个目录复制到不可变镜像层,并将 `DSH_PRIMARY_RUNTIME` 设为 `primary-runtime/` 的绝对路径。SDK 原位查询该 payload。Desktop 使用同一构建器,并保留 Harness-home 安装与签名检查。 ----- @@ -108,7 +114,7 @@ kind: "package-reference" -- payload 的构建脚本(`apps/desktop/scripts/prepare-primary-runtime.ts`)仍只面向 Desktop;其它载体按同一布局自行组装。 +- Linux 构建目标为 GNU/Linux x64;尚未锁定 Linux ARM64 与 musl payload。 - Windows 上原地使用的 payload 须在载体里已可执行;原地模式不修复权限。 @@ -117,6 +123,6 @@ kind: "package-reference"
维护者的工作上下文——点击展开 -从 `apps/desktop-host` 抽出,让 SDK profile 能从容器镜像层挂载同一个工具。原地模式与可选的 Node.js/pnpm 组件都是为这种载体加的。 +包归属与载体选择记录在[共享运行时 Agent Note](../../../.agents/notes/implemented/architecture/2026-09-17-shared-office-runtime.zh.md) 中。
diff --git a/packages/skill/tool-workspace-dependencies/src/index.ts b/packages/skill/tool-workspace-dependencies/src/index.ts index 4069234c3e..e957ceab7d 100644 --- a/packages/skill/tool-workspace-dependencies/src/index.ts +++ b/packages/skill/tool-workspace-dependencies/src/index.ts @@ -1,7 +1,7 @@ /** Model-facing query for a bundled Python, Node.js, and pnpm payload, in place or installed under the Harness home. */ import { cp, lstat, mkdir, mkdtemp, readFile, rename, rm, stat } from 'node:fs/promises' -import { dirname, join } from 'node:path' +import { dirname, isAbsolute, join } from 'node:path' import type { Context } from '@deepseek-ai/cordis' import { defineTool } from '@deepseek-ai/dsh-tools' @@ -193,6 +193,9 @@ export async function installPrimaryRuntime(source: string, root: string): Promi * @param config - Payload location and optional installation directory. */ export function apply(ctx: Context, config: Config): void { + if (!isAbsolute(config.source) || (config.root !== undefined && !isAbsolute(config.root))) { + throw new Error('workspace dependencies: source and root must be absolute paths') + } let preparation: Promise | undefined ctx.effect(() => async () => { // Tool execution reports preparation failures; disposal only waits for filesystem work to settle. 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 30b39ce477..73f12ab47b 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 @@ -1,8 +1,9 @@ +import * as fs from 'node:fs/promises' import { mkdir, mkdtemp, readFile, rename, rm, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { pathToFileURL } from 'node:url' -import { afterEach, expect, it } from 'vitest' +import { afterEach, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' import Include from '@deepseek-ai/cordis-plugin-include' @@ -13,8 +14,16 @@ import { ToolCallId } from '@deepseek-ai/dsh-llm' import * as workspaceDependencies from '../src/index.ts' import { installPrimaryRuntime, readPrimaryRuntime, resolvePrimaryRuntime, workspaceDependencyPaths, type PrimaryRuntimeManifest } from '../src/index.ts' +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, rename: vi.fn(actual.rename), lstat: vi.fn(actual.lstat), cp: vi.fn(actual.cp) } +}) + const roots: string[] = [] -afterEach(async () => { await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))) }) +afterEach(async () => { + vi.resetAllMocks() + await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))) +}) async function fixture() { const directory = await mkdtemp(join(tmpdir(), 'dsh-primary-runtime-')) @@ -46,7 +55,7 @@ it.each(['win32', 'darwin', 'linux'])('returns %s interpreter and package paths' expect(paths.pythonPackages).toBe(join('/runtime', 'dependencies', 'python', ...(platform === 'win32' ? ['Lib'] : ['lib', 'python3.12']), 'site-packages')) }) -it.skipIf(process.platform === 'linux')('installs offline, reuses the same release, and leaves environment and user packages unchanged', async () => { +it('installs offline, reuses the same release, and leaves environment and user packages unchanged', async () => { const { source, root, manifest } = await fixture() const environment = { ...process.env } const installed = await installPrimaryRuntime(source, root) @@ -57,7 +66,7 @@ it.skipIf(process.platform === 'linux')('installs offline, reuses the same relea expect(process.env).toEqual(environment) }) -it.skipIf(process.platform === 'linux')('replaces release components and recovers an interrupted directory swap', async () => { +it('replaces release components and recovers an interrupted directory swap', async () => { const { source, root, manifest } = await fixture() await installPrimaryRuntime(source, root) await rename(root, `${root}.previous`) @@ -66,7 +75,7 @@ it.skipIf(process.platform === 'linux')('replaces release components and recover expect((await readPrimaryRuntime(root)).desktopVersion).toBe('2.0.0') }) -it.skipIf(process.platform === 'linux')('replaces dependencies when the locked payload changes without a Desktop version change', async () => { +it('replaces dependencies when the locked payload changes without a Desktop version change', async () => { const { source, root, manifest } = await fixture() const first = { ...manifest, payloadDigest: 'a'.repeat(64), pythonPackages: { 'python-docx': '1.1.2' } } await writeFile(join(source, 'runtime.json'), JSON.stringify(first)) @@ -81,7 +90,7 @@ it.skipIf(process.platform === 'linux')('replaces dependencies when the locked p await expect(readFile(join(installed.pythonPackages, 'old-package.py'))).rejects.toMatchObject({ code: 'ENOENT' }) }) -it.skipIf(process.platform === 'linux')('upgrades a release manifest without a payload digest', async () => { +it('upgrades a release manifest without a payload digest', async () => { const { source, root, manifest } = await fixture() await installPrimaryRuntime(source, root) await writeFile(join(source, 'runtime.json'), JSON.stringify({ ...manifest, payloadDigest: 'a'.repeat(64), pythonPackages: { 'python-docx': '1.2.0' } })) @@ -89,7 +98,7 @@ it.skipIf(process.platform === 'linux')('upgrades a release manifest without a p expect((await readPrimaryRuntime(root)).payloadDigest).toBe('a'.repeat(64)) }) -it.skipIf(process.platform === 'linux')('replaces changed payload bytes when only the digest changes', async () => { +it('replaces changed payload bytes when only the digest changes', async () => { const { source, root, manifest } = await fixture() const first = { ...manifest, payloadDigest: 'a'.repeat(64), pythonPackages: { 'python-docx': '1.2.0' } } const sourceFile = join(workspaceDependencyPaths(source, first).pythonPackages, 'library.py') @@ -103,7 +112,7 @@ it.skipIf(process.platform === 'linux')('replaces changed payload bytes when onl expect((await readPrimaryRuntime(root)).pythonPackages).toEqual(first.pythonPackages) }) -it.skipIf(process.platform === 'linux')('keeps the installed release when the replacement payload is incomplete', async () => { +it('keeps the installed release when the replacement payload is incomplete', async () => { const { source, root, manifest } = await fixture() await installPrimaryRuntime(source, root) await writeFile(join(source, 'runtime.json'), JSON.stringify({ ...manifest, desktopVersion: '2.0.0' })) @@ -112,7 +121,7 @@ it.skipIf(process.platform === 'linux')('keeps the installed release when the re expect((await readPrimaryRuntime(root)).desktopVersion).toBe('1.0.0') }) -it.skipIf(process.platform === 'linux')('refuses linked installation directories without modifying their targets', async () => { +it('refuses linked installation directories without modifying their targets', async () => { const { source, root, directory } = await fixture() const outside = join(directory, 'outside') await mkdir(outside) @@ -217,3 +226,122 @@ it('rejects a payload whose Node.js or pnpm version is malformed', async () => { await writeFile(join(source, 'runtime.json'), JSON.stringify({ ...manifest, components: { ...manifest.components, node: 'latest' } })) await expect(readPrimaryRuntime(source)).rejects.toThrow('invalid metadata') }) + +/** Create the real scoped tool registry for lifecycle and failure-path assertions. */ +async function tool(config: workspaceDependencies.Config) { + const ctx = new Context() + try { + await ctx.plugin(AgentRegistry) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRuntime) + const fiber = ctx.plugin(workspaceDependencies, config) + await fiber + const execute = () => ctx.tools.execute({ + signal: new AbortController().signal, name: 'load_workspace_dependencies', + arguments: {}, callId: ToolCallId('dependencies'), + }) + return { ctx, fiber, execute } + } catch (error) { + await ctx.fiber.dispose() + throw error + } +} + +it.each([null, [], { desktopVersion: '' }, { platform: 'unsupported' }, { arch: 'ia32' }, + { components: null }, { components: { python: 3 } }, { payloadDigest: 1 }, + { pythonPackages: null }, { pythonPackages: { numpy: 3 } }, +])('rejects malformed manifest fields: %j', async (invalid) => { + const { source, manifest } = await fixture() + const value = invalid === null || Array.isArray(invalid) ? invalid : { ...manifest, ...invalid } + await writeFile(join(source, 'runtime.json'), JSON.stringify(value)) + await expect(readPrimaryRuntime(source)).rejects.toThrow('invalid metadata') +}) + +it('reads legacy metadata without a distribution map and rejects incompatible platforms', async () => { + const { source, manifest } = await fixture() + const { pythonPackages: _packages, ...legacy } = manifest + await writeFile(join(source, 'runtime.json'), JSON.stringify(legacy)) + expect((await resolvePrimaryRuntime(source)).pythonDistributions).toEqual({}) + await writeFile(join(source, 'runtime.json'), JSON.stringify({ ...manifest, platform: process.platform === 'linux' ? 'darwin' : 'linux' })) + await expect(resolvePrimaryRuntime(source)).rejects.toThrow('incompatible') +}) + +it('propagates filesystem access failures without touching the installation', async () => { + const { source, root } = await fixture() + vi.mocked(fs.lstat).mockRejectedValueOnce(Object.assign(new Error('denied'), { code: 'EACCES' })) + await expect(installPrimaryRuntime(source, root)).rejects.toThrow('denied') + await expect(readFile(join(root, 'runtime.json'))).rejects.toMatchObject({ code: 'ENOENT' }) +}) + +it.each([false, true])('preserves the previous installation if publication fails (previous: %s)', async (previous) => { + const { source, root, manifest } = await fixture() + if (previous) { + await installPrimaryRuntime(source, root) + await writeFile(join(source, 'runtime.json'), JSON.stringify({ ...manifest, desktopVersion: '2.0.0' })) + } + const actual = await vi.importActual('node:fs/promises') + vi.mocked(fs.rename).mockImplementationOnce(previous ? actual.rename : async () => { throw new Error('publication failed') }) + if (previous) vi.mocked(fs.rename).mockRejectedValueOnce(new Error('publication failed')) + await expect(installPrimaryRuntime(source, root)).rejects.toThrow('publication failed') + if (previous) expect((await readPrimaryRuntime(root)).desktopVersion).toBe('1.0.0') + else await expect(readFile(join(root, 'runtime.json'))).rejects.toMatchObject({ code: 'ENOENT' }) + expect((await fs.readdir(dirname(root))).filter(name => name.startsWith('.primary-runtime-'))).toEqual([]) +}) + +it('retries a failed tool preparation and renders its call presentation', async () => { + const { source, root } = await fixture() + const metadata = await readFile(join(source, 'runtime.json')) + await rm(join(source, 'runtime.json')) + const { ctx, execute } = await tool({ source, root }) + try { + expect((await execute()).isError).toBe(true) + await writeFile(join(source, 'runtime.json'), metadata) + expect((await execute()).isError).toBe(false) + expect(await readFile(join(root, 'runtime.json'))).toEqual(metadata) + const descriptor = ctx.tools.get('load_workspace_dependencies')! + expect(descriptor.presentCall?.({})).toEqual({ card: 'generic', title: 'Load workspace dependencies', kind: 'read' }) + } finally { await ctx.fiber.dispose() } +}) + +it.each([false, true])('disposal waits for pending filesystem preparation (failure: %s)', async (failure) => { + const { source, root } = await fixture() + const actual = await vi.importActual('node:fs/promises') + let release!: () => void + let copying!: () => void + const entered = new Promise((resolve) => { copying = resolve }) + const proceed = new Promise((resolve) => { release = resolve }) + vi.mocked(fs.cp).mockImplementationOnce(async (...args) => { + copying() + await proceed + if (failure) throw new Error('copy failed') + await actual.cp(...args) + }) + const { ctx, fiber, execute } = await tool({ source, root }) + const pending = execute() + try { + await entered + let disposed = false + const disposal = fiber.dispose().then(() => { disposed = true }) + await Promise.resolve() + expect(disposed).toBe(false) + release() + await disposal + expect((await pending).isError).toBe(failure) + expect(ctx.tools.schemas().some(value => value.name === 'load_workspace_dependencies')).toBe(false) + } finally { + release() + await pending + await ctx.fiber.dispose() + } +}) + +it('disposes an unused tool without preparing the payload', async () => { + const { source, root } = await fixture() + const { ctx } = await tool({ source, root }) + await ctx.fiber.dispose() + await expect(readFile(join(root, 'runtime.json'))).rejects.toMatchObject({ code: 'ENOENT' }) +}) + +it.each([{ source: 'relative' }, { source: process.cwd(), root: 'relative' }])('rejects relative runtime configuration at plugin load: %j', async (config) => { + await expect(tool(config)).rejects.toThrow('must be absolute paths') +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 29df5ca749..4368a35618 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -99,9 +99,15 @@ importers: execa: specifier: ^10.0.0 version: 10.0.0 + extract-zip: + specifier: ^2.0.1 + version: 2.0.1(supports-color@9.4.0) fast-check: specifier: ^4.8.0 version: 4.8.0 + fflate: + specifier: ^0.8.2 + version: 0.8.3 istanbul-lib-report: specifier: ^3.0.1 version: 3.0.1 @@ -138,6 +144,9 @@ importers: oxlint-tsgolint: specifier: 7.0.2001 version: 7.0.2001 + pnpm: + specifier: 11.7.0 + version: 11.7.0 publint: specifier: ^0.3.21 version: 0.3.21 @@ -147,6 +156,9 @@ importers: spdx-expression-parse: specifier: ^5.0.0 version: 5.0.0 + tar: + specifier: ^7.5.0 + version: 7.5.22 tsdown: specifier: ^0.22.2 version: 0.22.2(oxc-resolver@11.20.0)(publint@0.3.21)(tsx@4.22.4)(typescript@6.0.3) diff --git a/scripts/gen-third-party-notices.spec.ts b/scripts/gen-third-party-notices.spec.ts index 3ab18f9291..e4a452d712 100644 --- a/scripts/gen-third-party-notices.spec.ts +++ b/scripts/gen-third-party-notices.spec.ts @@ -2,7 +2,7 @@ import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSyn import { join, resolve } from 'node:path' import { tmpdir } from 'node:os' import { describe, expect, it } from 'vitest' -import desktopRuntimeLock from '../apps/desktop/scripts/primary-runtime-lock.json' with { type: 'json' } +import desktopRuntimeLock from './primary-runtime/lock.json' with { type: 'json' } import { CLAUDE_AGENT_SDK_PACKAGE, assertRuntimeLicenses, diff --git a/scripts/gen-third-party-notices.ts b/scripts/gen-third-party-notices.ts index c77fbd6b38..c65dbdc6f1 100644 --- a/scripts/gen-third-party-notices.ts +++ b/scripts/gen-third-party-notices.ts @@ -13,7 +13,7 @@ import { dirname, resolve } from 'node:path' import * as yaml from 'js-yaml' import { parse as parseToml, type TomlTableWithoutBigInt, type TomlValueWithoutBigInt } from 'smol-toml' import parseSpdx from 'spdx-expression-parse' -import desktopRuntimeLock from '../apps/desktop/scripts/primary-runtime-lock.json' with { type: 'json' } +import desktopRuntimeLock from './primary-runtime/lock.json' with { type: 'json' } import { browserBundledExternals } from './browser-bundled-externals.ts' const root = resolve(import.meta.dirname, '..') @@ -819,7 +819,7 @@ ${python.map(dep => `| [\`${dep.name}\`](${dep.repo}) | ${dep.license} | ${dep.r ## Desktop bundled Python distributions -The [Desktop runtime lock](apps/desktop/scripts/primary-runtime-lock.json) records each distribution version and the wheel download hashes. The table includes every entry in \`pythonPackages\`, including transitive dependencies. Wheel extraction preserves distribution metadata and the license and notice files supplied by each archive. Project licenses below do not enumerate the separate licenses of native libraries bundled inside wheels. +The [Desktop runtime lock](scripts/primary-runtime/lock.json) records each distribution version and the wheel download hashes. The table includes every entry in \`pythonPackages\`, including transitive dependencies. Wheel extraction preserves distribution metadata and the license and notice files supplied by each archive. Project licenses below do not enumerate the separate licenses of native libraries bundled inside wheels. | Distribution | Locked version | Project license | | --- | --- | --- | diff --git a/apps/desktop/scripts/primary-runtime-lock.json b/scripts/primary-runtime/lock.json similarity index 82% rename from apps/desktop/scripts/primary-runtime-lock.json rename to scripts/primary-runtime/lock.json index 4dcf73c886..060d2018a6 100644 --- a/apps/desktop/scripts/primary-runtime-lock.json +++ b/scripts/primary-runtime/lock.json @@ -74,6 +74,30 @@ "sha256": "623c8799c17128753c65699f1c3aa32402657393a9ad6db09ed8b98ddf76611d" } ] + }, + "linux-x64": { + "nodeArchive": "linux-x64.tar.gz", + "nodeSha256": "6e1db87ef58b8819e5d5402eff1536491b18edd8eb7bee5ef7897876e88dc5ff", + "pythonTarget": "x86_64-unknown-linux-gnu", + "pythonSha256": "72748da13197c1fb161e3afeef20a6a385ff24f2165e6e2758e47008e7faba4c", + "wheels": [ + { + "url": "https://files.pythonhosted.org/packages/b6/23/2a1b231b8ff672b4c450dac27164a8b2ca7d9b7144f9c02d2396518352eb/numpy-2.3.5-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", + "sha256": "0d8163f43acde9a73c2a33605353a4f1bc4798745a8b1d73183b28e5b435ae28" + }, + { + "url": "https://files.pythonhosted.org/packages/3d/fe/89d77e424365280b79d99b3e1e7d606f5165af2f2ecfaf0c6d24c799d607/pandas-3.0.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", + "sha256": "532527a701281b9dd371e2f582ed9094f4c12dd9ffb82c0c54ee28d8ac9520c4" + }, + { + "url": "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", + "sha256": "78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91" + }, + { + "url": "https://files.pythonhosted.org/packages/db/36/aa413bc214dc4f785ad2b2ddd8cc99aae7062d49ab155e91e6011af00daf/lxml-6.1.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", + "sha256": "379f8a75cf6eb7eef0af074b55f49ab73b868388a98de14646abcdfa4564bb11" + } + ] } }, "wheels": [ diff --git a/scripts/primary-runtime/prepare.spec.ts b/scripts/primary-runtime/prepare.spec.ts new file mode 100644 index 0000000000..fd050d9a62 --- /dev/null +++ b/scripts/primary-runtime/prepare.spec.ts @@ -0,0 +1,135 @@ +import { createHash } from 'node:crypto' +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { basename, join } from 'node:path' +import { zipSync } from 'fflate' +import { expect, it } from 'vitest' +import { downloadPrimaryRuntimeAsset, prepareOfficeSkillAssets, primaryRuntimePayloadDigest, smokePrimaryRuntime, unpackPrimaryRuntimeWheel } from './prepare.ts' +import lock from './lock.json' with { type: 'json' } + +const libraryWheel = Buffer.from('UEsDBAoAAAAAAASeLl0sYMPjDAAAAAwAAAAJAAAAc2FtcGxlLnB5c2FtcGxlID0gNDIKUEsBAh4DCgAAAAAABJ4uXSxgw+MMAAAADAAAAAkAAAAAAAAAAQAAAKSBAAAAAHNhbXBsZS5weVBLBQYAAAAAAQABADcAAAAzAAAAAAA=', 'base64') +const relocatedWheel = Buffer.from('UEsDBAoAAAAAAASeLl3x0Nj9FAAAABQAAAAeAAAAc2FtcGxlLTEuMC5kYXRhL3NjcmlwdHMvc2FtcGxlcmVxdWlyZXMgcmVsb2NhdGlvbgpQSwECHgMKAAAAAAAEni5d8dDY/RQAAAAUAAAAHgAAAAAAAAABAAAApIEAAAAAc2FtcGxlLTEuMC5kYXRhL3NjcmlwdHMvc2FtcGxlUEsFBgAAAAABAAEATAAAAFAAAAAAAA==', 'base64') +const externalLibraryWheel = Buffer.from('UEsDBBQAAAAAAAAAIVyBOE8OHAAAABwAAAAhAAAAc2FtcGxlLTEuMC5kYXRhL3B1cmVsaWIvc2FtcGxlLnB5cmVxdWlyZXMgbGlicmFyeSByZWxvY2F0aW9uClBLAQIUAxQAAAAAAAAAIVyBOE8OHAAAABwAAAAhAAAAAAAAAAAAAACAAQAAAABzYW1wbGUtMS4wLmRhdGEvcHVyZWxpYi9zYW1wbGUucHlQSwUGAAAAAAEAAQBPAAAAWwAAAAAA', 'base64') + +it.each(Object.entries(lock.targets))('records every locked wheel distribution and version for %s', (_target, artifact) => { + const normalize = (name: string): string => name.toLowerCase().replace(/[-_.]+/gu, '-') + const distributions = [...artifact.wheels, ...lock.wheels].map(({ url }) => { + const [name, version] = basename(new URL(url).pathname).split('-') + return [normalize(name!), version] as const + }) + const declared = Object.entries(lock.pythonPackages).map(([name, version]) => [normalize(name), version] as const) + expect(new Set(distributions.map(([name]) => name)).size).toBe(distributions.length) + expect(new Set(declared.map(([name]) => name)).size).toBe(declared.length) + expect(Object.fromEntries(distributions)).toEqual(Object.fromEntries(declared)) +}) + +it('keeps a target payload identity independent of other target archives', () => { + const changed = structuredClone(lock) + changed.targets['win-x64'].wheels[0]!.sha256 = 'a'.repeat(64) + expect(primaryRuntimePayloadDigest('mac-arm64', changed, '11.7.0')).toBe(primaryRuntimePayloadDigest('mac-arm64', lock, '11.7.0')) + expect(primaryRuntimePayloadDigest('win-x64', changed, '11.7.0')).not.toBe(primaryRuntimePayloadDigest('win-x64', lock, '11.7.0')) +}) + +it('invalidates payload identity for shared wheels, package versions and package-manager changes', () => { + const wheel = structuredClone(lock), distribution = structuredClone(lock) + wheel.wheels[0]!.sha256 = 'a'.repeat(64) + distribution.pythonPackages['python-docx'] = '1.2.1' + const original = primaryRuntimePayloadDigest('mac-arm64', lock, '11.7.0') + expect(primaryRuntimePayloadDigest('mac-arm64', wheel, '11.7.0')).not.toBe(original) + expect(primaryRuntimePayloadDigest('mac-arm64', distribution, '11.7.0')).not.toBe(original) + expect(primaryRuntimePayloadDigest('mac-arm64', lock, '11.7.1')).not.toBe(original) +}) + +it('reports missing distribution metadata before trying to execute a stale native payload', async () => { + const root = await mkdtemp(join(tmpdir(), 'desktop-stale-runtime-')) + try { + await writeFile(join(root, 'runtime.json'), JSON.stringify({ platform: process.platform, arch: process.arch })) + expect(() => { smokePrimaryRuntime(root) }).toThrow('missing Python distribution versions; prepare the payload') + } finally { await rm(root, { recursive: true, force: true }) } +}) + +it('extracts a hash-verified cached library without a Python installer or network request', async () => { + const root = await mkdtemp(join(tmpdir(), 'desktop-wheel-')) + try { + const hash = createHash('sha256').update(libraryWheel).digest('hex') + const archive = join(root, hash) + await writeFile(archive, libraryWheel) + expect(await downloadPrimaryRuntimeAsset('https://unused.invalid/library.whl', hash, root)).toBe(archive) + await unpackPrimaryRuntimeWheel(archive, join(root, 'site-packages')) + expect(await readFile(join(root, 'site-packages/sample.py'), 'utf8')).toBe('sample = 42\n') + await writeFile(archive, 'corrupt archive') + await expect(downloadPrimaryRuntimeAsset('https://unused.invalid/library.whl', hash, root)).rejects.toThrow('checksum mismatch') + } finally { + await rm(root, { recursive: true, force: true }) + } +}) + +it('fully extracts a large deflate-compressed wheel entry', async () => { + const root = await mkdtemp(join(tmpdir(), 'desktop-large-wheel-')) + try { + const expected = Buffer.alloc(1024 * 1024) + // Seeded xorshift bytes keep the compressed entry larger than the stream buffers. + let state = 26 + for (let index = 0; index < expected.length; index++) { + state ^= state << 13 + state ^= state >>> 17 + state ^= state << 5 + expected[index] = state & 255 + } + const archive = join(root, 'large.whl') + await writeFile(archive, zipSync({ 'large.bin': expected }, { level: 6 })) + await unpackPrimaryRuntimeWheel(archive, root) + expect(await readFile(join(root, 'large.bin'))).toEqual(expected) + } finally { + await rm(root, { recursive: true, force: true }) + } +}) + +it('retains auxiliary wheel scripts without generating command wrappers', async () => { + const root = await mkdtemp(join(tmpdir(), 'desktop-wheel-')) + try { + const archive = join(root, 'relocated.whl') + await writeFile(archive, relocatedWheel) + await unpackPrimaryRuntimeWheel(archive, join(root, 'site-packages')) + expect(await readFile(join(root, 'site-packages/sample-1.0.data/scripts/sample'), 'utf8')).toBe('requires relocation\n') + } finally { + await rm(root, { recursive: true, force: true }) + } +}) + +it('rejects library files requiring an unsupported installation scheme', async () => { + const root = await mkdtemp(join(tmpdir(), 'desktop-wheel-')) + try { + const archive = join(root, 'relocated.whl') + await writeFile(archive, externalLibraryWheel) + await expect(unpackPrimaryRuntimeWheel(archive, join(root, 'site-packages'))).rejects.toThrow('unsupported installation paths') + } finally { + await rm(root, { recursive: true, force: true }) + } +}) + +it('copies complete Office resources outside the application archive and removes obsolete assets', async () => { + const root = await mkdtemp(join(tmpdir(), 'desktop-office-assets-')) + try { + const source = join(root, 'package', 'assets') + const destination = join(root, 'Contents', 'Resources', 'runtime', 'office-skills') + await mkdir(join(source, 'scripts'), { recursive: true }) + await writeFile(join(source, 'scripts', 'check_office.py'), 'print("checker")\n') + for (const name of ['office-docx', 'office-pptx', 'office-xlsx']) { + await mkdir(join(source, name)) + await writeFile(join(source, name, 'SKILL.md'), `# ${name}\n`) + } + await prepareOfficeSkillAssets(source, destination) + await writeFile(join(destination, 'obsolete.py'), 'old helper') + await prepareOfficeSkillAssets(source, destination) + for (const name of ['office-docx', 'office-pptx', 'office-xlsx']) { + expect(await readFile(join(destination, name, 'SKILL.md'), 'utf8')).toBe(`# ${name}\n`) + } + expect(await readFile(join(destination, 'scripts', 'check_office.py'), 'utf8')).toBe('print("checker")\n') + await expect(readFile(join(destination, 'obsolete.py'))).rejects.toMatchObject({ code: 'ENOENT' }) + } finally { await rm(root, { recursive: true, force: true }) } +}) + +it('gives Python-only payloads a distinct identity', () => { + expect(primaryRuntimePayloadDigest('linux-x64', lock, undefined)).not.toBe(primaryRuntimePayloadDigest('linux-x64', lock, '11.7.0')) +}) diff --git a/scripts/primary-runtime/prepare.ts b/scripts/primary-runtime/prepare.ts new file mode 100644 index 0000000000..b84769dfd6 --- /dev/null +++ b/scripts/primary-runtime/prepare.ts @@ -0,0 +1,210 @@ +/** Prepare pinned, relocatable script interpreters without installing into the build host. */ + +import { execFileSync } from 'node:child_process' +import { createHash } from 'node:crypto' +import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { cp } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { parseArgs } from 'node:util' +import extractZip from 'extract-zip' +import { x as extractTar } from 'tar' +import { workspaceDependencyPaths, type PrimaryRuntimeManifest } from '../../packages/skill/tool-workspace-dependencies/src/index.ts' +import lock from './lock.json' with { type: 'json' } + +/** + * Download or reuse an archive only when its bytes match the release lock. + * @param url - Locked archive URL. + * @param sha256 - Expected SHA-256 digest. + * @param cache - Download cache directory. + * @returns Verified local archive path. + */ +export async function downloadPrimaryRuntimeAsset(url: string, sha256: string, cache: string): Promise { + const destination = join(cache, sha256) + let bytes: Buffer + try { bytes = readFileSync(destination) } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + const response = await fetch(url) + if (!response.ok) throw new Error(`primary runtime download: ${String(response.status)} ${url}`) + bytes = Buffer.from(await response.arrayBuffer()) + } + if (createHash('sha256').update(bytes).digest('hex') !== sha256) throw new Error(`primary runtime download: checksum mismatch for ${url}`) + writeFileSync(destination, bytes) + return destination +} + +async function pythonArchive(target: keyof typeof lock.targets, cache: string): Promise { + const artifact = lock.targets[target] + const filename = `cpython-${lock.pythonVersion}+${lock.pythonRelease}-${artifact.pythonTarget}-install_only_stripped.tar.gz` + return downloadPrimaryRuntimeAsset(`https://github.com/astral-sh/python-build-standalone/releases/download/${lock.pythonRelease}/${encodeURIComponent(filename)}`, artifact.pythonSha256, cache) +} + +/** + * Identify the inputs that assemble one target's payload, excluding unrelated target locks. + * @param target - Runtime target whose archives are installed. + * @param runtimeLock - Locked interpreter and wheel inputs. + * @param pnpmVersion - Package-manager version copied into the payload. + * @returns SHA-256 payload identity for installation reuse. + */ +export function primaryRuntimePayloadDigest( + target: keyof typeof lock.targets, runtimeLock: typeof lock, pnpmVersion: string | undefined, +): string { + const { pythonVersion, pythonRelease, nodeVersion, wheels, pythonPackages } = runtimeLock + // Identity preserves key order within the selected target, wheel records and distribution map, plus wheel-entry order. + // Bump format when extraction or assembly changes payload bytes without changing locked inputs. + return createHash('sha256').update(JSON.stringify({ + format: 3, target, pythonVersion, pythonRelease, nodeVersion: pnpmVersion === undefined ? undefined : nodeVersion, + artifact: runtimeLock.targets[target], wheels, pythonPackages, pnpm: pnpmVersion, + })).digest('hex') +} + +/** + * Unpack a locked library wheel, retaining auxiliary scripts in its distribution data directory. + * @param archive - Hash-verified wheel archive. + * @param destination - Absolute site-packages directory. + * @returns Resolves after extraction without command wrappers; rejects other wheel installation schemes. + */ +export async function unpackPrimaryRuntimeWheel(archive: string, destination: string): Promise { + await extractZip(archive, { + dir: destination, + onEntry: (entry) => { + const [directory, scheme] = entry.fileName.split('/') + if (directory?.endsWith('.data') && scheme !== '' && scheme !== 'scripts') { + throw new Error(`primary runtime: wheel requires unsupported installation paths: ${entry.fileName}`) + } + }, + }) +} + +/** + * Copy the skill package's complete asset tree to ordinary filesystem resources. + * @param source - The package's assets directory. + * @param destination - External Office skill resource directory. + * @returns Resolves after replacing the external assets with the complete package tree. + */ +export async function prepareOfficeSkillAssets(source: string, destination: string): Promise { + rmSync(destination, { recursive: true, force: true }) + await cp(source, destination, { recursive: true, dereference: true }) +} + +/** A locked interpreter and wheel target. */ +export type PrimaryRuntimeTarget = keyof typeof lock.targets + +/** Build-only inputs shared by Desktop and SDK carriers. */ +export interface PreparePrimaryRuntimeOptions { + /** Target whose archives and wheels are downloaded. */ + readonly target: PrimaryRuntimeTarget + /** Resource directory receiving primary-runtime/ and office-skills/. */ + readonly output: string + /** SHA-256-addressed archive cache. */ + readonly cache: string + /** Carrier release recorded in the legacy desktopVersion manifest field. */ + readonly version: string + /** Omit Node.js and pnpm for carriers providing only Python. */ + readonly pythonOnly?: boolean +} + +/** + * Materialize locked interpreters, libraries and Office resources without executing target code. + * @param options - Explicit target and carrier-owned output locations. + * @returns Resolves after the complete payload and skills have been copied to the output directory. + */ +export async function preparePrimaryRuntime(options: PreparePrimaryRuntimeOptions): Promise { + const { target } = options + const paths = { runtime: resolve(options.output), downloads: resolve(options.cache) } + const artifact = lock.targets[target] + mkdirSync(paths.runtime, { recursive: true }) + mkdirSync(paths.downloads, { recursive: true }) + const staging = mkdtempSync(join(tmpdir(), 'dsh-primary-')) + try { + const output = join(staging, 'payload') + const dependencies = join(output, 'dependencies') + mkdirSync(dependencies, { recursive: true }) + let pnpmVersion: string | undefined + if (!options.pythonOnly) { + const nodeFilename = `node-v${lock.nodeVersion}-${artifact.nodeArchive}` + const nodeArchive = await downloadPrimaryRuntimeAsset(`https://nodejs.org/dist/v${lock.nodeVersion}/${nodeFilename}`, artifact.nodeSha256, paths.downloads) + const unpackedNode = join(staging, 'node') + mkdirSync(unpackedNode) + if (target === 'win-x64') await extractZip(nodeArchive, { dir: unpackedNode }) + else await extractTar({ file: nodeArchive, cwd: unpackedNode }) + const nodeSource = join(unpackedNode, nodeFilename.replace(/\.(?:zip|tar\.gz)$/u, '')) + mkdirSync(join(dependencies, 'node', 'bin'), { recursive: true }) + mkdirSync(join(dependencies, 'node', 'node_modules')) + writeFileSync(join(dependencies, 'node', 'node_modules', 'README.txt'), 'Reserved for bundled Node packages. pnpm uses its default installation directories.\n') + cpSync(join(nodeSource, ...(target === 'win-x64' ? ['node.exe'] : ['bin', 'node'])), + join(dependencies, 'node', 'bin', target === 'win-x64' ? 'node.exe' : 'node')) + cpSync(join(nodeSource, 'LICENSE'), join(dependencies, 'node', 'LICENSE')) + const require = createRequire(import.meta.url) + const pnpmManifest = require.resolve('pnpm') + pnpmVersion = (JSON.parse(readFileSync(pnpmManifest, 'utf8')) as { version: string }).version + await cp(dirname(pnpmManifest), join(dependencies, 'pnpm'), { recursive: true, dereference: true }) + } + await extractTar({ file: await pythonArchive(target, paths.downloads), cwd: dependencies }) + const manifest: PrimaryRuntimeManifest = { + desktopVersion: options.version, + platform: target === 'win-x64' ? 'win32' : target === 'linux-x64' ? 'linux' : 'darwin', + arch: target === 'mac-arm64' ? 'arm64' : 'x64', + payloadDigest: primaryRuntimePayloadDigest(target, lock, pnpmVersion), + pythonPackages: lock.pythonPackages, + components: { + python: lock.pythonVersion, + ...(pnpmVersion === undefined ? {} : { node: lock.nodeVersion, pnpm: pnpmVersion }), + numpy: lock.pythonPackages.numpy, pandas: lock.pythonPackages.pandas, + }, + } + const entries = workspaceDependencyPaths(output, manifest) + for (const wheel of [...artifact.wheels, ...lock.wheels]) { + await unpackPrimaryRuntimeWheel(await downloadPrimaryRuntimeAsset(wheel.url, wheel.sha256, paths.downloads), entries.pythonPackages) + } + writeFileSync(join(output, 'runtime.json'), `${JSON.stringify(manifest, undefined, 2)}\n`) + const destination = join(paths.runtime, 'primary-runtime') + rmSync(destination, { recursive: true, force: true }) + await cp(output, destination, { recursive: true, dereference: true }) + } finally { + rmSync(staging, { recursive: true, force: true }) + } + const require = createRequire(import.meta.url) + await prepareOfficeSkillAssets(join(dirname(require.resolve('@deepseek-ai/dsh-skill-office/package.json')), 'assets'), + join(paths.runtime, 'office-skills')) +} + +/** + * Execute the native payload's interpreters, package manager and Python libraries. + * @param root - Final payload directory, including any platform signatures. + * @param environment - Scrubbed subprocess environment; defaults to excluding credential-shaped names. + */ +export function smokePrimaryRuntime(root: string, environment: NodeJS.ProcessEnv = Object.fromEntries( + Object.entries(process.env).filter(([name]) => !/(?:KEY|SECRET|TOKEN|PASSWORD)/iu.test(name)), +)): void { + const manifest = JSON.parse(readFileSync(join(root, 'runtime.json'), 'utf8')) as PrimaryRuntimeManifest + if (manifest.platform !== process.platform || manifest.arch !== process.arch) return + if (manifest.pythonPackages === undefined) throw new Error('primary runtime: missing Python distribution versions; prepare the payload before running its smoke checks.') + const entries = workspaceDependencyPaths(root, manifest) + const options = { stdio: 'inherit', timeout: 120_000, env: environment } as const + execFileSync(entries.python, ['-I', '-B', '-c', 'import decimal, xml.parsers.expat, lzma, uuid, numpy, pandas; assert numpy.arange(4).sum() == 6; assert pandas.DataFrame({"n": [1, 2]}).n.sum() == 3'], options) + execFileSync(entries.python, ['-I', '-B', join(import.meta.dirname, 'smoke.py'), JSON.stringify(manifest.pythonPackages), + manifest.components.python, join(dirname(root), 'office-skills', 'scripts', 'check_office.py')], options) + execFileSync(entries.python, ['-I', '-B', '-m', 'pip', 'check'], options) + if (entries.node !== undefined) execFileSync(entries.node, ['-e', `if (process.versions.node !== ${JSON.stringify(manifest.components.node)}) process.exit(1)`], options) + if (entries.pnpm !== undefined && entries.node !== undefined) execFileSync(entries.node, [entries.pnpm, '--version'], options) +} + +if (import.meta.main) { + const { values } = parseArgs({ options: { + target: { type: 'string' }, output: { type: 'string' }, cache: { type: 'string' }, + 'python-only': { type: 'boolean', default: false }, + } }) + if (!values.target || !Object.hasOwn(lock.targets, values.target) || !values.output) { + throw new Error(`Usage: pnpm run prepare:primary-runtime --target <${Object.keys(lock.targets).join('|')}> --output [--cache ] [--python-only]`) + } + const { version } = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')) as { version: string } + const output = resolve(values.output) + await preparePrimaryRuntime({ + target: values.target as PrimaryRuntimeTarget, output, + cache: values.cache ?? join(tmpdir(), 'dsh-primary-runtime-downloads'), version, + pythonOnly: values['python-only'], + }) + smokePrimaryRuntime(join(output, 'primary-runtime')) +} diff --git a/apps/desktop/scripts/smoke-primary-runtime.py b/scripts/primary-runtime/smoke.py similarity index 100% rename from apps/desktop/scripts/smoke-primary-runtime.py rename to scripts/primary-runtime/smoke.py From be8040d042c75848f24c379cb8fa705084a4bf60 Mon Sep 17 00:00:00 2001 From: fz Date: Thu, 17 Sep 2026 21:15:55 +0800 Subject: [PATCH 03/12] test(sdk): align Office checks with runtime profile resolution --- apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts b/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts index 5e95ce02f0..a27258fc4c 100644 --- a/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts +++ b/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts @@ -8,7 +8,6 @@ import { zstdDecompress } from 'node:zlib' import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' import { execa } from 'execa' import { describe, expect, it, onTestFinished } from 'vitest' -import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' import { workspaceDependencyPaths, type PrimaryRuntimeManifest } from '@deepseek-ai/dsh-tool-workspace-dependencies' const repoRoot = fileURLToPath(new URL('../../../../../', import.meta.url)) @@ -456,14 +455,14 @@ it.each(['unset', 'full', 'python-only', 'missing-assets'] as const)('composes O const address = server.address() if (address === null || typeof address === 'string') throw new Error('model fixture did not bind') const home = join(root, 'home') - const launch = resolveExampleLaunch({ - srcBin: binScript, tsconfigPath: join(repoRoot, 'tsconfig.json'), sourceImport: 'tsx/esm', + const officeLaunch = resolveExampleLaunch({ + srcBin: fileURLToPath(new URL('../../../src/bin.ts', import.meta.url)), mode: 'lib', configArgs: ['--profile', 'sdk'], env: { DSH_HOME: home, DSH_PRIMARY_RUNTIME: mode === 'unset' ? undefined : source + '/', DSH_PERMISSION_MODE: 'danger-full-access', DSH_TELEMETRY_DISABLED: '1', DEEPSEEK_API_KEY: 'local-fixture', DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}` }, }) - const child = execa(launch.command, launch.args, { cwd: repoRoot, env: launch.env, timeout: 60_000, reject: false }) + const child = execa(officeLaunch.command, officeLaunch.args, { cwd: repoRoot, env: officeLaunch.env, timeout: 60_000, reject: false }) onTestFinished(async () => { child.kill('SIGKILL'); await child }) let buffer = '', stderr = '' const lines: string[] = [] From 4d34cd87dd13fca2b4cc7f7c77c0e1e83bf4c8a2 Mon Sep 17 00:00:00 2001 From: fz Date: Fri, 18 Sep 2026 11:52:04 +0800 Subject: [PATCH 04/12] fix(skill): address shared runtime review findings --- ...2026-09-17-shared-office-runtime.i18n.yaml | 4 +-- .../2026-09-17-shared-office-runtime.md | 2 ++ .../2026-09-17-shared-office-runtime.zh.md | 2 ++ ...26-09-14-desktop-primary-runtime.i18n.yaml | 4 +-- .../2026-09-14-desktop-primary-runtime.md | 4 ++- .../2026-09-14-desktop-primary-runtime.zh.md | 4 ++- ...30-generated-third-party-notices.i18n.yaml | 4 +-- ...026-07-30-generated-third-party-notices.md | 6 ++-- ...-07-30-generated-third-party-notices.zh.md | 6 ++-- THIRD_PARTY_NOTICES.md | 6 ++-- apps/desktop/README.i18n.yaml | 4 +-- apps/desktop/README.md | 2 +- apps/desktop/README.zh.md | 2 +- apps/desktop/package.json | 2 -- docs/config-catalog.i18n.yaml | 4 +-- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- docs/tool-catalog.i18n.yaml | 4 +-- docs/tool-catalog.md | 2 +- docs/tool-catalog.zh.md | 2 +- package.json | 1 - .../README.i18n.yaml | 4 +-- .../tool-workspace-dependencies/README.md | 2 +- .../tool-workspace-dependencies/README.zh.md | 2 +- .../tool-workspace-dependencies/package.json | 5 +++- .../tool-workspace-dependencies/src/index.ts | 15 +++++++--- .../tests/tool-workspace-dependencies.spec.ts | 15 ++++++++++ .../tool-workspace-dependencies/tsconfig.json | 3 ++ pnpm-lock.yaml | 10 +++---- scripts/gen-third-party-notices.spec.ts | 30 +++++++++---------- scripts/gen-third-party-notices.ts | 30 +++++++++---------- scripts/primary-runtime/prepare.spec.ts | 13 ++++++++ .../tool-schemas.expected.json | 2 +- 33 files changed, 122 insertions(+), 78 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-09-17-shared-office-runtime.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-17-shared-office-runtime.i18n.yaml index 303cd65bd0..559bae0972 100644 --- a/.agents/notes/implemented/architecture/2026-09-17-shared-office-runtime.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-17-shared-office-runtime.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 .agents/notes/implemented/architecture/2026-09-17-shared-office-runtime.md -2026-09-17-shared-office-runtime.md: 28daa6d3809705098722c41847fab9e3ee255bfe -2026-09-17-shared-office-runtime.zh.md: 09cc54e254546aa2787c37ab695c922ee86f0d66 +2026-09-17-shared-office-runtime.md: 5925974ce96a91afcb97f89af58f87a3b2d8ff93 +2026-09-17-shared-office-runtime.zh.md: 61be67508f285cb3e06305c95eed617e0d5f4218 diff --git a/.agents/notes/implemented/architecture/2026-09-17-shared-office-runtime.md b/.agents/notes/implemented/architecture/2026-09-17-shared-office-runtime.md index 28daa6d380..5925974ce9 100644 --- a/.agents/notes/implemented/architecture/2026-09-17-shared-office-runtime.md +++ b/.agents/notes/implemented/architecture/2026-09-17-shared-office-runtime.md @@ -14,6 +14,8 @@ SDK deployments need the same Office authoring libraries as Desktop while keepin The [shared build entry](../../../../scripts/primary-runtime/prepare.ts) owns the download lock, extraction and native smoke checks for Desktop targets and GNU/Linux x64. Desktop supplies output paths and its release version, requires Node.js and pnpm, and applies its signing-specific subprocess environment. Other carriers choose their output directory and may build a Python-only payload. The existing `desktopVersion` manifest field retains its name and records the carrier release; `payloadDigest` distinguishes locked inputs and component selection. +The [Desktop primary-runtime decision](../feature/2026-09-14-desktop-primary-runtime.md) continues to own Desktop installation and platform signing. This decision replaces only its Desktop-only builder and query placement; both records remain active. + ## Alternatives considered **Keep implementation in Desktop.** This leaves SDK and container builders depending on application packaging and signing code despite needing only interpreters, Office resources and a query. diff --git a/.agents/notes/implemented/architecture/2026-09-17-shared-office-runtime.zh.md b/.agents/notes/implemented/architecture/2026-09-17-shared-office-runtime.zh.md index 09cc54e254..61be67508f 100644 --- a/.agents/notes/implemented/architecture/2026-09-17-shared-office-runtime.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-17-shared-office-runtime.zh.md @@ -14,6 +14,8 @@ SDK 部署需要与 Desktop 相同的 Office 创作库,同时将解释器 payl [共享构建入口](../../../../scripts/primary-runtime/prepare.ts) 持有 Desktop 目标与 GNU/Linux x64 的下载锁、解压和本机冒烟检查。Desktop 提供输出路径和发布版本,要求包含 Node.js 与 pnpm,并应用签名专用子进程环境。其他载体选择自己的输出目录,也可构建 Python-only payload。现有 `desktopVersion` 清单字段保留名字并记录载体发布版本;`payloadDigest` 区分锁定输入与组件选择。 +[Desktop 第一方运行时决策](../feature/2026-09-14-desktop-primary-runtime.zh.md)继续持有 Desktop 安装与平台签名规则。本决策仅替代其中由 Desktop 独占构建器和查询实现的部分;两份记录均保持有效。 + ## 考虑过的替代方案 **将实现保留在 Desktop。** SDK 和容器构建器只需要解释器、Office 资源和查询,却仍会依赖应用打包及签名代码。 diff --git a/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.i18n.yaml b/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.i18n.yaml index 50233385bf..8dacaafa96 100644 --- a/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.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 .agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.md -2026-09-14-desktop-primary-runtime.md: 51bbaac89c5c2ec9a3a016030e7b320d2b5566b8 -2026-09-14-desktop-primary-runtime.zh.md: 4f27d61b5e713f66e2f478894dbd547cd7ecfff4 +2026-09-14-desktop-primary-runtime.md: de535fd7125c08bf788ddf7680224c6e0df81a4a +2026-09-14-desktop-primary-runtime.zh.md: 508c574e4970b4cc53ef3bdaee8524a867673d34 diff --git a/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.md b/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.md index 51bbaac89c..de535fd712 100644 --- a/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.md +++ b/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.md @@ -14,7 +14,7 @@ Desktop ships Python, Node.js, pnpm, data-processing libraries and Office author The application version, component versions, Python distribution versions and locked-input digest live in `runtime.json`, not the directory name. The digest covers the selected target's interpreter and wheel archives, shared wheel inputs, Python distribution versions, pnpm version and assembly format; other targets do not invalidate it. Key order within the selected target, wheel records and distribution map, plus wheel-entry order, participates in this identity; top-level lock key order does not. Extraction or assembly changes that alter payload bytes without changing locked inputs require an explicit format bump. Installation publishes a completed staged copy and retains the previous directory until replacement succeeds. Matching payloads reuse installed files; replacements also replace user-added Python dependencies inside the managed tree. Older manifests without a digest remain readable and differ from the current payload. Distribution names use PEP 503 normalization; duplicate normalized names are rejected, and present numpy/pandas distribution versions must agree with their component versions. The Desktop single-instance owner and the tool's shared installation promise serialize normal installation requests. -Node downloads and hash-verifies the complete locked wheel set and unpacks library files into site-packages. This avoids build-host Python and pip version selection without implementing dependency resolution or general wheel installation. Auxiliary scripts, including XlsxWriter's VBA extraction script, remain under the wheel's `.data/scripts` directory; command-line entry-point wrappers are outside this library payload. Other `.data` installation schemes are rejected. Native smoke executes the final payload after temporary files are removed, checking the exact locked distribution set plus bundled pip, Python and pinned wheel versions, dependency completeness and editable Office document round trips, so interpreter links must survive relocation. Smoke checks disable bytecode writes to keep validation artifacts out of the shipped payload. +The shared Node build entry downloads and hash-verifies the complete locked wheel set and unpacks library files into site-packages. This avoids build-host Python and pip version selection without implementing dependency resolution or general wheel installation. Auxiliary scripts, including XlsxWriter's VBA extraction script, remain under the wheel's `.data/scripts` directory; command-line entry-point wrappers are outside this library payload. Other `.data` installation schemes are rejected. Native smoke executes the final payload after temporary files are removed, checking the exact locked distribution set plus bundled pip, Python and pinned wheel versions, dependency completeness and editable Office document round trips, so interpreter links must survive relocation. Smoke checks disable bytecode writes to keep validation artifacts out of the shipped payload. macOS grants `com.apple.security.cs.allow-jit` only to the standalone Node executable. Hardened-runtime signing without that entitlement prevents V8 from allocating its code region. Interpreter and library smoke checks run after signing as well as after staging cleanup; a valid signature alone does not establish executable behavior. @@ -22,6 +22,8 @@ Windows signed packaging separates materialization from execution with supervise Desktop ZIP extraction pins `extract-zip` to `yauzl` 3.4.0 through a scoped dependency override. The 2.x reader can leave large deflate entries unfinished on Node 26 ([upstream issue](https://github.com/thejoshwolfe/yauzl/issues/176)); retaining the existing extractor preserves its path validation and wheel-entry checks. The development launcher uses top-level await so unfinished preparation cannot exit successfully. A large compressed wheel regression checks the complete extracted bytes. +The [shared-runtime decision](../architecture/2026-09-17-shared-office-runtime.md) owns builder, lock and query-package placement for Desktop and SDK carriers, including GNU/Linux x64 and Python-only payloads. Desktop retains the installation and signing behavior described here and requires the full Python, Node.js and pnpm payload. + ## Alternatives considered **System interpreters only.** They do not provide predictable availability or preinstalled numpy and pandas. diff --git a/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.zh.md b/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.zh.md index 4f27d61b5e..508c574e49 100644 --- a/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.zh.md +++ b/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.zh.md @@ -14,7 +14,7 @@ Desktop 将 Python、Node.js、pnpm、数据处理库和 Office 创作库作为 应用版本、组件版本、Python 分发包版本和锁定输入摘要记录在 `runtime.json` 中,不放在目录名里。摘要涵盖所选目标的解释器与 wheel 压缩包、共享 wheel 输入、Python 分发包版本、pnpm 版本和组装格式;其他目标不会使其失效。所选目标、wheel 记录及分发包映射内部的键顺序,以及 wheel 条目顺序参与该身份计算;锁文件顶层键的顺序不参与。解压或组装逻辑在锁定输入不变时改变产物字节,必须显式提升格式版本。安装发布完整的暂存副本,并在替换成功前保留之前的目录。相同产物复用已安装文件;替换也会移除受管目录内用户添加的 Python 依赖。不含摘要的旧清单仍可读取,并与当前产物区分。分发包名称按 PEP 503 归一化,归一化后重复的名称会被拒绝;清单包含 numpy/pandas 分发包版本时,必须与相应组件版本一致。Desktop 单实例所有者和工具共享的安装 Promise 串行处理正常安装请求。 -Node 下载并校验完整锁定 wheel 集的哈希,将库文件解压到 site-packages。这避免选择构建主机的 Python 和 pip 版本,也无需实现依赖解析或通用 wheel 安装。XlsxWriter 的 VBA 提取脚本等辅助脚本保留在 wheel 的 `.data/scripts` 目录中;命令行入口包装器不属于该库产物。其他 `.data` 安装方案会被拒绝。本机 smoke 在临时文件删除后执行最终产物,检查精确锁定的分发包集合与内置 pip、Python 与固定 wheel 版本、依赖完整性及可编辑 Office 文档的写入和读取,因此解释器链接必须在迁移后仍有效。Smoke 检查禁用字节码写入,避免把验证产物纳入分发内容。 +共享 Node 构建入口下载并校验完整锁定 wheel 集的哈希,将库文件解压到 site-packages。这避免选择构建主机的 Python 和 pip 版本,也无需实现依赖解析或通用 wheel 安装。XlsxWriter 的 VBA 提取脚本等辅助脚本保留在 wheel 的 `.data/scripts` 目录中;命令行入口包装器不属于该库产物。其他 `.data` 安装方案会被拒绝。本机 smoke 在临时文件删除后执行最终产物,检查精确锁定的分发包集合与内置 pip、Python 与固定 wheel 版本、依赖完整性及可编辑 Office 文档的写入和读取,因此解释器链接必须在迁移后仍有效。Smoke 检查禁用字节码写入,避免把验证产物纳入分发内容。 macOS 仅向独立 Node 可执行文件授予 `com.apple.security.cs.allow-jit`。缺少此权限的强化运行时签名会阻止 V8 分配代码区域。解释器和库的 smoke 检查在签名后以及暂存清理后执行;签名有效本身不能证明程序可运行。 @@ -22,6 +22,8 @@ Windows 签名打包通过受监督的签名阶段覆盖第一方运行时和应 Desktop ZIP 解压通过定向依赖覆盖为 `extract-zip` 固定 `yauzl` 3.4.0。2.x 读取器在 Node 26 上可能无法完成较大 deflate 条目的读取([上游问题](https://github.com/thejoshwolfe/yauzl/issues/176));保留现有解压器可保留其路径校验和 wheel 条目检查。开发启动器使用顶层 await,避免准备未完成却成功退出。大压缩 wheel 回归测试检查完整的解压字节。 +[共享运行时决策](../architecture/2026-09-17-shared-office-runtime.zh.md)持有 Desktop 与 SDK 载体的构建器、锁文件和查询包归属,覆盖 GNU/Linux x64 与 Python-only payload。Desktop 保留本文的安装与签名行为,并要求完整的 Python、Node.js 和 pnpm payload。 + ## Alternatives considered **只使用系统解释器。** 无法保证可用性或预装 numpy 和 pandas。 diff --git a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml index 888d5b333c..c92bdd5546 100644 --- a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.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 .agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md -2026-07-30-generated-third-party-notices.md: c4458fac7de4d9e9b63b23855f95aaf69eb89f64 -2026-07-30-generated-third-party-notices.zh.md: f26d0d001ed06dd5bea00bbc5e3400b31b254081 +2026-07-30-generated-third-party-notices.md: 71f1fd0a5b650d53c5ef21438e17e0a6e162bdf5 +2026-07-30-generated-third-party-notices.zh.md: e3adb43e4b407945a8cca88105de689407b5908b diff --git a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md index c4458fac7d..71f1fd0a5b 100644 --- a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md +++ b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md @@ -12,13 +12,13 @@ A hand-written inventory answers none of those durably. Roughly a hundred rows o ## Decision -[`THIRD_PARTY_NOTICES.md`](../../../../THIRD_PARTY_NOTICES.md) is generated by [`scripts/gen-third-party-notices.ts`](../../../../scripts/gen-third-party-notices.ts) from the workspace manifests, `vendor/README.md`, the `pyproject.toml` files, `pnpm-workspace.yaml`, and the [Desktop runtime lock](../../../../scripts/primary-runtime/lock.json). The root README pair links the file from its License section. +[`THIRD_PARTY_NOTICES.md`](../../../../THIRD_PARTY_NOTICES.md) is generated by [`scripts/gen-third-party-notices.ts`](../../../../scripts/gen-third-party-notices.ts) from the workspace manifests, `vendor/README.md`, the `pyproject.toml` files, `pnpm-workspace.yaml`, and the [shared runtime lock](../../../../scripts/primary-runtime/lock.json). The root README pair links the file from its License section. -**Freshness is maintained, not merely enforced.** A pre-commit job regenerates the file and stages it whenever a generator input is staged — any manifest, a workspace declaration, the root or Desktop runtime lock file, `vendor/README.md`, a `pyproject.toml`, the generator itself, or the script holding the build-time pin — so an unrelated dependency edit never has to come back and rerun a generator. The committed bytes are then asserted inside [`scripts/gen-third-party-notices.spec.ts`](../../../../scripts/gen-third-party-notices.spec.ts), which the test lane already runs — the check adds no gate process, no scheduler slot, and no separate CI step. `pnpm run verify-third-party-notices` remains available for a standalone check. +**Freshness is maintained, not merely enforced.** A pre-commit job regenerates the file and stages it whenever a generator input is staged — any manifest, a workspace declaration, the root or shared runtime lock file, `vendor/README.md`, a `pyproject.toml`, the generator itself, or the script holding the build-time pin — so an unrelated dependency edit never has to come back and rerun a generator. The committed bytes are then asserted inside [`scripts/gen-third-party-notices.spec.ts`](../../../../scripts/gen-third-party-notices.spec.ts), which the test lane already runs — the check adds no gate process, no scheduler slot, and no separate CI step. `pnpm run verify-third-party-notices` remains available for a standalone check. One trigger gap is accepted rather than worked around: lefthook inspects only files present on disk, so **deleting** a manifest runs no job, and removing a package reaches the assertion in the test lane instead. Reconstructing the staged file list to include deletions does not work — lefthook filters the list against the working tree either way. The assertion is the backstop for exactly this case. -The file discloses **direct** dependencies by default; complete npm and Python SDK closures remain in their lock files. Two separately distributed payload sets receive explicit entries: the official Claude executable packages declared by `@anthropic-ai/claude-agent-sdk`, and every distribution in the Desktop runtime lock's `pythonPackages`. Desktop entries include normalized names, exact versions, and recorded license metadata; missing metadata, duplicate normalized names and conflicting normalized versions fail generation. The Desktop wheel set is independent of the `python/` manifests, so scanning only those manifests would omit the packaged Office libraries. Desktop distributions also pass the runtime license check, including the permissive `MIT-CMU` and `PSF-2.0` identifiers declared by Pillow and typing-extensions. +The file discloses **direct** dependencies by default; complete npm and Python SDK closures remain in their lock files. Two separately distributed payload sets receive explicit entries: the official Claude executable packages declared by `@anthropic-ai/claude-agent-sdk`, and every distribution in the shared runtime lock's `pythonPackages`. Bundled entries include normalized names, exact versions, and recorded license metadata; missing metadata, duplicate normalized names and conflicting normalized versions fail generation. The shared wheel set is independent of the `python/` manifests, so scanning only those manifests would omit the packaged Office libraries. Bundled distributions also pass the runtime license check, including the permissive `MIT-CMU` and `PSF-2.0` identifiers declared by Pillow and typing-extensions. **Tiering follows distribution, not manifest section.** Installed runtime libraries are identified by `dependencies` or `optionalDependencies` outside `DEV_ONLY_AREAS` — the root manifest, `packages/test-support/`, `packages/test-support/client-runtime/`, `website/`, `native/`. Browser inputs resolved by the shipping tsdown and Vite configurations also count as runtime, even in `devDependencies`; [browser third-party build inputs](2026-09-08-browser-third-party-build-inputs.md) owns that classification. Test-support dependencies do not ship merely because their manifest says `dependencies`, and the generator explicitly discloses `tsx` because source launches execute through its ESM hook. diff --git a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md index f26d0d001e..e3adb43e4b 100644 --- a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md +++ b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md @@ -12,13 +12,13 @@ Status: implemented ## 决策 -[`THIRD_PARTY_NOTICES.md`](../../../../THIRD_PARTY_NOTICES.md) 由 [`scripts/gen-third-party-notices.ts`](../../../../scripts/gen-third-party-notices.ts) 依据各工作区 manifest、`vendor/README.md`、`pyproject.toml`、`pnpm-workspace.yaml` 与 [Desktop 运行时锁文件](../../../../scripts/primary-runtime/lock.json)生成。根 README 双语两侧都从「许可证」一节链到该文件。 +[`THIRD_PARTY_NOTICES.md`](../../../../THIRD_PARTY_NOTICES.md) 由 [`scripts/gen-third-party-notices.ts`](../../../../scripts/gen-third-party-notices.ts) 依据各工作区 manifest、`vendor/README.md`、`pyproject.toml`、`pnpm-workspace.yaml` 与 [共享运行时锁文件](../../../../scripts/primary-runtime/lock.json)生成。根 README 双语两侧都从「许可证」一节链到该文件。 -**新鲜度会得到维护,而非仅靠校验。** 只要暂存了生成器的任一输入——任何 manifest、工作区声明、根锁文件或 Desktop 运行时锁文件、`vendor/README.md`、某个 `pyproject.toml`、生成器自身,或持有构建期 pin 的脚本——pre-commit 任务就会重新生成该文件并将其暂存,改依赖的人不必事后再折返跑一次生成器。已提交的字节随后由 [`scripts/gen-third-party-notices.spec.ts`](../../../../scripts/gen-third-party-notices.spec.ts) 断言,而测试 lane 本就会跑这个文件——这项校验不增加门禁进程、不占调度位、也不新增 CI 步骤。需要单独校验时,`pnpm run verify-third-party-notices` 仍然可用。 +**新鲜度会得到维护,而非仅靠校验。** 只要暂存了生成器的任一输入——任何 manifest、工作区声明、根锁文件或共享运行时锁文件、`vendor/README.md`、某个 `pyproject.toml`、生成器自身,或持有构建期 pin 的脚本——pre-commit 任务就会重新生成该文件并将其暂存,改依赖的人不必事后再折返跑一次生成器。已提交的字节随后由 [`scripts/gen-third-party-notices.spec.ts`](../../../../scripts/gen-third-party-notices.spec.ts) 断言,而测试 lane 本就会跑这个文件——这项校验不增加门禁进程、不占调度位、也不新增 CI 步骤。需要单独校验时,`pnpm run verify-third-party-notices` 仍然可用。 有一处触发缺口是接受而非绕过的:lefthook 只检视磁盘上存在的文件,因此**删除** manifest 不会触发任何任务,移除一个包会落到测试 lane 的断言上。重构暂存文件列表以纳入删除的做法不成立——无论怎么给列表,lefthook 都会拿工作树过滤一遍。这个场景正由断言兜底。 -文件默认只披露**直接**依赖;完整的 npm 与 Python SDK 闭包保留在各自锁文件中。两组独立分发的载荷会明确列出:`@anthropic-ai/claude-agent-sdk` 声明的官方 Claude 可执行包,以及 Desktop 运行时锁文件 `pythonPackages` 中的每个分发包。Desktop 条目包含归一化名称、精确版本和已记录的许可证元数据;元数据缺失、归一化后名称重复或版本冲突都会使生成失败。Desktop wheel 集合独立于 `python/` 下的 manifest,仅扫描后者会遗漏已打包的 Office 库。Desktop 分发包同样通过运行时许可证检查,其中包括 Pillow 与 typing-extensions 声明的宽松许可证标识 `MIT-CMU` 和 `PSF-2.0`。 +文件默认只披露**直接**依赖;完整的 npm 与 Python SDK 闭包保留在各自锁文件中。两组独立分发的载荷会明确列出:`@anthropic-ai/claude-agent-sdk` 声明的官方 Claude 可执行包,以及共享运行时锁文件 `pythonPackages` 中的每个分发包。内置条目包含归一化名称、精确版本和已记录的许可证元数据;元数据缺失、归一化后名称重复或版本冲突都会使生成失败。共享 wheel 集合独立于 `python/` 下的 manifest,仅扫描后者会遗漏已打包的 Office 库。内置分发包同样通过运行时许可证检查,其中包括 Pillow 与 typing-extensions 声明的宽松许可证标识 `MIT-CMU` 和 `PSF-2.0`。 **分层依据是分发内容,而非 manifest 字段名。** 安装的运行时库由 `DEV_ONLY_AREAS` 之外的 `dependencies` 或 `optionalDependencies` 识别;排除区域为根 manifest、`packages/test-support/`、`packages/test-support/client-runtime/`、`website/`、`native/`。发布所用的 tsdown 与 Vite 配置解析到的浏览器输入也属于运行时,即使它们位于 `devDependencies`;[浏览器第三方构建输入](2026-09-08-browser-third-party-build-inputs.zh.md)拥有这项分类。测试支撑依赖不会仅因字段写成 `dependencies` 就被交付,而生成器显式披露 `tsx`,因为源码启动通过其 ESM 钩子执行。 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index b2d8c6da7c..5295b36d89 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -5,7 +5,7 @@ DeepSeek Harness is licensed under [MIT](LICENSE). It depends on the third-party software listed below. Each project remains under its own license; nothing in this file changes those terms. -This file lists **direct** dependencies declared by the workspace, the explicitly disclosed official Claude Code platform payload closure, and the Desktop bundled Python distributions. It is generated by `scripts/gen-third-party-notices.ts`: a pre-commit hook regenerates it whenever a staged file changes one of its inputs, and `scripts/gen-third-party-notices.spec.ts` asserts in the test lane that the committed bytes match. Deleting a manifest runs no hook, so that case is caught by the assertion instead. Run `pnpm run verify-third-party-notices` for the standalone check. +This file lists **direct** dependencies declared by the workspace, the explicitly disclosed official Claude Code platform payload closure, and the Bundled Python distributions. It is generated by `scripts/gen-third-party-notices.ts`: a pre-commit hook regenerates it whenever a staged file changes one of its inputs, and `scripts/gen-third-party-notices.spec.ts` asserts in the test lane that the committed bytes match. Deleting a manifest runs no hook, so that case is caught by the assertion instead. Run `pnpm run verify-third-party-notices` for the standalone check. The complete npm transitive closure, including the Landlock launcher workspace, is recorded with exact pinned versions in [`pnpm-lock.yaml`](pnpm-lock.yaml) — inspect it with `pnpm licenses list`. The Python SDK closure is recorded separately in [`python/sdk/uv.lock`](python/sdk/uv.lock). @@ -244,9 +244,9 @@ Direct dependencies of the `pyproject.toml` manifests, plus `uv` as the developm | [`pytest`](https://github.com/pytest-dev/pytest) | MIT | test-only | | [`uv`](https://github.com/astral-sh/uv) | MIT / Apache-2.0 | development workflow tool | -## Desktop bundled Python distributions +## Bundled Python distributions -The [Desktop runtime lock](scripts/primary-runtime/lock.json) records each distribution version and the wheel download hashes. The table includes every entry in `pythonPackages`, including transitive dependencies. Wheel extraction preserves distribution metadata and the license and notice files supplied by each archive. Project licenses below do not enumerate the separate licenses of native libraries bundled inside wheels. +The [shared runtime lock](scripts/primary-runtime/lock.json) records each distribution version and the wheel download hashes. The table includes every entry in `pythonPackages`, including transitive dependencies. Wheel extraction preserves distribution metadata and the license and notice files supplied by each archive. Project licenses below do not enumerate the separate licenses of native libraries bundled inside wheels. | Distribution | Locked version | Project license | | --- | --- | --- | diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index f4b0246e8e..6d5d0707d4 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/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 apps/desktop/README.md -README.md: 123e27d1d03bf218835af310cb529365cdc47b94 -README.zh.md: aee40ffa4ceae673a931f076d668debfdf83de2d +README.md: 91e093e17fe7b748beda76a39c2002732b892902 +README.zh.md: 484788eaaa5c7ce604f784c0f8b89688ecc5c815 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 123e27d1d0..91e093e17f 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -28,7 +28,7 @@ The payload follows the Desktop release. `runtime.json` records the Desktop vers The private Desktop `runtime/bin` directory is added only to package-installation processes, not the Host PATH inherited by PTC and agent shells. This tool does not change PATH, environment variables or user package-manager configuration. pnpm retains its own defaults and user settings for global packages, executable entries and its store, including native errors when the environment does not support global installation. There is no separate dependency updater. [The primary-runtime decision](../../.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.md) records these choices. -Node prepares the bundled interpreters and Python libraries without a system Python or pip. [The download lock](../../scripts/primary-runtime/lock.json) pins interpreter archives, Python distribution versions and target-specific wheel URLs and hashes; pnpm follows the Desktop build dependency lock. Wheel filenames and distribution versions must agree for every target. Preserve key order within the selected target, wheel records and distribution map, plus wheel-entry order; these affect payload identity, while top-level lock key order does not. Library wheels unpack into site-packages, retaining auxiliary files under each wheel's `.data/scripts` directory without generating command wrappers. Other installation schemes are rejected. Native-target checks verify the locked wheel set and versions, permit the interpreter's bundled pip, and check the Python version, Office document read/write operations and dependency completeness without writing bytecode after staging cleanup and again after macOS signing. The standalone Node executable receives the JIT entitlement required by V8. Cross-target execution and signed installation require the target release host. Both `dev:desktop` and `start:desktop` prepare `.desktop-build/targets//runtime/primary-runtime` before launching Electron; first use may download locked dependencies. An unfinished preparation cannot report a successful launcher exit. +Node prepares the bundled interpreters and Python libraries without a system Python or pip. [The download lock](../../scripts/primary-runtime/lock.json) pins interpreter archives, Python distribution versions and target-specific wheel URLs and hashes; the shared builder resolves pnpm from the root development dependency pin. The root package-manager version and the Desktop pin are checked for agreement. Wheel filenames and distribution versions must agree for every target. Preserve key order within the selected target, wheel records and distribution map, plus wheel-entry order; these affect payload identity, while top-level lock key order does not. Library wheels unpack into site-packages, retaining auxiliary files under each wheel's `.data/scripts` directory without generating command wrappers. Other installation schemes are rejected. Native-target checks verify the locked wheel set and versions, permit the interpreter's bundled pip, and check the Python version, Office document read/write operations and dependency completeness without writing bytecode after staging cleanup and again after macOS signing. The standalone Node executable receives the JIT entitlement required by V8. Cross-target execution and signed installation require the target release host. Both `dev:desktop` and `start:desktop` prepare `.desktop-build/targets//runtime/primary-runtime` before launching Electron; first use may download locked dependencies. An unfinished preparation cannot report a successful launcher exit. | Decision | Why | Direct consequence | |---|---|---| diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index aee40ffa4c..484788eaaa 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -28,7 +28,7 @@ Desktop 默认注册 `office-docx`、`office-pptx` 和 `office-xlsx`。这些技 Desktop 私有的 `runtime/bin` 目录仅添加到包安装进程,不进入 PTC 和 agent shell 从 Host 继承的 PATH。该工具不修改 PATH、环境变量或用户包管理器配置。pnpm 的全局包、命令入口和 store 保留自身默认值及用户设置,包括环境不支持全局安装时的原生错误。不提供独立依赖更新器。[第一方 Runtime 决策](../../.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.zh.md)记录这些选择。 -Node 准备内置解释器和 Python 库,无需系统 Python 或 pip。[下载锁](../../scripts/primary-runtime/lock.json)固定解释器压缩包、Python 分发包版本及目标平台 wheel 的 URL 和哈希;pnpm 使用 Desktop 构建依赖锁。每个目标的 wheel 文件名必须与分发包版本一致。所选目标、wheel 记录及分发包映射内部的键顺序,以及 wheel 条目顺序都会影响产物身份,编辑时须保留;锁文件顶层键的顺序不影响该身份。库 wheel 解压到 site-packages,各 wheel 的 `.data/scripts` 目录保留辅助文件,不生成命令行包装器。其他安装方案会被拒绝。本机目标检查在清理暂存目录后以及 macOS 签名后验证锁定 wheel 的集合与版本,允许解释器自带的 pip,并检查 Python 版本、Office 文档读写和依赖完整性,不写入字节码。独立 Node 可执行文件获得 V8 所需的 JIT 权限。跨目标执行和签名安装需要对应的发布主机。`dev:desktop` 和 `start:desktop` 都会在启动 Electron 前准备 `.desktop-build/targets//runtime/primary-runtime`;首次准备可能需要下载锁定的依赖。准备未完成时,启动命令不能报告成功退出。 +Node 准备内置解释器和 Python 库,无需系统 Python 或 pip。[下载锁](../../scripts/primary-runtime/lock.json)固定解释器压缩包、Python 分发包版本及目标平台 wheel 的 URL 和哈希;共享构建器从根开发依赖中解析 pnpm 固定版本。测试检查根 package-manager 版本和 Desktop 固定版本保持一致。每个目标的 wheel 文件名必须与分发包版本一致。所选目标、wheel 记录及分发包映射内部的键顺序,以及 wheel 条目顺序都会影响产物身份,编辑时须保留;锁文件顶层键的顺序不影响该身份。库 wheel 解压到 site-packages,各 wheel 的 `.data/scripts` 目录保留辅助文件,不生成命令行包装器。其他安装方案会被拒绝。本机目标检查在清理暂存目录后以及 macOS 签名后验证锁定 wheel 的集合与版本,允许解释器自带的 pip,并检查 Python 版本、Office 文档读写和依赖完整性,不写入字节码。独立 Node 可执行文件获得 V8 所需的 JIT 权限。跨目标执行和签名安装需要对应的发布主机。`dev:desktop` 和 `start:desktop` 都会在启动 Electron 前准备 `.desktop-build/targets//runtime/primary-runtime`;首次准备可能需要下载锁定的依赖。准备未完成时,启动命令不能报告成功退出。 | 决策 | 原因 | 直接结果 | |---|---|---| diff --git a/apps/desktop/package.json b/apps/desktop/package.json index c060f1c673..aed9d6f839 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -50,10 +50,8 @@ "electron": "^44.0.0", "electron-builder": "^26.15.3", "extract-zip": "^2.0.1", - "fflate": "^0.8.2", "js-yaml": "^4.2.0", "pnpm": "11.7.0", - "tar": "^7.5.0", "typescript": "^6.0.3" } } diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 27d34f7af2..6942c71a25 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.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 docs/config-catalog.md -config-catalog.md: 64ef9c99d2cc98690097d848ab7cfb899c6bb474 -config-catalog.zh.md: 76faa711e53011ba34d599f59f616280a4660ad0 +config-catalog.md: 2efbd1bda5d4445fe53c1dd8cc1ad27cd3cfcead +config-catalog.zh.md: bb95afa5cb2b7825e45d9ec5315ea1a3ab79de12 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 64ef9c99d2..2efbd1bda5 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -3482,7 +3482,7 @@ export interface Config { } ``` -Source: [`packages/skill/tool-workspace-dependencies/src/index.ts:14`](../packages/skill/tool-workspace-dependencies/src/index.ts) +Source: [`packages/skill/tool-workspace-dependencies/src/index.ts:15`](../packages/skill/tool-workspace-dependencies/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 76faa711e5..bb95afa5cb 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -3484,7 +3484,7 @@ export interface Config { } ``` -来源:[`packages/skill/tool-workspace-dependencies/src/index.ts:14`](../packages/skill/tool-workspace-dependencies/src/index.ts) +来源:[`packages/skill/tool-workspace-dependencies/src/index.ts:15`](../packages/skill/tool-workspace-dependencies/src/index.ts) diff --git a/docs/tool-catalog.i18n.yaml b/docs/tool-catalog.i18n.yaml index 95707a37cf..ada24e35aa 100644 --- a/docs/tool-catalog.i18n.yaml +++ b/docs/tool-catalog.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 docs/tool-catalog.md -tool-catalog.md: bdc3a5af4b1bf723879c423fe201576ca3772e64 -tool-catalog.zh.md: bc445ea8d6b0a5778e4bd191693ff01bd6781d73 +tool-catalog.md: b3854acff78dc3478bd64c87c2b2c8a89e3cdb15 +tool-catalog.zh.md: 1cf65d6e71012260347416e864117c78c528e890 diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index bdc3a5af4b..b3854acff7 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -2445,7 +2445,7 @@ Source: [`packages/workflow/tool-workflow/src/index.ts`](../packages/workflow/to ### `load_workspace_dependencies` -Get absolute paths to bundled Python, Node.js, pnpm, and library directories, plus bundled Python distribution versions. Python includes numpy, pandas, python-docx, python-pptx, openpyxl, Pillow, lxml, and XlsxWriter. Use these libraries for Office files unless the user or workspace instructions select another environment. Run pnpm with the returned Node executable and pnpm script path. This does not change PATH or package-manager settings. +Get absolute paths to bundled Python and library directories, plus bundled Python distribution versions. Node.js and pnpm paths are included when the payload provides them. Python includes numpy, pandas, python-docx, python-pptx, openpyxl, Pillow, lxml, and XlsxWriter. Use these libraries for Office files unless the user or workspace instructions select another environment. When Node.js and pnpm paths are returned, run pnpm with that Node executable and pnpm script path. This does not change PATH or package-manager settings. ```json { diff --git a/docs/tool-catalog.zh.md b/docs/tool-catalog.zh.md index bc445ea8d6..1cf65d6e71 100644 --- a/docs/tool-catalog.zh.md +++ b/docs/tool-catalog.zh.md @@ -2453,7 +2453,7 @@ todo_write 是会话所有的状态;UI 将最新的 todo/write 事件渲染为 ### `load_workspace_dependencies` -获取随包附带的 Python、Node.js、pnpm 和库目录的绝对路径,以及随包 Python 发行版的版本。Python 含 numpy、pandas、python-docx、python-pptx、openpyxl、Pillow、lxml 与 XlsxWriter。除非用户或工作区指令选择了别的环境,Office 文件请使用这些库。用返回的 Node 可执行文件和 pnpm 脚本路径运行 pnpm。本工具不改 PATH,也不改包管理器设置。 +获取随包附带的 Python 和库目录的绝对路径,以及随包 Python 发行版的版本。payload 提供 Node.js 和 pnpm 时才返回对应路径。Python 含 numpy、pandas、python-docx、python-pptx、openpyxl、Pillow、lxml 与 XlsxWriter。除非用户或工作区指令选择了别的环境,Office 文件请使用这些库。返回 Node.js 和 pnpm 路径时,用该 Node 可执行文件和 pnpm 脚本路径运行 pnpm。本工具不改 PATH,也不改包管理器设置。 ```json { diff --git a/package.json b/package.json index 16e6180821..5315c1a8d9 100644 --- a/package.json +++ b/package.json @@ -90,7 +90,6 @@ "check:ci:windows-complete": "tsx scripts/run-gates.ts ci-windows-complete", "check:ci:windows-observational": "tsx scripts/run-gates.ts ci-windows-observational", "check:node-compat": "tsx scripts/run-gates.ts node-compat", - "pnpm": "11.7.0", "publint": "tsx scripts/publint-all.ts", "doc-typecheck": "npm run build:lib:host && npm run doc-typecheck:contracts-ready", "doc-typecheck:contracts-ready": "tsx scripts/doc-typecheck.ts", diff --git a/packages/skill/tool-workspace-dependencies/README.i18n.yaml b/packages/skill/tool-workspace-dependencies/README.i18n.yaml index 9664f745e2..19c7ea6952 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: 1464e231980bc24559d5dfc2635d51e57bcfc116 -README.zh.md: 48520369dde7cb0a5fcfb8d53af687ce651c47d5 +README.md: 77020b8ca21224b9c2aeed440c7837a1fc610e7c +README.zh.md: 62ca9c088a3c6b69343285699864b2cd3418d683 diff --git a/packages/skill/tool-workspace-dependencies/README.md b/packages/skill/tool-workspace-dependencies/README.md index 1464e23198..77020b8ca2 100644 --- a/packages/skill/tool-workspace-dependencies/README.md +++ b/packages/skill/tool-workspace-dependencies/README.md @@ -25,7 +25,7 @@ Deployments that ship their own script runtimes (Desktop's primary runtime, or a ## Use this package -Mount the plugin beside the tool registry with the payload directory. The bundled Office skills (`@deepseek-ai/dsh-skill-office`) reference this tool by name for their default interpreter. +Mount the plugin beside the tool registry with the payload directory. Configuration validation requires a nonempty `source` and rejects empty `root` values before activation; both paths must be absolute. The bundled Office skills (`@deepseek-ai/dsh-skill-office`) reference this tool by name for their default interpreter. ### Minimal configuration diff --git a/packages/skill/tool-workspace-dependencies/README.zh.md b/packages/skill/tool-workspace-dependencies/README.zh.md index 48520369dd..62ca9c088a 100644 --- a/packages/skill/tool-workspace-dependencies/README.zh.md +++ b/packages/skill/tool-workspace-dependencies/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -与工具注册表一起挂载,给出 payload 目录。内置 Office skills(`@deepseek-ai/dsh-skill-office`)按名字引用本工具取默认解释器。 +与工具注册表一起挂载,给出 payload 目录。配置校验在激活前要求非空 `source` 并拒绝空 `root`;两条路径都必须为绝对路径。内置 Office skills(`@deepseek-ai/dsh-skill-office`)按名字引用本工具取默认解释器。 ### 最小配置 diff --git a/packages/skill/tool-workspace-dependencies/package.json b/packages/skill/tool-workspace-dependencies/package.json index b3f26fcf86..81597a74dc 100644 --- a/packages/skill/tool-workspace-dependencies/package.json +++ b/packages/skill/tool-workspace-dependencies/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-workspace-dependencies", "description": "The load_workspace_dependencies tool: absolute paths into a bundled Python, Node.js, and pnpm payload", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, @@ -37,5 +37,8 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^" + }, + "dependencies": { + "@deepseek-ai/schemastery": "workspace:^" } } diff --git a/packages/skill/tool-workspace-dependencies/src/index.ts b/packages/skill/tool-workspace-dependencies/src/index.ts index e957ceab7d..de056b144b 100644 --- a/packages/skill/tool-workspace-dependencies/src/index.ts +++ b/packages/skill/tool-workspace-dependencies/src/index.ts @@ -3,6 +3,7 @@ import { cp, lstat, mkdir, mkdtemp, readFile, rename, rm, stat } from 'node:fs/promises' import { dirname, isAbsolute, join } from 'node:path' import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { defineTool } from '@deepseek-ai/dsh-tools' /** Cordis plugin identity. */ @@ -22,6 +23,12 @@ export interface Config { readonly root?: string } +/** Require a named payload source before the Loader activates the tool. */ +export const Config: z = z.object({ + source: z.string().min(1).required(), + root: z.string().min(1), +}) + /** Versions recorded by the payload build, independent of user-installed packages. */ export interface PrimaryRuntimeManifest { readonly desktopVersion: string @@ -160,9 +167,9 @@ export async function installPrimaryRuntime(source: string, root: string): Promi const manifest = await compatibleManifest(source) await mkdir(dirname(root), { recursive: true }) const previous = `${root}.previous` - await exists(previous) - await exists(root) - if (!await exists(root) && await exists(previous)) await rename(previous, root) + const previousExists = await exists(previous) + const rootExists = await exists(root) + 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) @@ -203,7 +210,7 @@ export function apply(ctx: Context, config: Config): void { }) ctx.tools.register(defineTool({ name: 'load_workspace_dependencies', - description: 'Get absolute paths to bundled Python, Node.js, pnpm, and library directories, plus bundled Python distribution versions. Python includes numpy, pandas, python-docx, python-pptx, openpyxl, Pillow, lxml, and XlsxWriter. Use these libraries for Office files unless the user or workspace instructions select another environment. Run pnpm with the returned Node executable and pnpm script path. This does not change PATH or package-manager settings.', + description: 'Get absolute paths to bundled Python and library directories, plus bundled Python distribution versions. Node.js and pnpm paths are included when the payload provides them. Python includes numpy, pandas, python-docx, python-pptx, openpyxl, Pillow, lxml, and XlsxWriter. Use these libraries for Office files unless the user or workspace instructions select another environment. When Node.js and pnpm paths are returned, run pnpm with that Node executable and pnpm script path. This does not change PATH or package-manager settings.', parameters: {}, output: { schema: { 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 73f12ab47b..f71adaee68 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 @@ -345,3 +345,18 @@ it('disposes an unused tool without preparing the payload', async () => { it.each([{ source: 'relative' }, { source: process.cwd(), root: 'relative' }])('rejects relative runtime configuration at plugin load: %j', async (config) => { await expect(tool(config)).rejects.toThrow('must be absolute paths') }) + +it.each([{}, { source: '' }, { source: 1 }])('rejects invalid payload configuration with a named source error: %j', (config) => { + expect(() => workspaceDependencies.Config(config as unknown as workspaceDependencies.Config)).toThrow('source') +}) + +it('validates the previous directory even when a current installation exists', async () => { + const { source, root, directory } = await fixture() + await installPrimaryRuntime(source, root) + const outside = join(directory, 'outside-previous') + await mkdir(outside) + await writeFile(join(outside, 'keep'), 'untouched') + await symlink(outside, `${root}.previous`, process.platform === 'win32' ? 'junction' : 'dir') + await expect(installPrimaryRuntime(source, root)).rejects.toThrow('filesystem link') + expect(await readFile(join(outside, 'keep'), 'utf8')).toBe('untouched') +}) diff --git a/packages/skill/tool-workspace-dependencies/tsconfig.json b/packages/skill/tool-workspace-dependencies/tsconfig.json index 28eabaa205..a3e6344b9d 100644 --- a/packages/skill/tool-workspace-dependencies/tsconfig.json +++ b/packages/skill/tool-workspace-dependencies/tsconfig.json @@ -14,6 +14,9 @@ { "path": "../../../vendor/cordis" }, + { + "path": "../../../vendor/schemastery" + }, { "path": "../../core/tools" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a1c3404061..686ed5f48d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -593,18 +593,12 @@ importers: extract-zip: specifier: ^2.0.1 version: 2.0.1(supports-color@9.4.0) - fflate: - specifier: ^0.8.2 - version: 0.8.3 js-yaml: specifier: ^4.2.0 version: 4.3.1 pnpm: specifier: 11.7.0 version: 11.7.0 - tar: - specifier: ^7.5.0 - version: 7.5.22 typescript: specifier: ^6.0.3 version: 6.0.3 @@ -10375,6 +10369,10 @@ importers: version: link:../../core/tools packages/skill/tool-workspace-dependencies: + dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ diff --git a/scripts/gen-third-party-notices.spec.ts b/scripts/gen-third-party-notices.spec.ts index e4a452d712..8527a0d588 100644 --- a/scripts/gen-third-party-notices.spec.ts +++ b/scripts/gen-third-party-notices.spec.ts @@ -2,13 +2,13 @@ import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSyn import { join, resolve } from 'node:path' import { tmpdir } from 'node:os' import { describe, expect, it } from 'vitest' -import desktopRuntimeLock from './primary-runtime/lock.json' with { type: 'json' } +import primaryRuntimeLock from './primary-runtime/lock.json' with { type: 'json' } import { CLAUDE_AGENT_SDK_PACKAGE, assertRuntimeLicenses, claudeDistributionFromManifest, collectPythonDependencies, - collectDesktopPythonDependencies, + collectBundledPythonDependencies, isOwnerAuthorizedRuntime, isPermissive, type Manifest, @@ -33,7 +33,7 @@ describe('THIRD_PARTY_NOTICES.md', () => { }, async () => { const generated = await render() expect(generated).toContain('It depends on the third-party software listed below.') - expect(generated).toContain(`| [\`numpy\`](https://github.com/numpy/numpy) | ${desktopRuntimeLock.pythonPackages.numpy} | BSD-3-Clause |`) + expect(generated).toContain(`| [\`numpy\`](https://github.com/numpy/numpy) | ${primaryRuntimeLock.pythonPackages.numpy} | BSD-3-Clause |`) expect(generated).toContain('## LibreOffice conversion kit') expect(generated).toContain('Recipients must have access to those corresponding sources and notices.') expect(readFileSync(resolve(root, 'THIRD_PARTY_NOTICES.md'), 'utf8'), 'stale notices — run `pnpm run gen-third-party-notices`').toBe(generated) @@ -323,22 +323,22 @@ describe('collectPythonDependencies', () => { }) }) -describe('collectDesktopPythonDependencies', () => { - it('discloses the committed Desktop closure with its exact locked versions', () => { - const dependencies = collectDesktopPythonDependencies(desktopRuntimeLock.pythonPackages) - expect(dependencies).toHaveLength(Object.keys(desktopRuntimeLock.pythonPackages).length) +describe('collectBundledPythonDependencies', () => { + it('discloses the committed bundled Python closure with its exact locked versions', () => { + const dependencies = collectBundledPythonDependencies(primaryRuntimeLock.pythonPackages) + expect(dependencies).toHaveLength(Object.keys(primaryRuntimeLock.pythonPackages).length) expect(dependencies).toContainEqual({ - name: 'pillow', version: desktopRuntimeLock.pythonPackages.Pillow, + name: 'pillow', version: primaryRuntimeLock.pythonPackages.Pillow, license: 'MIT-CMU', repo: 'https://github.com/python-pillow/Pillow', }) expect(dependencies).toContainEqual({ - name: 'typing-extensions', version: desktopRuntimeLock.pythonPackages.typing_extensions, + name: 'typing-extensions', version: primaryRuntimeLock.pythonPackages.typing_extensions, license: 'PSF-2.0', repo: 'https://github.com/python/typing_extensions', }) }) it('normalizes names while preserving pinned version strings', () => { - expect(collectDesktopPythonDependencies({ 'typing_extensions': '4.16.0', 'Pillow': '12.3.0' })) + expect(collectBundledPythonDependencies({ 'typing_extensions': '4.16.0', 'Pillow': '12.3.0' })) .toEqual([ { name: 'pillow', version: '12.3.0', license: 'MIT-CMU', repo: 'https://github.com/python-pillow/Pillow' }, { name: 'typing-extensions', version: '4.16.0', license: 'PSF-2.0', repo: 'https://github.com/python/typing_extensions' }, @@ -346,18 +346,18 @@ describe('collectDesktopPythonDependencies', () => { }) it('rejects duplicate normalized distribution names with the same locked version', () => { - expect(() => collectDesktopPythonDependencies({ typing_extensions: '4.16.0', 'typing.extensions': '4.16.0' })) + expect(() => collectBundledPythonDependencies({ typing_extensions: '4.16.0', 'typing.extensions': '4.16.0' })) .toThrow('duplicate normalized names') }) it('rejects missing distribution metadata and conflicting normalized versions', () => { - expect(() => collectDesktopPythonDependencies({ missing: '1.0' })).toThrow('missing from PYTHON_METADATA') - expect(() => collectDesktopPythonDependencies({ Pillow: '12.3.0', pillow: '12.4.0' })).toThrow('conflicting locked versions') + expect(() => collectBundledPythonDependencies({ missing: '1.0' })).toThrow('missing from PYTHON_METADATA') + expect(() => collectBundledPythonDependencies({ Pillow: '12.3.0', pillow: '12.4.0' })).toThrow('conflicting locked versions') }) it('applies the runtime license check to bundled Python distributions', () => { - expect(() => { assertRuntimeLicenses(collectDesktopPythonDependencies(desktopRuntimeLock.pythonPackages)) }).not.toThrow() - const dependencies = collectDesktopPythonDependencies({ 'copyleft-wheel': '1.0' }, { + expect(() => { assertRuntimeLicenses(collectBundledPythonDependencies(primaryRuntimeLock.pythonPackages)) }).not.toThrow() + const dependencies = collectBundledPythonDependencies({ 'copyleft-wheel': '1.0' }, { 'copyleft-wheel': { license: 'GPL-3.0-only', repo: 'https://example.com/project' }, }) expect(() => { assertRuntimeLicenses(dependencies) }).toThrow('copyleft-wheel (GPL-3.0-only)') diff --git a/scripts/gen-third-party-notices.ts b/scripts/gen-third-party-notices.ts index c65dbdc6f1..6167190367 100644 --- a/scripts/gen-third-party-notices.ts +++ b/scripts/gen-third-party-notices.ts @@ -1,7 +1,7 @@ /** * Generate `THIRD_PARTY_NOTICES.md` from the workspace manifests: every * external dependency named by a workspace `package.json`, the vendored-package - * manifest in `vendor/README.md`, the Python `pyproject.toml` files, the Desktop + * manifest in `vendor/README.md`, the Python `pyproject.toml` files, the shared * Python distribution lock, and the pnpm patch list. npm metadata comes from the installed * store, so the tree must be installed. `--check` verifies the committed * artifact. Tier policy and ownership live in @@ -13,7 +13,7 @@ import { dirname, resolve } from 'node:path' import * as yaml from 'js-yaml' import { parse as parseToml, type TomlTableWithoutBigInt, type TomlValueWithoutBigInt } from 'smol-toml' import parseSpdx from 'spdx-expression-parse' -import desktopRuntimeLock from './primary-runtime/lock.json' with { type: 'json' } +import primaryRuntimeLock from './primary-runtime/lock.json' with { type: 'json' } import { browserBundledExternals } from './browser-bundled-externals.ts' const root = resolve(import.meta.dirname, '..') @@ -93,7 +93,7 @@ const OVERRIDES: Record = { /** * Python metadata is recorded from the distributions' license and project * fields; generation does not require installing their wheels. Both Python - * manifests and the Desktop lock reject names absent from this map. + * manifests and the shared runtime lock reject names absent from this map. */ const PYTHON_METADATA: Record = { pydantic: { license: 'MIT', repo: 'https://github.com/pydantic/pydantic', role: 'runtime dependency of `deepseek-harness-sdk`' }, @@ -616,12 +616,12 @@ function collectPython(): { name: string; license: string; repo: string; role: s } /** - * Disclose every Desktop wheel distribution using its locked version, rejecting duplicate normalized names. - * @param packages - Distribution names and exact versions from the Desktop runtime lock. + * Disclose every bundled wheel distribution using its locked version, rejecting duplicate normalized names. + * @param packages - Distribution names and exact versions from the shared runtime lock. * @param metadata - License and source metadata for every locked distribution. * @returns Normalized, sorted distribution identities with versions and licenses. */ -export function collectDesktopPythonDependencies( +export function collectBundledPythonDependencies( packages: Readonly>, metadata: PythonMetadata = PYTHON_METADATA, ): { name: string; version: string; license: string; repo: string }[] { @@ -630,14 +630,14 @@ export function collectDesktopPythonDependencies( const name = normalizePythonDistributionName(distribution) const previous = normalized.get(name) if (previous !== undefined && previous !== version) { - throw new Error(`gen-third-party-notices: Desktop python distribution ${name} has conflicting locked versions.`) + throw new Error(`gen-third-party-notices: bundled Python distribution ${name} has conflicting locked versions.`) } - if (previous !== undefined) throw new Error(`gen-third-party-notices: Desktop python distribution ${name} has duplicate normalized names.`) + if (previous !== undefined) throw new Error(`gen-third-party-notices: bundled Python distribution ${name} has duplicate normalized names.`) normalized.set(name, version) } return [...normalized].sort(([a], [b]) => a.localeCompare(b)).map(([name, version]) => { const entry = metadata[name] - if (entry === undefined) throw new Error(`gen-third-party-notices: Desktop python distribution ${name} is missing from PYTHON_METADATA.`) + if (entry === undefined) throw new Error(`gen-third-party-notices: bundled Python distribution ${name} is missing from PYTHON_METADATA.`) return { name, version, license: entry.license, repo: entry.repo } }) } @@ -753,7 +753,7 @@ export async function render(): Promise { const kitRuntime = runtimeDeps.some(dep => dep.name === LIBREOFFICE_KIT_PACKAGE) const vendored = collectVendored() const python = collectPython() - const desktopPython = collectDesktopPythonDependencies(desktopRuntimeLock.pythonPackages) + const bundledPython = collectBundledPythonDependencies(primaryRuntimeLock.pythonPackages) const patched = collectPatched() const claudeDistribution = runtimeDeps.some( dep => dep.name === CLAUDE_AGENT_SDK_PACKAGE, @@ -762,7 +762,7 @@ export async function render(): Promise { : undefined const nonPermissiveDev = devDeps.filter(dep => !isPermissive(dep.license)) assertRuntimeLicenses(runtimeDeps) - assertRuntimeLicenses(desktopPython) + assertRuntimeLicenses(bundledPython) const patchedLines = patched.map(({ spec, patch }) => `- \`${spec}\` — [\`${patch}\`](${patch})`) return `