From 6bc7938a151dd68544c336ce757208a7de0d7fd9 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 16 Sep 2026 12:26:54 +0800 Subject: [PATCH 01/15] fix(web): localize built-in plugin names and descriptions --- .../plugin-manager/live-enabled.expected.md | 18 ++--- .../plugin-manager/manager.expected.md | 18 ++--- apps/web/tests/plugin-manager.e2e.ts | 38 ++++++++++ .../client/ui-plugin-manager/README.i18n.yaml | 4 +- packages/client/ui-plugin-manager/README.md | 2 + .../client/ui-plugin-manager/README.zh.md | 2 + .../src/client/PluginManagerPage.tsx | 12 ++-- .../ui-plugin-manager/src/client/locales.ts | 12 ++++ .../src/client/presentation.ts | 27 +++++++- .../tests/components.client.spec.tsx | 69 ++++++++++++++++--- 10 files changed, 167 insertions(+), 35 deletions(-) diff --git a/apps/web/tests/expected/plugin-manager/live-enabled.expected.md b/apps/web/tests/expected/plugin-manager/live-enabled.expected.md index 382a8254de..0c785fc80b 100644 --- a/apps/web/tests/expected/plugin-manager/live-enabled.expected.md +++ b/apps/web/tests/expected/plugin-manager/live-enabled.expected.md @@ -8,17 +8,17 @@ - text: "3" - list: - listitem: - - button "查看 experimental-agent-team-profile": experimental-agent-team-profile - - text: 官方 Experimental profile bundle enabling Agent Teams over dsh-base - - switch "启用 experimental-agent-team-profile" + - button "查看 智能体团队(实验性)": 智能体团队(实验性) + - text: 官方 启用智能体团队协作与团队工具。 + - switch "启用 智能体团队(实验性)" - listitem: - - button "查看 experimental-agent-team-web-profile": experimental-agent-team-web-profile - - text: 官方 Experimental Web profile layer for Agent Teams Remote and UI plugins - - switch "启用 experimental-agent-team-web-profile" + - button "查看 智能体团队 Web 界面(实验性)": 智能体团队 Web 界面(实验性) + - text: 官方 在浏览器中查看团队成员、任务看板和成员会话。 + - switch "启用 智能体团队 Web 界面(实验性)" - listitem: - - button "查看 experimental-auto-review": experimental-auto-review - - text: 官方 Per-tool LLM authorization review for the DeepSeek Harness Auto permission preset - - switch "启用 experimental-auto-review" + - button "查看 自动授权审查(实验性)": 自动授权审查(实验性) + - text: 官方 提供自动审查权限模式,由模型在每次工具调用前判断是否授权。 + - switch "启用 自动授权审查(实验性)" - heading "已安装" [level=3] - text: "1" - list: diff --git a/apps/web/tests/expected/plugin-manager/manager.expected.md b/apps/web/tests/expected/plugin-manager/manager.expected.md index fa151f6eb7..808a71b71f 100644 --- a/apps/web/tests/expected/plugin-manager/manager.expected.md +++ b/apps/web/tests/expected/plugin-manager/manager.expected.md @@ -8,17 +8,17 @@ - text: "3" - list: - listitem: - - button "查看 experimental-agent-team-profile": experimental-agent-team-profile - - text: 官方 Experimental profile bundle enabling Agent Teams over dsh-base - - switch "启用 experimental-agent-team-profile" + - button "查看 智能体团队(实验性)": 智能体团队(实验性) + - text: 官方 启用智能体团队协作与团队工具。 + - switch "启用 智能体团队(实验性)" - listitem: - - button "查看 experimental-agent-team-web-profile": experimental-agent-team-web-profile - - text: 官方 Experimental Web profile layer for Agent Teams Remote and UI plugins - - switch "启用 experimental-agent-team-web-profile" + - button "查看 智能体团队 Web 界面(实验性)": 智能体团队 Web 界面(实验性) + - text: 官方 在浏览器中查看团队成员、任务看板和成员会话。 + - switch "启用 智能体团队 Web 界面(实验性)" - listitem: - - button "查看 experimental-auto-review": experimental-auto-review - - text: 官方 Per-tool LLM authorization review for the DeepSeek Harness Auto permission preset - - switch "启用 experimental-auto-review" + - button "查看 自动授权审查(实验性)": 自动授权审查(实验性) + - text: 官方 提供自动审查权限模式,由模型在每次工具调用前判断是否授权。 + - switch "启用 自动授权审查(实验性)" - heading "已安装" [level=3] - text: "1" - list: diff --git a/apps/web/tests/plugin-manager.e2e.ts b/apps/web/tests/plugin-manager.e2e.ts index 58989b599e..b4ff640b0c 100644 --- a/apps/web/tests/plugin-manager.e2e.ts +++ b/apps/web/tests/plugin-manager.e2e.ts @@ -88,6 +88,44 @@ describe('web e2e: plugin manager', () => { expect(tripwire.pageErrors).toEqual([]) }, 60_000) + it('updates built-in names and descriptions when the UI language changes', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-locale')) + const panel = await openPluginsPanel() + await panel.getByRole('button', { name: '查看 智能体团队(实验性)' }).click() + const packageName = panel.locator('[data-plugin-name]') + expect(await packageName.textContent()).toBe('@deepseek-ai/dsh-experimental-agent-team-profile') + expect(await panel.getByText('启用智能体团队协作与团队工具。').count()).toBe(1) + try { + await page.getByRole('button', { name: '设置', exact: true }).click() + await page.getByRole('dialog', { name: '设置' }).getByRole('button', { name: '中文' }).click() + await page.getByRole('menuitem', { name: 'English' }).click() + await page.getByRole('dialog', { name: 'Settings' }).waitFor() + await page.keyboard.press('Escape') + await panel.getByRole('heading', { name: 'Experimental Agent Teams', exact: true }).waitFor() + expect(await packageName.textContent()).toBe('@deepseek-ai/dsh-experimental-agent-team-profile') + expect(await panel.getByText('Enable agent team collaboration and team tools.').count()).toBe(1) + await panel.getByRole('button', { name: 'Back to plugins' }).click() + for (const title of ['Experimental Agent Teams', 'Experimental Agent Teams Web UI', 'Experimental Auto Authorization Review']) { + await panel.getByRole('button', { name: `View ${title}`, exact: true }).waitFor() + expect(await panel.getByRole('switch', { name: `Enable ${title}`, exact: true }).count()).toBe(1) + } + expect(await panel.getByText('View team members, the task board, and teammate sessions in the browser.').count()).toBe(1) + expect(await panel.getByText('Add an Auto review permission mode that uses the model to assess authorization before each tool call.').count()).toBe(1) + } finally { + if (await page.locator('html').getAttribute('lang') === 'en') { + if (await page.getByRole('dialog', { name: 'Settings' }).count() === 0) { + await page.getByRole('button', { name: 'Settings', exact: true }).click() + } + await page.getByRole('dialog', { name: 'Settings' }).getByRole('button', { name: 'English' }).click() + await page.getByRole('menuitem', { name: '中文' }).click() + await page.getByRole('dialog', { name: '设置' }).waitFor() + } + await closeSettings() + } + await panel.getByRole('button', { name: '查看 智能体团队(实验性)', exact: true }).waitFor() + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) + it('checks a spec before installing it and words what the check refused', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-install')) const panel = await openPluginsPanel() diff --git a/packages/client/ui-plugin-manager/README.i18n.yaml b/packages/client/ui-plugin-manager/README.i18n.yaml index 507f807683..89f69b21a5 100644 --- a/packages/client/ui-plugin-manager/README.i18n.yaml +++ b/packages/client/ui-plugin-manager/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/client/ui-plugin-manager/README.md -README.md: 313ebf7df21ea73c0c20b1c84cac6767c2079037 -README.zh.md: 81b15b459d8bea4721ed2f84607b1c737101b3b8 +README.md: 1a3ae0f0b5e6cfca4fdbb272f84ca4b641933ba6 +README.zh.md: 0d4d1aeb98fecb24a6bc3e9d0ac84389b0fe939e diff --git a/packages/client/ui-plugin-manager/README.md b/packages/client/ui-plugin-manager/README.md index 313ebf7df2..1a3ae0f0b5 100644 --- a/packages/client/ui-plugin-manager/README.md +++ b/packages/client/ui-plugin-manager/README.md @@ -27,6 +27,8 @@ Use the **Plugins** entry in the Web sidebar to manage the profile's installed b Select **Plugins** in the sidebar. The page reads the inventory and the bundles through `api-remotes` when first opened; a Host without a managed profile shows the page as unavailable. **Built in** comes first and lists the bundles the installation ships for switching on, each tagged official, off until switched on and without an uninstall; **Installed** lists the bundles the profile holds. Cards are listed by name, so switching a bundle on or off does not move its card. A dependency without a bundle patch is not a plugin and is not listed unless the profile selects it, in which case it carries a problem tag. Global configuration remains in the Settings **Plugins** section. +The built-in Agent Teams, Agent Teams Web UI, and Auto Authorization Review bundles have localized names and descriptions that follow the UI language. Their detail pages retain the full npm package name; other packages display their short package name and original description. + ### Installing a bundle **Add plugin** takes a package name with an optional version, a Git address, a tarball, or an absolute local path; the dialog says a package name is what follows `dsh plugin add` in a README. **Not sure what to enter?** under the field opens a guide that shows the three common forms with an example each; **Use example** drops one into the field. **Install** first asks the Host to read what the spec names (`pluginManager.inspect`): a name the list already shows, a name the registry does not have, a path without a package, a package without a bundle patch, or a spec pnpm would refuse comes back under the field as one sentence, with the spec kept for editing. An accepted spec opens the installing screen, which shows the package's name, one-liner, and version as the Host read them and folds pnpm's command and output behind **Show install details**. A finished install offers **Enable now**, which switches the new bundle on, closes the dialog, and scrolls the list to it; closing instead leaves it installed and off. A failed install says what went wrong in one line — the registry or network could not be reached, the package was not found, the disk is full, the profile is not writable, pnpm blocked a build script — with pnpm's output behind the details and **Retry** at hand; the Host has already put the profile files back. When pnpm blocked a dependency's install scripts, the failed screen lists the packages whose scripts wait for permission and offers **Allow these scripts and retry** in place of **Retry**; the Host saves the permission in the profile's `pnpm-workspace.yaml`, which a failed run leaves as pnpm wrote it, then runs pnpm again, and the installed screen names what was allowed. A successful installation does not certify that a module can activate. diff --git a/packages/client/ui-plugin-manager/README.zh.md b/packages/client/ui-plugin-manager/README.zh.md index 81b15b459d..0d4d1aeb98 100644 --- a/packages/client/ui-plugin-manager/README.zh.md +++ b/packages/client/ui-plugin-manager/README.zh.md @@ -27,6 +27,8 @@ kind: "package-reference" 在侧栏选择**插件**。页面首次打开时通过 `api-remotes` 读取清单与组合包;没有受管 profile 的 Host 上页面显示为不可用。**内置**排在前面,列出安装随附、供开启的组合包,每个带官方标签,开启前保持关闭,且没有卸载;**已安装**列出 profile 持有的组合包。卡片按名称排序,启停组合包不会挪动它的卡片。没有组合包 patch 的依赖不是插件,除非 profile 选中了它才会带异常标签列出。全局配置仍在设置的**插件**分区中编辑。 +内置的 Agent Teams、Agent Teams Web UI 和 Auto Authorization Review 组合包使用随界面语言切换的本地化名称和描述。详情页保留完整 npm 包名;其他包显示简写包名和原始描述。 + ### 安装一个组合包 **添加插件**接受包名(可带版本)、Git 地址、压缩包或本地绝对路径;对话框说明包名就是 README 里 `dsh plugin add` 后面的那一段。输入框下方的**不知道该填什么?**展开一段引导,给出三种常见形式各一个示例;**填入示例**把示例填进输入框。**安装**先让 Host 读出 spec 指向什么(`pluginManager.inspect`):列表中已有的名字、注册表没有的名字、没有包的路径、没有组合包 patch 的包,或 pnpm 会拒绝的 spec,都以一句话回到输入框下方,spec 保留可继续编辑。通过检查的 spec 打开安装中界面,展示 Host 读到的包名、一句话简介和版本,pnpm 的命令与输出折叠在**查看安装详情**之后。安装完成后提供**立即启用**:启用新组合包、关闭对话框并把列表滚动到它;直接关闭则让它保持已安装但关闭。安装失败时用一行话说明原因——注册表或网络不可达、包不存在、磁盘已满、profile 不可写、pnpm 拦下了构建脚本——pnpm 输出在详情里,**重试**就在手边;Host 已经把 profile 文件放回原样。pnpm 拦下依赖的安装脚本时,失败界面列出等待允许的包,并以**允许这些脚本并重试**取代**重试**;Host 把授权写进 profile 的 `pnpm-workspace.yaml`(失败的运行保留 pnpm 写入的这个文件)再运行 pnpm,安装完成界面会说明允许了哪些脚本。安装成功不代表模块一定能够激活。 diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx index 7fc7facb79..73ed864613 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx @@ -21,7 +21,7 @@ import { type ConfirmState, type InstallInputError, type InstallState, type InstallSubject, type PackageRow, type PackageView, type PluginManagerFace, } from './manager-store.ts' -import { managementText, noticeText, shortName, type Translate } from './presentation.ts' +import { managementText, noticeText, packageText, type Translate } from './presentation.ts' import css from './PluginManagerPage.module.css' /** Full component props assembled by the main slot renderer. */ @@ -213,7 +213,7 @@ function PackageCard({ pkg, t, busy, highlighted, onOpen, onSetEnabled }: { readonly onOpen: () => void readonly onSetEnabled: (enabled: boolean) => void }): ReactNode { - const title = shortName(pkg.name) + const { title, description } = packageText(pkg, t) const status = packageStatus(pkg) return (
  • {t('statusOfficial')} : null} {status === 'problem' ? {t('statusProblem')} : null} - {pkg.description === undefined ? null : {pkg.description}} + {description === undefined ? null : {description}}
    @@ -262,7 +262,7 @@ function PackageDetail({ readonly onUninstall: () => void readonly onSetRowEnabled: (row: PackageRow, enabled: boolean) => void }): ReactNode { - const title = shortName(pkg.name) + const { title, description } = packageText(pkg, t) const status = packageStatus(pkg) return (
    @@ -299,7 +299,7 @@ function PackageDetail({ {status === 'problem' ? {t('statusProblem')} : null}

    {pkg.name}

    -

    {pkg.description ?? t('noDescription')}

    +

    {description ?? t('noDescription')}

    {pkg.error === undefined ? null :

    {t('reasonLabel')}: {managementText(pkg.error, t)}

    } {pkg.readOnlyReason === undefined ? null :

    {managementText({ code: pkg.readOnlyReason }, t)}

    } @@ -635,7 +635,7 @@ function ConfirmDialog({ confirm, t, onConfirm, onCancel }: { readonly onConfirm: () => void readonly onCancel: () => void }): ReactNode { - const name = shortName(confirm.packageName) + const { title: name } = packageText({ name: confirm.packageName }, t) return ( ['t'] +const BUILTIN_COPY = new Map([ + ['@deepseek-ai/dsh-experimental-agent-team-profile', { + title: 'builtinAgentTeamTitle', description: 'builtinAgentTeamDescription', + }], + ['@deepseek-ai/dsh-experimental-agent-team-web-profile', { + title: 'builtinAgentTeamWebTitle', description: 'builtinAgentTeamWebDescription', + }], + ['@deepseek-ai/dsh-experimental-auto-review', { + title: 'builtinAutoReviewTitle', description: 'builtinAutoReviewDescription', + }], +]) + /** The sentence each of the Host's refusal codes reads as. */ const CODE_KEYS = { 'management-required': 'reasonManagementRequired', @@ -54,6 +66,19 @@ export function shortName(name: string): string { return unscoped.replace(/^dsh-(?:host-|client-)?/, '') } +/** + * Localize known built-in packages by exact npm name at render time. + * @param pkg - original package identity and optional metadata description. + * @param t - the manager's current translate function. + * @returns localized copy, or the package's short name and original description. + */ +export function packageText(pkg: Pick, t: Translate): { title: string; description: string | undefined } { + const keys = BUILTIN_COPY.get(pkg.name) + return keys === undefined + ? { title: shortName(pkg.name), description: pkg.description } + : { title: t(keys.title), description: t(keys.description) } +} + /** * The sentence one notice shows. * @param notice - the last action's outcome. diff --git a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx index 01283c6b06..7ac5613d74 100644 --- a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx +++ b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx @@ -7,16 +7,18 @@ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { PluginManagerPage } from '../src/client/PluginManagerPage.tsx' import type { PluginManagerPageProps } from '../src/client/PluginManagerPage.tsx' import { rowKey, type InstallState, type PackageRow, type PackageView, type PluginManagerState } from '../src/client/manager-store.ts' -import { en, type PluginManagerLocaleKey } from '../src/client/locales.ts' +import { en, zh, type PluginManagerLocaleKey } from '../src/client/locales.ts' afterEach(cleanup) -const t = ((key: PluginManagerLocaleKey, params?: Record): string => +const translate = (dict: typeof en): PluginManagerPageProps['t'] => ((key: PluginManagerLocaleKey, params?: Record): string => Object.entries(params ?? {}).reduce( (text, [name, value]) => text.replaceAll(`{${name}}`, value), - en[key], + dict[key], )) as PluginManagerPageProps['t'] +const t = translate(en) + function pkg(overrides: Partial = {}): PackageView { return { name: 'dsh-better-sidebar', @@ -74,8 +76,13 @@ function renderTab(state: Partial = {}) { ...actions, usePluginManager: bindSnapshotSelector(store), } as unknown as PluginManagerPageProps - render() - return { store, actions, set: (next: Partial) => { act(() => { store.set({ ...store.getSnapshot(), ...next }) }) } } + const { rerender } = render() + return { + store, + actions, + set: (next: Partial) => { act(() => { store.set({ ...store.getSnapshot(), ...next }) }) }, + setLanguage: (dict: typeof en) => { rerender() }, + } } describe('PluginManagerPage', () => { @@ -140,14 +147,60 @@ describe('PluginManagerPage', () => { const { actions } = renderTab({ packages: [pkg({ name: '@deepseek-ai/dsh-experimental-agent-team-profile', installed: false, optional: true, enabled: false })], }) - fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'experimental-agent-team-profile') })) + fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', en.builtinAgentTeamTitle) })) const detail = document.querySelector('[data-plugin-detail]') as HTMLElement expect(within(detail).getByText(en.statusOfficial)).toBeTruthy() - expect(within(detail).queryByRole('button', { name: en.uninstallLabel.replace('{name}', 'experimental-agent-team-profile') })).toBeNull() - fireEvent.click(within(detail).getByRole('switch', { name: en.enableToggle.replace('{name}', 'experimental-agent-team-profile') })) + expect(within(detail).queryByRole('button', { name: en.uninstallLabel.replace('{name}', en.builtinAgentTeamTitle) })).toBeNull() + fireEvent.click(within(detail).getByRole('switch', { name: en.enableToggle.replace('{name}', en.builtinAgentTeamTitle) })) expect(actions.setEnabled).toHaveBeenCalledExactlyOnceWith('@deepseek-ai/dsh-experimental-agent-team-profile', true) }) + it.each([ + ['agent-team-profile', 'builtinAgentTeamTitle', 'builtinAgentTeamDescription'], + ['agent-team-web-profile', 'builtinAgentTeamWebTitle', 'builtinAgentTeamWebDescription'], + ['auto-review', 'builtinAutoReviewTitle', 'builtinAutoReviewDescription'], + ] as const)('localizes %s across cards, details, switches, and uninstall confirmation', (suffix, titleKey, descriptionKey) => { + const name = `@deepseek-ai/dsh-experimental-${suffix}` + const { actions, set, setLanguage } = renderTab({ packages: [pkg({ name, description: 'Original metadata.' })] }) + const assertCard = (dict: typeof en) => { + expect(screen.getByRole('button', { name: dict.openDetail.replace('{name}', dict[titleKey]) }).textContent).toBe(dict[titleKey]) + expect(screen.getByText(dict[descriptionKey])).toBeTruthy() + expect(screen.getByRole('switch', { name: dict.enableToggle.replace('{name}', dict[titleKey]) })).toBeTruthy() + expect(screen.queryByText('Original metadata.')).toBeNull() + } + assertCard(en) + setLanguage(zh) + assertCard(zh) + fireEvent.click(screen.getByRole('switch', { name: zh.enableToggle.replace('{name}', zh[titleKey]) })) + expect(actions.setEnabled).toHaveBeenCalledExactlyOnceWith(name, false) + fireEvent.click(screen.getByRole('button', { name: zh.openDetail.replace('{name}', zh[titleKey]) })) + for (const dict of [zh, en]) { + setLanguage(dict) + expect(screen.getByRole('heading', { level: 3 }).textContent).toBe(dict[titleKey]) + expect(screen.getByText(dict[descriptionKey])).toBeTruthy() + expect(document.querySelector('[data-plugin-name]')?.textContent).toBe(name) + expect(screen.getByRole('switch', { name: dict.enableToggle.replace('{name}', dict[titleKey]) })).toBeTruthy() + expect(screen.getByRole('button', { name: dict.uninstallLabel.replace('{name}', dict[titleKey]) })).toBeTruthy() + } + fireEvent.click(screen.getByRole('button', { name: en.uninstallLabel.replace('{name}', en[titleKey]) })) + expect(actions.uninstall).toHaveBeenCalledExactlyOnceWith(name) + set({ confirm: { action: 'uninstall', packageName: name } }) + for (const dict of [en, zh]) { + setLanguage(dict) + expect(screen.getByRole('dialog', { name: dict.confirmUninstallTitle.replace('{name}', dict[titleKey]) })).toBeTruthy() + } + }) + + it('preserves metadata for another scope with the same short name', () => { + const name = '@acme/dsh-experimental-agent-team-profile' + const { setLanguage } = renderTab({ packages: [pkg({ name, description: 'Third-party description.' })] }) + setLanguage(zh) + fireEvent.click(screen.getByRole('button', { name: zh.openDetail.replace('{name}', 'experimental-agent-team-profile') })) + expect(screen.getByRole('heading', { level: 3 }).textContent).toBe('experimental-agent-team-profile') + expect(screen.getByText('Third-party description.')).toBeTruthy() + expect(document.querySelector('[data-plugin-name]')?.textContent).toBe(name) + }) + it('opens a guide under the field and drops an example into it', () => { const { actions } = renderTab({ install: { ...IDLE_INSTALL, open: true } }) expect(screen.queryByText(en.installGuideIntro)).toBeNull() From a139e6e9e1ef3cbce7c191682b85c0c86ebacce0 Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Wed, 16 Sep 2026 12:52:50 +0800 Subject: [PATCH 02/15] fix(ptc): preserve Electron Node mode when launching programs --- ...09-11-sandboxed-node-ptc-runtime.i18n.yaml | 4 +- .../2026-09-11-sandboxed-node-ptc-runtime.md | 2 + ...026-09-11-sandboxed-node-ptc-runtime.zh.md | 2 + apps/desktop/tests/ptc-runtime.spec.ts | 43 +++++++++++++++++++ .../ptc-runtime-node/README.i18n.yaml | 4 +- .../ptc-runtime/ptc-runtime-node/README.md | 2 +- .../ptc-runtime/ptc-runtime-node/README.zh.md | 2 +- .../ptc-runtime-node/src/environment.ts | 4 +- .../tests/host-failures.spec.ts | 2 + .../ptc-runtime-node/tests/runtime.spec.ts | 2 +- .../session/ptc-node-workspace/snapshot.yml | 2 + 11 files changed, 60 insertions(+), 9 deletions(-) create mode 100644 apps/desktop/tests/ptc-runtime.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.i18n.yaml index 90f1e4a1a0..844eb9d0c8 100644 --- a/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-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-11-sandboxed-node-ptc-runtime.md -2026-09-11-sandboxed-node-ptc-runtime.md: 242b3cfedb49b7ab60c47c6ee03005ddb821bb33 -2026-09-11-sandboxed-node-ptc-runtime.zh.md: d1ab47550e49129ee3e1fefbdfa54cda397374a3 +2026-09-11-sandboxed-node-ptc-runtime.md: 4db1920abc188af0e27ad38d6d88ac04998d3844 +2026-09-11-sandboxed-node-ptc-runtime.zh.md: 6adce26536804f4c66e013689c2f807daa81a359 diff --git a/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.md b/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.md index 242b3cfedb..4db1920abc 100644 --- a/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.md +++ b/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.md @@ -14,6 +14,8 @@ The [PTC foundation](../feature/2026-06-15-ptc.md) remains responsible for regis `dsh-ptc-runtime-node` runs each program in one fresh Node process. The host resolves execution choices, confines the launch through the same `ctx.sandbox` provider as Bash, and gives process lifetime to `ctx.subprocess`. The child evaluates erasable TypeScript with direct Node APIs, an empty model environment and host-provided asynchronous bindings. No worker or persistent kernel remains inside this provider. +The native startup allowlist preserves `ELECTRON_RUN_AS_NODE` through launch and child initialization while model-visible `process.env` stays empty. Desktop uses Electron as its Node executable; removing this selector launches Electron's application path instead of the PTC bootstrap. Sandbox permission changes cannot repair that launch mismatch. The macOS Desktop regression uses real Electron to verify binding writes, direct workspace writes, and rejection of writes outside the workspace under restricted policy. It requires an installed Electron binary; ordinary runtime tests cover environment filtering without that dependency. + ### Resolved inputs and policy `PtcRuntime.resolve(request)` validates supported options and supplies a complete `PtcRunSpec`; `run(spec)` does not introduce defaults. PTC passes the calling Session's cwd and resolved standing policy. Direct runtime callers receive deployment defaults through the same resolver. The filesystem and subprocess providers share one execution world, and bootstrap paths cross through the filesystem's explicit host-file mapping or a configured preinstalled bootstrap. diff --git a/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.zh.md b/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.zh.md index d1ab47550e..6adce26536 100644 --- a/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.zh.md @@ -14,6 +14,8 @@ Node worker 隔离 JavaScript 状态,但不应用调用 Session 的 OS 沙箱 `dsh-ptc-runtime-node` 在一个全新 Node 进程中运行每个程序。Host 解析执行选择,通过与 Bash 相同的 `ctx.sandbox` 提供方约束启动,并将进程生命周期交给 `ctx.subprocess`。子进程以直接 Node API、空模型环境和 Host 提供的异步绑定求值可擦除 TypeScript。本提供方不保留 worker 或持久内核。 +原生启动白名单在启动和子进程初始化期间保留 `ELECTRON_RUN_AS_NODE`,而模型可见的 `process.env` 仍为空。桌面端使用 Electron 作为 Node 可执行文件;删除此选择变量会启动 Electron 应用路径,而不是 PTC bootstrap。更改沙箱权限无法修复这一启动模式不匹配。macOS 桌面端回归测试使用真实 Electron 验证绑定写入、直接工作区写入以及受限策略对工作区外写入的拒绝。该测试需要已安装的 Electron 二进制文件;普通运行时测试无需此依赖即可覆盖环境过滤。 + ### 已解析输入与策略 `PtcRuntime.resolve(request)` 验证支持的选项并补全 `PtcRunSpec`;`run(spec)` 不引入默认值。PTC 传入调用 Session 的 cwd 与已解析常设策略。直接运行时调用方通过同一解析器取得部署默认值。文件系统与子进程提供方共享一个执行世界,bootstrap 路径通过文件系统的显式宿主文件映射或配置的预安装 bootstrap 传递。 diff --git a/apps/desktop/tests/ptc-runtime.spec.ts b/apps/desktop/tests/ptc-runtime.spec.ts new file mode 100644 index 0000000000..83645837e3 --- /dev/null +++ b/apps/desktop/tests/ptc-runtime.spec.ts @@ -0,0 +1,43 @@ +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { existsSync } from 'node:fs' +import { createRequire } from 'node:module' +import { homedir } from 'node:os' +import { dirname, join } from 'node:path' +import { Context } from '@deepseek-ai/cordis' +import { expect, it, onTestFinished, vi } from 'vitest' +import { mountRuntime } from '../../../packages/ptc-runtime/ptc-runtime-node/tests/setup.ts' + +const require = createRequire(import.meta.url) +const electronInstalled = existsSync(join(dirname(require.resolve('electron')), 'path.txt')) + +// Desktop's downloaded Electron binary is not installed by ordinary workspace dependency setup. +it.skipIf(process.platform !== 'darwin' || !electronInstalled).each(['workspace-write', 'danger-full-access'] as const)('runs PTC writes under Electron with %s', async (mode) => { + const electron = require('electron') as string + const root = await mkdtemp(join(homedir(), '.dsh-electron-ptc-')) + const ctx = new Context() + onTestFinished(async () => { + try { await ctx.fiber.dispose() } finally { + vi.unstubAllEnvs() + await rm(root, { recursive: true, force: true }) + } + }) + const cwd = join(root, 'workspace') + await mkdir(cwd) + vi.stubEnv('ELECTRON_RUN_AS_NODE', '1') + vi.stubEnv('DSH_TEST_RUNTIME_SECRET', 'must-not-inherit') + const runtime = await mountRuntime(ctx, { nodeExecutable: electron }, { mode, workspaceRoot: cwd }) + const result = await runtime.run(runtime.resolve({ + program: `await tools.write({}); + const fs = await import('node:fs/promises'); + await fs.writeFile('direct.txt', 'direct'); + let outside; + try { await fs.writeFile('../outside.txt', 'outside'); outside = true } catch (error) { if (error.code !== 'EPERM' && error.code !== 'EACCES') throw error; outside = false } + return { electron: Boolean(process.versions.electron), env: { ...process.env }, outside };`, + bindings: [{ global: 'tools', functions: { write: async () => { await writeFile(join(cwd, 'note.txt'), 'hello'); return null } } }], + })) + expect(result.error).toBeUndefined() + expect(result.value).toEqual({ electron: true, env: {}, outside: mode === 'danger-full-access' }) + expect(await readFile(join(cwd, 'note.txt'), 'utf8')).toBe('hello') + expect(await readFile(join(cwd, 'direct.txt'), 'utf8')).toBe('direct') + expect(existsSync(join(root, 'outside.txt'))).toBe(mode === 'danger-full-access') +}) diff --git a/packages/ptc-runtime/ptc-runtime-node/README.i18n.yaml b/packages/ptc-runtime/ptc-runtime-node/README.i18n.yaml index 7050c920bc..8ea1e46c78 100644 --- a/packages/ptc-runtime/ptc-runtime-node/README.i18n.yaml +++ b/packages/ptc-runtime/ptc-runtime-node/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/ptc-runtime/ptc-runtime-node/README.md -README.md: f8f7b5535ba8ed1594b898d410df04e24f5dc8ef -README.zh.md: dd4e33e70f05ae3917f128e33bf26af5d1a915c4 +README.md: b8047c5ec359bf0e7b55bc484c097dcdf14f137e +README.zh.md: d2d56a2c4b9798a1eb3296ada20d87cad2ba1f1b diff --git a/packages/ptc-runtime/ptc-runtime-node/README.md b/packages/ptc-runtime/ptc-runtime-node/README.md index f8f7b5535b..b8047c5ec3 100644 --- a/packages/ptc-runtime/ptc-runtime-node/README.md +++ b/packages/ptc-runtime/ptc-runtime-node/README.md @@ -85,7 +85,7 @@ The host owns policy, deadlines, binding lookup and process cleanup. The child o ### Launch and control -The host strips erasable types, resolves the executable and bootstrap in the configured execution world, awaits argv confinement through `ctx.sandbox`, then spawns through `ctx.subprocess`. Cancellation is checked again after confinement, so a provider returning after cancellation cannot start the program. After adopting the inherited control channel, the child retains only executable-search, Windows system, and temporary paths in its OS environment and replaces the program-visible `process.env` with an empty dictionary. Windows ACL setup receives the parent's distinct `TEMP` and `TMP` values for shared grant locks, then replaces both with its private directory before starting the program. These native paths keep nested process creation and native temporary-file APIs functional. The heap limit uses Node argv or a provider-created `NODE_OPTIONS` value for packaged executables; ambient loader and inspector flags are discarded. +The host strips erasable types, resolves the executable and bootstrap in the configured execution world, awaits argv confinement through `ctx.sandbox`, then spawns through `ctx.subprocess`. Cancellation is checked again after confinement, so a provider returning after cancellation cannot start the program. After adopting the inherited control channel, the child retains only executable-search, Windows system, and temporary paths plus Electron's `ELECTRON_RUN_AS_NODE` selector in its OS environment and replaces the program-visible `process.env` with an empty dictionary. Windows ACL setup receives the parent's distinct `TEMP` and `TMP` values for shared grant locks, then replaces both with its private directory before starting the program. These native paths keep nested process creation and native temporary-file APIs functional. The host also preserves the Electron selector when launching the child so the Desktop executable runs the Node bootstrap. The heap limit uses Node argv or a provider-created `NODE_OPTIONS` value for packaged executables; ambient loader and inspector flags are discarded. Length-framed JSON travels separately from stdout/stderr. The host bounds frames and queued writes, validates call identity and declared binding names before dispatch, and refuses invalid traffic. The child flushes its terminal frame and keeps the control channel open until the host closes it. After submitting that frame, it ignores later binding replies and sends no further program control messages. Output capture meters serialized logs plus the completion or diagnostic; fixed result-envelope fields and sandbox metadata are outside that ledger. diff --git a/packages/ptc-runtime/ptc-runtime-node/README.zh.md b/packages/ptc-runtime/ptc-runtime-node/README.zh.md index dd4e33e70f..d2d56a2c4b 100644 --- a/packages/ptc-runtime/ptc-runtime-node/README.zh.md +++ b/packages/ptc-runtime/ptc-runtime-node/README.zh.md @@ -85,7 +85,7 @@ Host 负责策略、截止时间、绑定查找和进程清理。子进程负责 ### 启动与控制 -Host 擦除可擦除类型,在配置的执行世界中解析可执行文件与 bootstrap,通过 `ctx.sandbox` 等待 argv 限制准备完成,再通过 `ctx.subprocess` 启动。限制准备完成后会再次检查取消状态,因此提供方在取消后返回也无法启动程序。接管继承的控制通道后,子进程在 OS 环境中只保留可执行文件搜索路径、Windows 系统路径和临时路径,并将程序可见的 `process.env` 替换为空字典。Windows ACL 初始化接收父进程各自的 `TEMP` 和 `TMP` 值以使用共享授权锁,然后在启动程序前将二者替换为私有目录。这些原生路径使嵌套进程创建和原生临时文件 API 仍可正常工作。堆上限通过 Node argv 或为打包可执行文件由提供方构造的 `NODE_OPTIONS` 值传递;环境中的加载器和调试器标志会被丢弃。 +Host 擦除可擦除类型,在配置的执行世界中解析可执行文件与 bootstrap,通过 `ctx.sandbox` 等待 argv 限制准备完成,再通过 `ctx.subprocess` 启动。限制准备完成后会再次检查取消状态,因此提供方在取消后返回也无法启动程序。接管继承的控制通道后,子进程在 OS 环境中只保留可执行文件搜索路径、Windows 系统路径、临时路径以及 Electron 的 `ELECTRON_RUN_AS_NODE` 选择变量,并将程序可见的 `process.env` 替换为空字典。Windows ACL 初始化接收父进程各自的 `TEMP` 和 `TMP` 值以使用共享授权锁,然后在启动程序前将二者替换为私有目录。这些原生路径使嵌套进程创建和原生临时文件 API 仍可正常工作。Host 启动子进程时也保留 Electron 选择变量,使桌面端可执行文件运行 Node bootstrap。堆上限通过 Node argv 或为打包可执行文件由提供方构造的 `NODE_OPTIONS` 值传递;环境中的加载器和调试器标志会被丢弃。 带长度分帧的 JSON 与 stdout/stderr 分开传输。Host 限制帧与排队写入,在分派前验证调用身份和已声明的绑定名,并拒绝无效通信。子进程刷新终态帧后仍保持控制通道打开,直到 Host 关闭通道。提交终态帧后,子进程忽略后续绑定回复,不再发送程序控制消息。输出捕获计量序列化日志加完成值或诊断;固定结果信封字段与沙箱元数据不计入该账本。 diff --git a/packages/ptc-runtime/ptc-runtime-node/src/environment.ts b/packages/ptc-runtime/ptc-runtime-node/src/environment.ts index 759f7fc3ca..3d2b2186ae 100644 --- a/packages/ptc-runtime/ptc-runtime-node/src/environment.ts +++ b/packages/ptc-runtime/ptc-runtime-node/src/environment.ts @@ -1,4 +1,4 @@ /** Startup variables required by native executables before model evaluation. */ -/** Native executable search, Windows system paths, and sandbox temporary paths retained in the OS environment. */ -export const STARTUP_ENVIRONMENT_NAMES: ReadonlySet = new Set(['PATH', 'PATHEXT', 'SYSTEMROOT', 'WINDIR', 'TEMP', 'TMP']) +/** Native paths and Electron's Node-mode selector retained in the OS environment. */ +export const STARTUP_ENVIRONMENT_NAMES: ReadonlySet = new Set(['PATH', 'PATHEXT', 'SYSTEMROOT', 'WINDIR', 'TEMP', 'TMP', 'ELECTRON_RUN_AS_NODE']) diff --git a/packages/ptc-runtime/ptc-runtime-node/tests/host-failures.spec.ts b/packages/ptc-runtime/ptc-runtime-node/tests/host-failures.spec.ts index 12d6f9f4c2..6ac19764d2 100644 --- a/packages/ptc-runtime/ptc-runtime-node/tests/host-failures.spec.ts +++ b/packages/ptc-runtime/ptc-runtime-node/tests/host-failures.spec.ts @@ -484,12 +484,14 @@ describe('Node runtime host failures', () => { onTestFinished(() => { vi.unstubAllEnvs() }) vi.stubEnv('TEMP', 'fixture-temp-first') vi.stubEnv('TMP', 'fixture-tmp-second') + vi.stubEnv('ELECTRON_RUN_AS_NODE', '1') vi.stubEnv('DSH_TEST_RUNTIME_SECRET', 'must-not-inherit') h.onBoot(() => { h.emit({ type: 'done' }) }) expect((await h.start()).error).toBeUndefined() const env = h.spawn.mock.calls[0]?.[0].env ?? {} expect(Object.hasOwn(env, 'TEMP')).toBe(false) expect(Object.hasOwn(env, 'TMP')).toBe(false) + expect(Object.hasOwn(env, 'ELECTRON_RUN_AS_NODE')).toBe(false) expect(Object.hasOwn(env, 'DSH_TEST_RUNTIME_SECRET')).toBe(true) expect(env.DSH_TEST_RUNTIME_SECRET).toBeUndefined() }) diff --git a/packages/ptc-runtime/ptc-runtime-node/tests/runtime.spec.ts b/packages/ptc-runtime/ptc-runtime-node/tests/runtime.spec.ts index ca01c14371..447faec9ce 100644 --- a/packages/ptc-runtime/ptc-runtime-node/tests/runtime.spec.ts +++ b/packages/ptc-runtime/ptc-runtime-node/tests/runtime.spec.ts @@ -59,7 +59,7 @@ describe('Node program process', () => { expect(value.env).toEqual([]) expect(value.status).toBe(0) expect(value.error).toBeNull() - const nativeKeys = ['PATH', 'PATHEXT', 'SYSTEMROOT', 'WINDIR', 'TEMP', 'TMP'] + const nativeKeys = ['PATH', 'PATHEXT', 'SYSTEMROOT', 'WINDIR', 'TEMP', 'TMP', 'ELECTRON_RUN_AS_NODE'] // CoreFoundation initializes this entry independently when a macOS child starts. if (process.platform === 'darwin') nativeKeys.push('__CF_USER_TEXT_ENCODING') expect(value.childKeys.filter(key => !nativeKeys.includes(key.toUpperCase()))).toEqual([]) diff --git a/snapshots/session/ptc-node-workspace/snapshot.yml b/snapshots/session/ptc-node-workspace/snapshot.yml index c7a1f6c783..094cf2fc11 100644 --- a/snapshots/session/ptc-node-workspace/snapshot.yml +++ b/snapshots/session/ptc-node-workspace/snapshot.yml @@ -10,6 +10,8 @@ replay: override: true platform: posix permission: workspace-write +environment: + ELECTRON_RUN_AS_NODE: '1' workspace: final: true parent: outside-temp From a66d81e33f7938bc678f7cda94101a666d69d9b6 Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Wed, 16 Sep 2026 13:11:08 +0800 Subject: [PATCH 03/15] fix(ptc): limit Electron selector to startup and budget native tests --- .../2026-09-11-sandboxed-node-ptc-runtime.i18n.yaml | 4 ++-- .../2026-09-11-sandboxed-node-ptc-runtime.md | 2 +- .../2026-09-11-sandboxed-node-ptc-runtime.zh.md | 2 +- apps/desktop/tests/ptc-runtime.spec.ts | 9 ++++++++- packages/ptc-runtime/ptc-runtime-node/README.i18n.yaml | 4 ++-- packages/ptc-runtime/ptc-runtime-node/README.md | 2 +- packages/ptc-runtime/ptc-runtime-node/README.zh.md | 2 +- packages/ptc-runtime/ptc-runtime-node/src/environment.ts | 4 ++-- packages/ptc-runtime/ptc-runtime-node/src/index.ts | 4 ++-- .../ptc-runtime/ptc-runtime-node/tests/runtime.spec.ts | 2 +- 10 files changed, 21 insertions(+), 14 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.i18n.yaml index 844eb9d0c8..f40d8780e9 100644 --- a/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-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-11-sandboxed-node-ptc-runtime.md -2026-09-11-sandboxed-node-ptc-runtime.md: 4db1920abc188af0e27ad38d6d88ac04998d3844 -2026-09-11-sandboxed-node-ptc-runtime.zh.md: 6adce26536804f4c66e013689c2f807daa81a359 +2026-09-11-sandboxed-node-ptc-runtime.md: f851775460c5bd01760d31552bde8c691807ec06 +2026-09-11-sandboxed-node-ptc-runtime.zh.md: 94f3a37eb702a97161c1d08cec531fcffb3460f2 diff --git a/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.md b/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.md index 4db1920abc..f851775460 100644 --- a/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.md +++ b/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.md @@ -14,7 +14,7 @@ The [PTC foundation](../feature/2026-06-15-ptc.md) remains responsible for regis `dsh-ptc-runtime-node` runs each program in one fresh Node process. The host resolves execution choices, confines the launch through the same `ctx.sandbox` provider as Bash, and gives process lifetime to `ctx.subprocess`. The child evaluates erasable TypeScript with direct Node APIs, an empty model environment and host-provided asynchronous bindings. No worker or persistent kernel remains inside this provider. -The native startup allowlist preserves `ELECTRON_RUN_AS_NODE` through launch and child initialization while model-visible `process.env` stays empty. Desktop uses Electron as its Node executable; removing this selector launches Electron's application path instead of the PTC bootstrap. Sandbox permission changes cannot repair that launch mismatch. The macOS Desktop regression uses real Electron to verify binding writes, direct workspace writes, and rejection of writes outside the workspace under restricted policy. It requires an installed Electron binary; ordinary runtime tests cover environment filtering without that dependency. +The host preserves `ELECTRON_RUN_AS_NODE` for child startup; the bootstrap removes it from the native environment before evaluation, and model-visible `process.env` stays empty. Nested Electron launches require their own explicit Node-mode selection. Desktop uses Electron as its Node executable; removing this selector launches Electron's application path instead of the PTC bootstrap. Sandbox permission changes cannot repair that launch mismatch. The macOS Desktop regression uses real Electron to verify binding writes, direct workspace writes, and rejection of writes outside the workspace under restricted policy. It requires an installed Electron binary; ordinary runtime tests cover environment filtering without that dependency. ### Resolved inputs and policy diff --git a/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.zh.md b/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.zh.md index 6adce26536..94f3a37eb7 100644 --- a/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.zh.md @@ -14,7 +14,7 @@ Node worker 隔离 JavaScript 状态,但不应用调用 Session 的 OS 沙箱 `dsh-ptc-runtime-node` 在一个全新 Node 进程中运行每个程序。Host 解析执行选择,通过与 Bash 相同的 `ctx.sandbox` 提供方约束启动,并将进程生命周期交给 `ctx.subprocess`。子进程以直接 Node API、空模型环境和 Host 提供的异步绑定求值可擦除 TypeScript。本提供方不保留 worker 或持久内核。 -原生启动白名单在启动和子进程初始化期间保留 `ELECTRON_RUN_AS_NODE`,而模型可见的 `process.env` 仍为空。桌面端使用 Electron 作为 Node 可执行文件;删除此选择变量会启动 Electron 应用路径,而不是 PTC bootstrap。更改沙箱权限无法修复这一启动模式不匹配。macOS 桌面端回归测试使用真实 Electron 验证绑定写入、直接工作区写入以及受限策略对工作区外写入的拒绝。该测试需要已安装的 Electron 二进制文件;普通运行时测试无需此依赖即可覆盖环境过滤。 +Host 在子进程启动时保留 `ELECTRON_RUN_AS_NODE`;bootstrap 在求值前将其从原生环境中删除,模型可见的 `process.env` 仍为空。嵌套启动 Electron 需要自行显式选择 Node 模式。桌面端使用 Electron 作为 Node 可执行文件;删除此选择变量会启动 Electron 应用路径,而不是 PTC bootstrap。更改沙箱权限无法修复这一启动模式不匹配。macOS 桌面端回归测试使用真实 Electron 验证绑定写入、直接工作区写入以及受限策略对工作区外写入的拒绝。该测试需要已安装的 Electron 二进制文件;普通运行时测试无需此依赖即可覆盖环境过滤。 ### 已解析输入与策略 diff --git a/apps/desktop/tests/ptc-runtime.spec.ts b/apps/desktop/tests/ptc-runtime.spec.ts index 83645837e3..757bfe95b2 100644 --- a/apps/desktop/tests/ptc-runtime.spec.ts +++ b/apps/desktop/tests/ptc-runtime.spec.ts @@ -1,3 +1,5 @@ +/** Desktop owns the Electron devDependency; the PTC helper supplies its real runtime providers. */ + import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' import { existsSync } from 'node:fs' import { createRequire } from 'node:module' @@ -10,8 +12,11 @@ import { mountRuntime } from '../../../packages/ptc-runtime/ptc-runtime-node/tes const require = createRequire(import.meta.url) const electronInstalled = existsSync(join(dirname(require.resolve('electron')), 'path.txt')) +// Both fixture-owned and shared-provider teardown await native process cleanup. +vi.setConfig({ hookTimeout: 30_000 }) + // Desktop's downloaded Electron binary is not installed by ordinary workspace dependency setup. -it.skipIf(process.platform !== 'darwin' || !electronInstalled).each(['workspace-write', 'danger-full-access'] as const)('runs PTC writes under Electron with %s', async (mode) => { +it.skipIf(process.platform !== 'darwin' || !electronInstalled).each(['workspace-write', 'danger-full-access'] as const)('runs PTC writes under Electron with %s', { timeout: 120_000 }, async (mode) => { const electron = require('electron') as string const root = await mkdtemp(join(homedir(), '.dsh-electron-ptc-')) const ctx = new Context() @@ -27,6 +32,8 @@ it.skipIf(process.platform !== 'darwin' || !electronInstalled).each(['workspace- vi.stubEnv('DSH_TEST_RUNTIME_SECRET', 'must-not-inherit') const runtime = await mountRuntime(ctx, { nodeExecutable: electron }, { mode, workspaceRoot: cwd }) const result = await runtime.run(runtime.resolve({ + // Bound startup failure before the outer test deadline, leaving time for managed cleanup. + timeoutMs: 30_000, program: `await tools.write({}); const fs = await import('node:fs/promises'); await fs.writeFile('direct.txt', 'direct'); diff --git a/packages/ptc-runtime/ptc-runtime-node/README.i18n.yaml b/packages/ptc-runtime/ptc-runtime-node/README.i18n.yaml index 8ea1e46c78..4c672e15a9 100644 --- a/packages/ptc-runtime/ptc-runtime-node/README.i18n.yaml +++ b/packages/ptc-runtime/ptc-runtime-node/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/ptc-runtime/ptc-runtime-node/README.md -README.md: b8047c5ec359bf0e7b55bc484c097dcdf14f137e -README.zh.md: d2d56a2c4b9798a1eb3296ada20d87cad2ba1f1b +README.md: 5196d6982c3bf8edda3b8b825e41db83f6994141 +README.zh.md: 16c9f8fc426b1f2d28eaa039ba259f08088664a8 diff --git a/packages/ptc-runtime/ptc-runtime-node/README.md b/packages/ptc-runtime/ptc-runtime-node/README.md index b8047c5ec3..5196d6982c 100644 --- a/packages/ptc-runtime/ptc-runtime-node/README.md +++ b/packages/ptc-runtime/ptc-runtime-node/README.md @@ -85,7 +85,7 @@ The host owns policy, deadlines, binding lookup and process cleanup. The child o ### Launch and control -The host strips erasable types, resolves the executable and bootstrap in the configured execution world, awaits argv confinement through `ctx.sandbox`, then spawns through `ctx.subprocess`. Cancellation is checked again after confinement, so a provider returning after cancellation cannot start the program. After adopting the inherited control channel, the child retains only executable-search, Windows system, and temporary paths plus Electron's `ELECTRON_RUN_AS_NODE` selector in its OS environment and replaces the program-visible `process.env` with an empty dictionary. Windows ACL setup receives the parent's distinct `TEMP` and `TMP` values for shared grant locks, then replaces both with its private directory before starting the program. These native paths keep nested process creation and native temporary-file APIs functional. The host also preserves the Electron selector when launching the child so the Desktop executable runs the Node bootstrap. The heap limit uses Node argv or a provider-created `NODE_OPTIONS` value for packaged executables; ambient loader and inspector flags are discarded. +The host strips erasable types, resolves the executable and bootstrap in the configured execution world, awaits argv confinement through `ctx.sandbox`, then spawns through `ctx.subprocess`. Cancellation is checked again after confinement, so a provider returning after cancellation cannot start the program. After adopting the inherited control channel, the child retains only executable-search, Windows system, and temporary paths in its OS environment and replaces the program-visible `process.env` with an empty dictionary. Windows ACL setup receives the parent's distinct `TEMP` and `TMP` values for shared grant locks, then replaces both with its private directory before starting the program. These native paths keep nested process creation and native temporary-file APIs functional. The host preserves `ELECTRON_RUN_AS_NODE` only for child startup so the Desktop executable runs the Node bootstrap; the bootstrap removes the selector before evaluating model code. The heap limit uses Node argv or a provider-created `NODE_OPTIONS` value for packaged executables; ambient loader and inspector flags are discarded. Length-framed JSON travels separately from stdout/stderr. The host bounds frames and queued writes, validates call identity and declared binding names before dispatch, and refuses invalid traffic. The child flushes its terminal frame and keeps the control channel open until the host closes it. After submitting that frame, it ignores later binding replies and sends no further program control messages. Output capture meters serialized logs plus the completion or diagnostic; fixed result-envelope fields and sandbox metadata are outside that ledger. diff --git a/packages/ptc-runtime/ptc-runtime-node/README.zh.md b/packages/ptc-runtime/ptc-runtime-node/README.zh.md index d2d56a2c4b..16c9f8fc42 100644 --- a/packages/ptc-runtime/ptc-runtime-node/README.zh.md +++ b/packages/ptc-runtime/ptc-runtime-node/README.zh.md @@ -85,7 +85,7 @@ Host 负责策略、截止时间、绑定查找和进程清理。子进程负责 ### 启动与控制 -Host 擦除可擦除类型,在配置的执行世界中解析可执行文件与 bootstrap,通过 `ctx.sandbox` 等待 argv 限制准备完成,再通过 `ctx.subprocess` 启动。限制准备完成后会再次检查取消状态,因此提供方在取消后返回也无法启动程序。接管继承的控制通道后,子进程在 OS 环境中只保留可执行文件搜索路径、Windows 系统路径、临时路径以及 Electron 的 `ELECTRON_RUN_AS_NODE` 选择变量,并将程序可见的 `process.env` 替换为空字典。Windows ACL 初始化接收父进程各自的 `TEMP` 和 `TMP` 值以使用共享授权锁,然后在启动程序前将二者替换为私有目录。这些原生路径使嵌套进程创建和原生临时文件 API 仍可正常工作。Host 启动子进程时也保留 Electron 选择变量,使桌面端可执行文件运行 Node bootstrap。堆上限通过 Node argv 或为打包可执行文件由提供方构造的 `NODE_OPTIONS` 值传递;环境中的加载器和调试器标志会被丢弃。 +Host 擦除可擦除类型,在配置的执行世界中解析可执行文件与 bootstrap,通过 `ctx.sandbox` 等待 argv 限制准备完成,再通过 `ctx.subprocess` 启动。限制准备完成后会再次检查取消状态,因此提供方在取消后返回也无法启动程序。接管继承的控制通道后,子进程在 OS 环境中只保留可执行文件搜索路径、Windows 系统路径和临时路径,并将程序可见的 `process.env` 替换为空字典。Windows ACL 初始化接收父进程各自的 `TEMP` 和 `TMP` 值以使用共享授权锁,然后在启动程序前将二者替换为私有目录。这些原生路径使嵌套进程创建和原生临时文件 API 仍可正常工作。Host 仅在子进程启动时保留 `ELECTRON_RUN_AS_NODE`,使桌面端可执行文件运行 Node bootstrap;bootstrap 在求值模型代码前删除该选择变量。堆上限通过 Node argv 或为打包可执行文件由提供方构造的 `NODE_OPTIONS` 值传递;环境中的加载器和调试器标志会被丢弃。 带长度分帧的 JSON 与 stdout/stderr 分开传输。Host 限制帧与排队写入,在分派前验证调用身份和已声明的绑定名,并拒绝无效通信。子进程刷新终态帧后仍保持控制通道打开,直到 Host 关闭通道。提交终态帧后,子进程忽略后续绑定回复,不再发送程序控制消息。输出捕获计量序列化日志加完成值或诊断;固定结果信封字段与沙箱元数据不计入该账本。 diff --git a/packages/ptc-runtime/ptc-runtime-node/src/environment.ts b/packages/ptc-runtime/ptc-runtime-node/src/environment.ts index 3d2b2186ae..759f7fc3ca 100644 --- a/packages/ptc-runtime/ptc-runtime-node/src/environment.ts +++ b/packages/ptc-runtime/ptc-runtime-node/src/environment.ts @@ -1,4 +1,4 @@ /** Startup variables required by native executables before model evaluation. */ -/** Native paths and Electron's Node-mode selector retained in the OS environment. */ -export const STARTUP_ENVIRONMENT_NAMES: ReadonlySet = new Set(['PATH', 'PATHEXT', 'SYSTEMROOT', 'WINDIR', 'TEMP', 'TMP', 'ELECTRON_RUN_AS_NODE']) +/** Native executable search, Windows system paths, and sandbox temporary paths retained in the OS environment. */ +export const STARTUP_ENVIRONMENT_NAMES: ReadonlySet = new Set(['PATH', 'PATHEXT', 'SYSTEMROOT', 'WINDIR', 'TEMP', 'TMP']) diff --git a/packages/ptc-runtime/ptc-runtime-node/src/index.ts b/packages/ptc-runtime/ptc-runtime-node/src/index.ts index 5700eaf89f..680366cf89 100644 --- a/packages/ptc-runtime/ptc-runtime-node/src/index.ts +++ b/packages/ptc-runtime/ptc-runtime-node/src/index.ts @@ -225,9 +225,9 @@ export class NodePtcRuntime extends PtcRuntime { // oxlint-disable-next-line typescript/no-unnecessary-condition -- Cancellation can settle during awaited confinement. if (settled) return await result.promise if (confined !== undefined) sandbox.enforcement = confined.enforcement - // Native launchers need executable search and Windows system paths before the child installs its model environment. + // Electron needs its Node-mode selector until bootstrap; the child then removes it with other ambient values. const env: NodeJS.ProcessEnv = Object.fromEntries(Object.keys(process.env) - .filter(key => !STARTUP_ENVIRONMENT_NAMES.has(key.toUpperCase())) + .filter(key => !STARTUP_ENVIRONMENT_NAMES.has(key.toUpperCase()) && key.toUpperCase() !== 'ELECTRON_RUN_AS_NODE') .map(key => [key, undefined])) if (packaged) { env.DSH_PTC_RUNTIME_NODE = '1' diff --git a/packages/ptc-runtime/ptc-runtime-node/tests/runtime.spec.ts b/packages/ptc-runtime/ptc-runtime-node/tests/runtime.spec.ts index 447faec9ce..ca01c14371 100644 --- a/packages/ptc-runtime/ptc-runtime-node/tests/runtime.spec.ts +++ b/packages/ptc-runtime/ptc-runtime-node/tests/runtime.spec.ts @@ -59,7 +59,7 @@ describe('Node program process', () => { expect(value.env).toEqual([]) expect(value.status).toBe(0) expect(value.error).toBeNull() - const nativeKeys = ['PATH', 'PATHEXT', 'SYSTEMROOT', 'WINDIR', 'TEMP', 'TMP', 'ELECTRON_RUN_AS_NODE'] + const nativeKeys = ['PATH', 'PATHEXT', 'SYSTEMROOT', 'WINDIR', 'TEMP', 'TMP'] // CoreFoundation initializes this entry independently when a macOS child starts. if (process.platform === 'darwin') nativeKeys.push('__CF_USER_TEXT_ENCODING') expect(value.childKeys.filter(key => !nativeKeys.includes(key.toUpperCase()))).toEqual([]) From 61c548e200ab00e6eceaf81bce3a819c195f393c Mon Sep 17 00:00:00 2001 From: turtle1999 Date: Wed, 16 Sep 2026 13:16:33 +0800 Subject: [PATCH 04/15] fix(sandbox): accept repeated effective permission modes --- .../feature/2026-07-06-sandbox.i18n.yaml | 4 ++-- .../implemented/feature/2026-07-06-sandbox.md | 6 ++--- .../feature/2026-07-06-sandbox.zh.md | 6 ++--- .../2026-09-16-sandbox-same-mode.i18n.yaml | 6 +++++ .../feature/2026-09-16-sandbox-same-mode.md | 23 +++++++++++++++++++ .../2026-09-16-sandbox-same-mode.zh.md | 23 +++++++++++++++++++ packages/fs/tool-fs/src/sandbox.ts | 3 ++- packages/fs/tool-fs/tests/tools.spec.ts | 11 +++++++++ packages/sandbox/sandbox/README.i18n.yaml | 4 ++-- packages/sandbox/sandbox/README.md | 4 ++-- packages/sandbox/sandbox/README.zh.md | 4 ++-- packages/sandbox/sandbox/src/escalation.ts | 17 ++++++-------- .../sandbox/sandbox/tests/escalation.spec.ts | 18 ++++++++++++--- packages/shell/tool-bash/README.i18n.yaml | 4 ++-- packages/shell/tool-bash/README.md | 2 +- packages/shell/tool-bash/README.zh.md | 2 +- packages/shell/tool-bash/tests/tools.spec.ts | 11 +++++++-- snapshots/acp/acp.snapshot.ts | 1 + snapshots/acp/fs-same-mode/input.json | 14 +++++++++++ snapshots/acp/fs-same-mode/session.v3.jsonl | 22 ++++++++++++++++++ snapshots/acp/fs-same-mode/snapshot.yml | 10 ++++++++ .../acp/fs-same-mode/stdout.expected.jsonl | 8 +++++++ .../workspace.expected/escalated.md | 1 + 23 files changed, 170 insertions(+), 34 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-09-16-sandbox-same-mode.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-09-16-sandbox-same-mode.md create mode 100644 .agents/notes/implemented/feature/2026-09-16-sandbox-same-mode.zh.md create mode 100644 snapshots/acp/fs-same-mode/input.json create mode 100644 snapshots/acp/fs-same-mode/session.v3.jsonl create mode 100644 snapshots/acp/fs-same-mode/snapshot.yml create mode 100644 snapshots/acp/fs-same-mode/stdout.expected.jsonl create mode 100644 snapshots/acp/fs-same-mode/workspace.expected/escalated.md diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml index 78fd3037e1..0b2036ca86 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.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-07-06-sandbox.md -2026-07-06-sandbox.md: 9f7139c8088df35ac87ddd66120dd8d6dc8e6e35 -2026-07-06-sandbox.zh.md: b8d8b1feb0db91431d0e71bea42299eafbc6856f +2026-07-06-sandbox.md: 45a1c9c028f127c67e56f4436088852f59e10140 +2026-07-06-sandbox.zh.md: 6ad51a039583949849c853fab2f7c2a72d6d9db7 diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.md index 9f7139c808..45a1c9c028 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.md @@ -86,7 +86,7 @@ The model sees the current effective file policy in the owner-derived `sandbox:p `ctx.sandboxPolicy.resolve()` stamps the complete execution policy — explicit escalation mode > session override > configured default, with `SessionHeader.cwd` > configured fallback root — before the executor runs. `SandboxBashExecutor.resolve()` retains that policy on the spec, or supplies the deployment fallback for a direct agentless caller, so `run()`/`start()` never read mutable session state. Per-process wrap facts are keyed by the returned `ShellProcess`; `onProcessDone()` receives spawn failure out of band from stderr classification and stamps that handle before `done` resolves, so overlapping processes retain their own modes and runner dialects. -When a confining executor is mounted, `bash` advertises paired `sandbox_permissions` and `justification` fields. The schema exposes the full closed escalation vocabulary because effective mode is per-session; execution rejects any target that is not strictly wider than that call's effective mode. Approval resolves before execution. `allowed-once` stamps the granted mode onto only that request, while `rejected`, `cancelled`, `unavailable`, a missing approval service, or a missing agent all fail closed with distinct results. No grant is persisted. +When a confining executor is mounted, `bash` advertises paired `sandbox_permissions` and `justification` fields. The schema exposes the full closed escalation vocabulary because effective mode is per-session; execution accepts a repeated effective mode without approval and rejects narrower or unsupported targets ([same-mode requests](2026-09-16-sandbox-same-mode.md)). Approval resolves before execution. `allowed-once` stamps the granted mode onto only that request, while `rejected`, `cancelled`, `unavailable`, a missing approval service, or a missing agent all fail closed with distinct results. No grant is persisted. Escalation is a same-turn retry of the denied command with the narrowest sufficient `sandbox_permissions` and a `justification`; the approval prompt is the consent step. It must be grounded in an actual denial, except when the session already observed the same denied access, and a disabled or rejected approval ends that command. The retry, approval decision, and result use existing tool and approval events. `dsh-tool-bash` owns the ask because the executor Service Definition has neither the agent nor call id required for user interaction. @@ -164,7 +164,7 @@ Each phase gets its full design when picked up, validated against the code at th What shipped pins — the tiers in Testing hold each: - A denied command retried with `sandbox_permissions` + `justification` prompts the user through the composed answerer chain; a grant runs THAT call under the wider mode (result facts say so) while every other call keeps its own effective mode; every non-grant outcome produces its distinct error text and executes nothing. -- The escalation fields exist exactly when the mounted executor confines; a request that is not strictly wider than the call's effective mode fails closed with its own text and prompts no one; a deployment with no ApprovalService fails escalating calls closed and leaves plain calls untouched. +- The escalation fields exist exactly when the mounted executor confines; a repeated effective mode succeeds without approval; narrower or unsupported targets fail closed without prompting; a deployment with no ApprovalService fails escalating calls closed and leaves plain calls untouched. - One sourced policy-context message states the complete current sandbox and approval policies atomically; the whole exchange — context messages, headers, knob events, approval notices, approvals, and results — reconstructs from the session log alone, with no policy bookkeeping events beyond the two knob events. - One preset selection records only changed knob values, while a no-op selection records nothing; the next pre-step upserts both current values atomically, and a committed sandbox switch is honored by the next call's stamp. - A resumed session's overrides enter its first new policy-context message with no catch-up state; a composition default changed while the process was down likewise appears in that message. @@ -182,7 +182,7 @@ Costs and accepted limits: - **Runner attribution uses an in-band protocol.** Exit status plus stderr cannot cryptographically identify the writer, so a confined child can mimic a fatal runner line and status to cause an availability/diagnostic false attribution. The conjunction and exact notice exclusion reduce accidental matches; this is not a sandbox bypass because the child is already confined. - **The launcher is a workspace dependency in source and an npm dependency after publication.** The main repository tests reviewed C source, native CI builds, and byte-pinned local tarballs together before publishing the same package family; the real-kernel e2e legs vouch for behavior through those installed bytes. - **The model may over-ask.** Escalating without denial grounding, or picking `danger-full-access` where `workspace-write` suffices: the description steers and the enum forces the ladder, but the human prompt is the actual gate; the `approval/asked` reasons make over-asking auditable, and a `prepend` policy answerer can auto-reject patterns a deployment never wants. -- **The advertised target set is static while the effective mode is per-session** (schemas are registry-global) — a session already at the widest mode is still offered the fields. Harmless by construction: the strict-wider check at execution, not the enum, is the safety boundary — a non-widening request fails with its own text and never prompts anyone. +- **The advertised target set is static while the effective mode is per-session** (schemas are registry-global) — a session already at the widest mode is still offered the fields. Harmless by construction: the strict-wider check at execution, not the enum, is the safety boundary — a same-mode request needs no approval, and narrower or unsupported targets fail without prompting. - **A granted escalation is not a working sandbox.** An unavailable backend still fails closed even for a granted escalation to a confining mode — at `confine()` when the platform has no chain or every probe fails, through the spawn channel when the selected executable cannot start, or through a structured rule when a started runner refuses — while a granted `danger-full-access` run never touches the provider at all: there the grant, not the probe, is the authority. - **Runtime-context history is append-only.** A policy switch appends a complete superseding snapshot after retained history, preserving the stable system-and-conversation prefix; unchanged state adds no message. - **Older policy snapshots remain in history.** Each full snapshot explicitly supersedes earlier runtime-context snapshots, so replay and compaction need only retain the latest materialized message. diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md index b8d8b1feb0..6ad51a0395 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md @@ -86,7 +86,7 @@ Landlock launcher 源码和包家族位于 `native/system`,与 harness 消费 `ctx.sandboxPolicy.resolve()` 在执行器运行前盖章完整执行策略——显式升级模式 > 会话覆盖 > 配置默认值,且 `SessionHeader.cwd` > 配置的后备根目录。`SandboxBashExecutor.resolve()` 在 spec 上保留该策略,或为直接的无 agent 调用方提供部署后备值,使 `run()`/`start()` 永不读取可变会话状态。每进程包装事实以返回的 `ShellProcess` 为键;`onProcessDone()` 会通过 stderr 分类之外的通道接收 spawn 失败,并在 `done` 结算前给该句柄盖章,因此重叠进程各自保留自己的模式和 runner 方言。 -当约束执行器被挂载时,`bash` 公布配对的 `sandbox_permissions` 和 `justification` 字段。schema 暴露完整的封闭升级词汇,因为有效模式是按会话的;执行拒绝任何不严格宽于该调用有效模式的目标。批准在执行之前解析。`allowed-once` 仅将授权模式盖章到该请求上,而 `rejected`、`cancelled`、`unavailable`、缺失的 approval 服务或缺失的 agent 都以各自不同的结果文本失败关闭。授权不持久化。 +当约束执行器被挂载时,`bash` 公布配对的 `sandbox_permissions` 和 `justification` 字段。schema 暴露完整的封闭升级词汇,因为有效模式是按会话的;执行允许重复有效模式且无需审批,拒绝更窄或不支持的目标([同模式请求](2026-09-16-sandbox-same-mode.zh.md))。批准在执行之前解析。`allowed-once` 仅将授权模式盖章到该请求上,而 `rejected`、`cancelled`、`unavailable`、缺失的 approval 服务或缺失的 agent 都以各自不同的结果文本失败关闭。授权不持久化。 升级是对被拒绝命令的同轮次重试,使用最窄的足够 `sandbox_permissions` 和一个 `justification`;批准提示词是同意步骤。它必须基于实际的拒绝,除非会话已观察到相同的被拒绝访问;禁用或被拒绝的批准终结该命令。重试、批准决策和结果使用既有的工具和批准事件。`dsh-tool-bash` 拥有请求动作,因为执行器 Service Definition 既没有 agent 也没有用户交互所需的 call id。 @@ -164,7 +164,7 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自能力边 已交付并固定的内容——测试中的各层级分别保障: - 被拒绝的命令以 `sandbox_permissions` + `justification` 重试时,通过组合的应答器链提示用户;授权使该次调用在更宽模式下运行(结果事实如此报告),而其他所有调用保持各自的有效模式;每种非授权结果产生各自不同的错误文本且不执行任何内容。 -- 升级字段恰好在已挂载的执行器约束时存在;不严格宽于调用有效模式的请求以自身文本失败关闭且不提示任何人;没有 ApprovalService 的部署对升级调用失败关闭,对普通调用不影响。 +- 升级字段恰好在已挂载的执行器约束时存在;重复有效模式无需审批即可成功;更窄或不支持的目标不提示任何人并失败关闭;没有 ApprovalService 的部署对升级调用失败关闭,对普通调用不影响。 - 一条带来源的策略上下文消息会以原子方式声明完整的当前沙箱策略与批准策略;整个交互——上下文消息、header、旋钮事件、批准通知、批准与结果——仅从会话日志即可重建,除两个旋钮事件外没有策略簿记事件。 - 一次 preset 选择只记录发生变化的旋钮值,而无操作的选择不记录任何内容;下一次 pre-step 会原子 upsert 两个当前值,已提交的沙箱切换由下一次调用的盖章兑现。 - 恢复会话的覆盖项会进入其首条新策略上下文消息,无需追赶状态;进程停止期间变更的组合默认值也会出现在该消息中。 @@ -182,7 +182,7 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自能力边 - **Runner 归因使用带内协议。** 退出状态与 stderr 无法以密码学方式识别写入者,因此受限子进程可以模仿 runner 的致命诊断行和状态,造成可用性或诊断误归因。多项证据的合取与精确通知排除减少了意外匹配;这不是沙箱绕过,因为子进程已经受到限制。 - **launcher 在源码中是 workspace 依赖,发布后是 NPM 依赖。** 主仓库会在发布同一个包家族之前,一起测试经审查的 C 源码、原生 CI 构建和字节固定的本地 tarball;真实内核 e2e 测试环节会验证这些安装字节的实际行为。 - **模型可能过度请求。** 在没有拒绝依据的情况下升级,或在 `workspace-write` 足够时选择 `danger-full-access`:描述引导且枚举强制阶梯,但人的提示词是实际门控;`approval/asked` 原因使过度请求可审计,且 `prepend` 策略应答器可以自动拒绝部署永远不想要的模式。 -- **公布的目标集是静态的,而有效模式是按会话的**(schema 是注册表全局的)——已处于最宽模式的会话仍被提供这些字段。构造上无害:执行时的严格放宽检查(而非枚举)是安全边界——非放宽请求以自身文本失败且不提示任何人。 +- **公布的目标集是静态的,而有效模式是按会话的**(schema 是注册表全局的)——已处于最宽模式的会话仍被提供这些字段。构造上无害:执行时的严格放宽检查(而非枚举)是安全边界——同模式请求无需审批,更窄或不支持的目标不提示任何人并失败。 - **授权的升级不等于可工作的沙箱。** 不可用的后端即使对授权升级到约束模式也仍然失败关闭——平台没有链或所有探测失败时在 `confine()` 阶段失败,所选可执行文件无法启动时通过 spawn 通道失败,已启动的 runner 拒绝时则通过结构化规则失败——而授权的 `danger-full-access` 运行根本不触及提供方:此时授权(而非探测)是权威。 - **运行时上下文历史仅追加。** 策略切换会在保留的历史之后追加一份用于取代先前快照的完整快照,从而保留稳定的系统与对话前缀;状态不变时不添加消息。 - **旧策略快照仍保留在历史中。** 每份完整快照都会明确取代更早的运行时上下文快照,因此回放与压缩(compaction)只需保留最新具体化的消息。 diff --git a/.agents/notes/implemented/feature/2026-09-16-sandbox-same-mode.i18n.yaml b/.agents/notes/implemented/feature/2026-09-16-sandbox-same-mode.i18n.yaml new file mode 100644 index 0000000000..426e7d8c80 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-16-sandbox-same-mode.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/feature/2026-09-16-sandbox-same-mode.md +2026-09-16-sandbox-same-mode.md: f1b7c3ae05103363f47f28fab1ae534a8a8b04c2 +2026-09-16-sandbox-same-mode.zh.md: 0bb04415230ec7a0f0f3fdd3c7f00bd7b76d9d9b diff --git a/.agents/notes/implemented/feature/2026-09-16-sandbox-same-mode.md b/.agents/notes/implemented/feature/2026-09-16-sandbox-same-mode.md new file mode 100644 index 0000000000..f1b7c3ae05 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-16-sandbox-same-mode.md @@ -0,0 +1,23 @@ +# Agent Note: Repeated sandbox modes need no approval + +Status: implemented + +English | [中文](2026-09-16-sandbox-same-mode.zh.md) + +## Problem + +Models can repeat `sandbox_permissions: danger-full-access` while that mode is already effective. Rejecting the call prevents authorized work without preventing any permission increase. + +## Decision + +`approveEscalation` returns the effective mode immediately when the requested mode matches it. Argument pairing remains mandatory at the tool. Wider modes still require approval; narrower and unsupported targets still fail. This partially supersedes the non-widening rejection in the [sandbox decision](2026-07-06-sandbox.md); its confinement and per-call approval decisions remain active. + +## Alternatives considered + +**Reject every non-widening request.** This makes a redundant argument fatal even though it asks for no additional permission. + +**Ask for approval again.** Existing permission is sufficient, so another prompt adds no authorization. + +## Consequences + +Bash and filesystem tools accept repeated effective modes without an approval service or agent. Shared unit tests cover both advertised targets, both tool consumers execute the repeated mode, and the `fs-same-mode` ACP snapshot verifies an unrestricted write with no approval events and checks the resulting file. diff --git a/.agents/notes/implemented/feature/2026-09-16-sandbox-same-mode.zh.md b/.agents/notes/implemented/feature/2026-09-16-sandbox-same-mode.zh.md new file mode 100644 index 0000000000..0bb0441523 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-16-sandbox-same-mode.zh.md @@ -0,0 +1,23 @@ +# Agent Note: 重复沙箱模式无需审批 + +Status: implemented + +[English](2026-09-16-sandbox-same-mode.md) | 中文 + +## 问题 + +模型可能在 `danger-full-access` 已生效时重复传入 `sandbox_permissions: danger-full-access`。拒绝该调用会阻止已获授权的工作,却没有阻止任何权限增加。 + +## 决策 + +当请求模式与生效模式相同时,`approveEscalation` 立即返回生效模式。工具仍要求参数成对出现。更宽模式仍需审批;更窄和不支持的目标仍然失败。这部分取代了[沙箱决策](2026-07-06-sandbox.zh.md)中的非放宽请求拒绝规则;其约束与逐调用审批决策仍然有效。 + +## 考虑过的替代方案 + +**拒绝所有非放宽请求。** 即使没有请求额外权限,冗余参数也会导致调用失败。 + +**再次请求审批。** 现有权限已经足够,再次提示不会增加授权。 + +## 影响 + +Bash 和文件系统工具无需审批服务或 agent 即可接受重复生效模式。共享单元测试覆盖两个已公布目标,两个工具消费方均执行重复模式,`fs-same-mode` ACP 快照验证没有审批事件的无限制写入并检查生成文件。 diff --git a/packages/fs/tool-fs/src/sandbox.ts b/packages/fs/tool-fs/src/sandbox.ts index 695a9fdfc9..c388d5f340 100644 --- a/packages/fs/tool-fs/src/sandbox.ts +++ b/packages/fs/tool-fs/src/sandbox.ts @@ -75,7 +75,8 @@ export class FsSandboxController { /** * The policy to stamp onto this mutation: an approved escalation grant (a * strictly wider retry resolved through `ctx.approval` before anything - * executes), else the session's standing mode. The calling session's cwd is + * executes), else the session's standing mode. Repeating the standing mode + * requires no approval. The calling session's cwd is * always carried as the workspace root. Validates the escalation argument * pairing first. * @param toolName - the mutating tool's name, for the approval audit trail. diff --git a/packages/fs/tool-fs/tests/tools.spec.ts b/packages/fs/tool-fs/tests/tools.spec.ts index 298bdd6a4d..b3747b7a21 100644 --- a/packages/fs/tool-fs/tests/tools.spec.ts +++ b/packages/fs/tool-fs/tests/tools.spec.ts @@ -943,6 +943,17 @@ describe('sandbox escalation API (write/edit)', () => { }]) }) + it.each(['workspace-write', 'danger-full-access'] as const)('writes under repeated %s without approval', async (mode) => { + const { ctx, fs } = await setupConfining() + const result = await call(ctx, 'write', { + file_path: 'a.txt', content: 'x', sandbox_permissions: mode, justification: 'use the current permissions', + }, escalationAgent([{ type: 'sandbox/mode', data: { mode } }])) + expect(result.isError).toBe(false) + expect(fs.stamped).toEqual([{ + mode, workspaceRoot: '/session-project', sessionId: SessionId('sess-fs-esc'), + }]) + }) + it('a rejected escalation fails closed with its own text and never mutates', async () => { const { ctx, fs } = await setupConfining({ approval: true }) ctx.on('approval/request', () => Promise.resolve('rejected' as const)) diff --git a/packages/sandbox/sandbox/README.i18n.yaml b/packages/sandbox/sandbox/README.i18n.yaml index 9b52d9ef7f..0dfbb88d80 100644 --- a/packages/sandbox/sandbox/README.i18n.yaml +++ b/packages/sandbox/sandbox/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/sandbox/sandbox/README.md -README.md: 88e873428115eb7a3880253b668a4de5b42d6e6d -README.zh.md: b0144d12395cf01667ef7c2045a1d5f722936a4d +README.md: 56cdd87c274cc8d951d4bc4e1388d7e985aa4a28 +README.zh.md: c09d7e31fca4825e370636a4c5d9648e145f6a54 diff --git a/packages/sandbox/sandbox/README.md b/packages/sandbox/sandbox/README.md index 88e8734281..56cdd87c27 100644 --- a/packages/sandbox/sandbox/README.md +++ b/packages/sandbox/sandbox/README.md @@ -63,7 +63,7 @@ Enforcement is reported per call: `full` means the backend governs every promise ### Denied calls and escalation -When a confined call is denied, the operation reports a denial marker naming the mode — `[sandbox: file access denied under mode]` — and, when the composition advertises escalation, an escalation hint. The model may retry the exact call once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a `justification`; the user sees one approval prompt and can allow once, reject, or cancel. The escalation must be strictly wider than the call's effective mode, and it applies to that one call only. +When a confined call is denied, the operation reports a denial marker naming the mode — `[sandbox: file access denied under mode]` — and, when the composition advertises escalation, an escalation hint. The model may retry the exact call once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a `justification`; the user sees one approval prompt and can allow once, reject, or cancel. A wider mode requires approval and applies to that one call only. Repeating the call's effective mode succeeds without approval; narrower targets remain invalid. ### Fail-closed behavior @@ -97,7 +97,7 @@ This section explains the design decisions behind the contract and points at the ### Escalation choreography -The ladder is a closed table — `read-only` may escalate to `workspace-write` or `danger-full-access`, `workspace-write` only to `danger-full-access` — checked at execution, never baked into a tool schema, whose enum stays the closed target vocabulary. [`approveEscalation`](src/escalation.ts) validates the `sandbox_permissions`/`justification` pairing, rejects non-widening requests without prompting a human, and maps every approval outcome to its own error before anything executes. +The ladder is a closed table — `read-only` may escalate to `workspace-write` or `danger-full-access`, `workspace-write` only to `danger-full-access` — checked at execution, never baked into a tool schema, whose enum stays the closed target vocabulary. [`approveEscalation`](src/escalation.ts) returns the current mode without approval when it is repeated, rejects narrower or unsupported targets, and requests approval for wider modes. Callers validate the `sandbox_permissions`/`justification` pairing first. ### Writable roots diff --git a/packages/sandbox/sandbox/README.zh.md b/packages/sandbox/sandbox/README.zh.md index b0144d1239..c09d7e31fc 100644 --- a/packages/sandbox/sandbox/README.zh.md +++ b/packages/sandbox/sandbox/README.zh.md @@ -63,7 +63,7 @@ kind: "package-reference" ### 被拒绝的调用与升权 -受限调用被拒绝时,操作会报告指明模式的拒绝标记——`[sandbox: file access denied under mode]`——组合声明升权能力时还会给出升权提示。模型可以用 `sandbox_permissions`(足以放行的最窄更宽模式)加 `justification` 重试一次完全相同的调用;用户会看到一次审批提示,可以选择允许一次、拒绝或取消。升权必须严格宽于调用的生效模式,且只作用于该次调用。 +受限调用被拒绝时,操作会报告指明模式的拒绝标记——`[sandbox: file access denied under mode]`——组合声明升权能力时还会给出升权提示。模型可以用 `sandbox_permissions`(足以放行的最窄更宽模式)加 `justification` 重试一次完全相同的调用;用户会看到一次审批提示,可以选择允许一次、拒绝或取消。更宽的模式需要审批,且只作用于该次调用。重复指定调用的生效模式无需审批即可成功;更窄的目标仍然无效。 ### 故障关闭行为 @@ -97,7 +97,7 @@ kind: "package-reference" ### 升权编排 -阶梯是封闭表——`read-only` 可升权到 `workspace-write` 或 `danger-full-access`,`workspace-write` 只能升权到 `danger-full-access`——在执行时检查,绝不写入工具 schema,schema 的枚举保持封闭的目标词汇。[`approveEscalation`](src/escalation.ts) 校验 `sandbox_permissions`/`justification` 配对、不提示人类就拒绝非加宽请求,并在任何执行前把每个审批结果映射到各自的错误。 +阶梯是封闭表——`read-only` 可升权到 `workspace-write` 或 `danger-full-access`,`workspace-write` 只能升权到 `danger-full-access`——在执行时检查,绝不写入工具 schema,schema 的枚举保持封闭的目标词汇。[`approveEscalation`](src/escalation.ts) 在请求重复当前模式时无需审批就返回该模式,拒绝更窄或不支持的目标,并为更宽模式请求审批。调用方先校验 `sandbox_permissions`/`justification` 配对。 ### 可写根目录 diff --git a/packages/sandbox/sandbox/src/escalation.ts b/packages/sandbox/sandbox/src/escalation.ts index 5cc180fe2b..f1cd91b58c 100644 --- a/packages/sandbox/sandbox/src/escalation.ts +++ b/packages/sandbox/sandbox/src/escalation.ts @@ -134,28 +134,25 @@ export interface EscalationRequest { requestedMode: string /** The model's one-sentence reason, shown verbatim to the user inside the audit reason. */ justification: string - /** The call's effective mode (session override ?? composition default) the request must strictly widen. */ + /** The call's effective mode (session override ?? composition default); repeating it needs no approval. */ effectiveMode: SandboxMode /** The family's noun for the escalated action in user-facing texts (`command` for bash, `operation` for fs). */ subject: string } /** - * Resolve a sandbox-escalation request BEFORE anything executes: check strict - * widening against the call's effective mode, then resolve the approval - * channel, then map every outcome — the ordered fail-closed sequence both - * enforcing families share. Returns the granted mode to stamp onto exactly - * this call; throws the distinct verbatim text for every other path (a - * non-widening request, a missing approval service, an agent-less execution, - * a rejection, a cancellation, an unanswerable ask) — the tool registry turns - * the throw into the call's isError result, and nothing has run. A - * non-widening request never prompts a human. + * Resolve a sandbox permission request before execution. Repeating the call's + * effective mode returns it without approval. A strictly wider mode requires + * approval and applies only to this call. Narrower or unsupported targets, + * missing approval services or agents for widening, and non-grant outcomes + * throw before execution. * @param request - the escalation to judge (see {@link EscalationRequest}). * @param approval - the approval ingredients the tool holds (see {@link EscalationApproval}). * @returns the granted mode, consumed by the one call that asked. */ export async function approveEscalation(request: EscalationRequest, approval: EscalationApproval): Promise { const { requestedMode: mode, effectiveMode, justification, subject } = request + if (mode === effectiveMode) return effectiveMode // Strict widening is an EXECUTION check against the call's effective mode — // deliberately not a schema constraint (the enum is the closed target // vocabulary; the effective mode is per-call truth). diff --git a/packages/sandbox/sandbox/tests/escalation.spec.ts b/packages/sandbox/sandbox/tests/escalation.spec.ts index 15810d09d5..3a3c886d03 100644 --- a/packages/sandbox/sandbox/tests/escalation.spec.ts +++ b/packages/sandbox/sandbox/tests/escalation.spec.ts @@ -81,13 +81,25 @@ describe('approveEscalation', () => { expect(seen[0]?.reason).toBe('escalate sandbox to workspace-write: the user asked to write in the workspace') }) - it('a non-widening request fails closed with its own text and never asks', async () => { + it.each(ESCALATION_TARGETS)('repeating %s succeeds without asking for approval', async (mode) => { + const seen: unknown[] = [] + const request = req({ requestedMode: mode, effectiveMode: mode }) + await expect(approveEscalation(request, ingredients({ approver: approver('rejected', r => seen.push(r)) }))) + .resolves.toBe(mode) + expect(seen).toEqual([]) + await expect(approveEscalation(request, ingredients({ approver: undefined, agent: undefined }))) + .resolves.toBe(mode) + }) + + it('a narrower or unsupported target fails closed without asking', async () => { const seen: unknown[] = [] const spy = ingredients({ approver: approver('allowed-once', r => seen.push(r)) }) - await expect(approveEscalation(req({ requestedMode: 'read-only' }), spy)) - .rejects.toThrow(/not strictly wider than this call's current "read-only" mode/) + await expect(approveEscalation(req({ requestedMode: 'read-only', effectiveMode: 'workspace-write' }), spy)) + .rejects.toThrow(/not strictly wider than this call's current "workspace-write" mode/) await expect(approveEscalation(req({ requestedMode: 'workspace-write', effectiveMode: 'danger-full-access' as never }), spy)) .rejects.toThrow(/not strictly wider/) + await expect(approveEscalation(req({ requestedMode: 'unknown-mode' }), spy)) + .rejects.toThrow(/not strictly wider/) expect(seen).toEqual([]) }) diff --git a/packages/shell/tool-bash/README.i18n.yaml b/packages/shell/tool-bash/README.i18n.yaml index e25b5d3428..188434e067 100644 --- a/packages/shell/tool-bash/README.i18n.yaml +++ b/packages/shell/tool-bash/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/shell/tool-bash/README.md -README.md: 43c70d7ebc8eb13f28c4e4576dfde8cb2402b775 -README.zh.md: e38e7afa6b7a534fe29d1022fc2797f5a98651b0 +README.md: 3e01a8b800b5156feecc7e36c45f3490f9abdfc0 +README.zh.md: 91eb88c4b08eb515a0fd5f31257d33b6e3033ab9 diff --git a/packages/shell/tool-bash/README.md b/packages/shell/tool-bash/README.md index 43c70d7ebc..3e01a8b800 100644 --- a/packages/shell/tool-bash/README.md +++ b/packages/shell/tool-bash/README.md @@ -60,7 +60,7 @@ Passing `run_in_background: true` admits a job and returns its id immediately; c ### Sandboxed execution and escalation -When the mounted executor confines commands (for example `dsh-bash-sandbox`), a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a command failure. The model may then retry the exact same command once in the same turn with `sandbox_permissions` (the narrowest wider mode that suffices) and a one-sentence `justification`; the approval prompt raised by that retry is how the user consents. Escalation is never speculative: a request with no real prior denial, or one that is not strictly wider than the current mode, fails closed without running anything, and a rejected escalation is final for that command. +When the mounted executor confines commands (for example `dsh-bash-sandbox`), a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a command failure. The model may then retry the exact same command once in the same turn with `sandbox_permissions` (the narrowest wider mode that suffices) and a one-sentence `justification`; the approval prompt raised by that retry is how the user consents. Request wider access only after a real denial; a rejected escalation is final for that command. Repeating the current mode runs without approval, while a narrower target fails before execution. ### What can go wrong diff --git a/packages/shell/tool-bash/README.zh.md b/packages/shell/tool-bash/README.zh.md index e38e7afa6b..91eb88c4b0 100644 --- a/packages/shell/tool-bash/README.zh.md +++ b/packages/shell/tool-bash/README.zh.md @@ -60,7 +60,7 @@ kind: "package-reference" ### 沙箱执行与升权 -当已挂载的执行器约束命令(例如 `dsh-bash-sandbox`)时,被阻止的文件操作会报告为 `[sandbox: file access denied under mode]`——这是策略拒绝,不是命令失败。模型随后可以在同一轮次中用 `sandbox_permissions`(满足需要的最窄更宽模式)与一句 `justification` 重试完全相同的命令一次;该重试引发的审批提示就是用户同意的方式。升权绝不能预先推测:没有真实拒绝依据的请求,或没有严格宽于当前模式的请求,都会直接失败且不执行任何操作;被拒绝的升权对该命令即为最终结果。 +当已挂载的执行器约束命令(例如 `dsh-bash-sandbox`)时,被阻止的文件操作会报告为 `[sandbox: file access denied under mode]`——这是策略拒绝,不是命令失败。模型随后可以在同一轮次中用 `sandbox_permissions`(满足需要的最窄更宽模式)与一句 `justification` 重试完全相同的命令一次;该重试引发的审批提示就是用户同意的方式。只有发生真实拒绝后才请求更宽权限;被拒绝的升权对该命令即为最终结果。重复当前模式无需审批即可执行,更窄目标则在执行前失败。 ### 可能出什么问题 diff --git a/packages/shell/tool-bash/tests/tools.spec.ts b/packages/shell/tool-bash/tests/tools.spec.ts index 1b7ab406e7..34697d37b6 100644 --- a/packages/shell/tool-bash/tests/tools.spec.ts +++ b/packages/shell/tool-bash/tests/tools.spec.ts @@ -619,14 +619,14 @@ describe('sandbox escalation through the generic task producer', () => { } }) - it('rejects injected escalation without a sandbox and non-widening escalation without prompting', async () => { + it('rejects injected escalation without a sandbox and narrower escalation without prompting', async () => { const plain = await setup() expect(text(await call(plain, 'bash', escalate))).toContain('not available in this composition') const { ctx } = await setupSandboxed(true) const prompted = vi.fn() ctx.on('approval/request', () => { prompted(); return Promise.resolve('allowed-once') }) - const result = await call(ctx, 'bash', { ...escalate, sandbox_permissions: 'workspace-write' }, sandboxAgent('workspace-write')) + const result = await call(ctx, 'bash', { ...escalate, sandbox_permissions: 'workspace-write' }, sandboxAgent('danger-full-access')) expect(text(result)).toContain('not strictly wider') expect(prompted).not.toHaveBeenCalled() @@ -639,6 +639,13 @@ describe('sandbox escalation through the generic task producer', () => { expect(text(await call(ctx, 'bash', escalate, malformed))).toContain('not strictly wider') }) + it.each(['workspace-write', 'danger-full-access'] as const)('runs a repeated %s request without approval', async (mode) => { + const { ctx, bash } = await setupSandboxed() + const result = await call(ctx, 'bash', { ...escalate, sandbox_permissions: mode }, sandboxAgent(mode)) + expect(result.isError).toBe(false) + expect(bash.modes).toEqual([mode]) + }) + it('fails closed when approval cannot be routed', async () => { const withoutService = await setupSandboxed() expect(text(await call(withoutService.ctx, 'bash', escalate, sandboxAgent()))).toContain('no approval service') diff --git a/snapshots/acp/acp.snapshot.ts b/snapshots/acp/acp.snapshot.ts index 91ed36c539..1cde2b7a68 100644 --- a/snapshots/acp/acp.snapshot.ts +++ b/snapshots/acp/acp.snapshot.ts @@ -35,6 +35,7 @@ const controllerCases: readonly { { name: 'escalation-approved', hasModelTurn: true }, { name: 'escalation-rejected', hasModelTurn: true }, { name: 'fs-escalation-approved', hasModelTurn: true }, + { name: 'fs-same-mode', hasModelTurn: true }, { name: 'image-compaction', hasModelTurn: true, diff --git a/snapshots/acp/fs-same-mode/input.json b/snapshots/acp/fs-same-mode/input.json new file mode 100644 index 0000000000..dc3282b89b --- /dev/null +++ b/snapshots/acp/fs-same-mode/input.json @@ -0,0 +1,14 @@ +{ + "steps": [ + { + "op": "initialize" + }, + { + "op": "newSession" + }, + { + "op": "prompt", + "text": "Use the write tool to create escalated.md containing exactly the single line: escalated. Set sandbox_permissions to danger-full-access and justification to 'use the current permissions'. The session already uses danger-full-access. After the result, reply with exactly DONE." + } + ] +} diff --git a/snapshots/acp/fs-same-mode/session.v3.jsonl b/snapshots/acp/fs-same-mode/session.v3.jsonl new file mode 100644 index 0000000000..c05fa31b87 --- /dev/null +++ b/snapshots/acp/fs-same-mode/session.v3.jsonl @@ -0,0 +1,22 @@ +{"type":"session","version":3,"id":"{{session:1}}","createdAt":1784045702340,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0} +{"type":"permission/preset","data":{"preset":"danger-full-access"}} +{"type":"sandbox/mode","data":{"mode":"danger-full-access"}} +{"type":"approval/policy","data":{"policy":"never"}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use the write tool to create escalated.md containing exactly the single line: escalated. Set sandbox_permissions to danger-full-access and justification to 'use the current permissions'. The session already uses danger-full-access. After the result, reply with exactly DONE."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"}]}} +{"type":"turn/start","data":{"turn":1}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","data":{"turn":1,"step":1}} +{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{message:2}}"}},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Use the write tool to create escalated.md containing exactly the single line: escalated. Set sandbox_permissions to danger-full-access and justification to 'use the current permissions'. The session already uses danger-full-access. After the result, reply with exactly DONE."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"{{message:3}}"},"surfaceOp":"append"} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"session/title","data":{"title":"Use the write tool to","messageSeqs":[8],"source":{"kind":"fallback"}}} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to create a file using the write tool with sandbox_permissions. Let me do that."},{"type":"tool-call","id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","arguments":"{\"file_path\": \"escalated.md\", \"content\": \"escalated\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"use the current permissions\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:4}}"},"usage":{"inputTokens":3871,"outputTokens":132,"cacheReadTokens":0,"reasoningTokens":23},"stream":[{"type":"chunk","time":1789535738230,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1789535738230,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," user"," wants"," me"," to"," create"," a"," file"," using"," the"," write"," tool"," with"," sand","box","_per","missions","."," Let"," me"," do"," that","."]},{"type":"chunk","time":1789535738230,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1789535738230,"index":1,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","args":["","{","\"","file","_path","\"",": ","\"","es","cal","ated",".md","\"",", ","\"","content","\"",": ","\"","es","cal","ated","\"",", ","\"","sand","box","_per","missions","\"",": ","\"","danger","-full","-access","\"",", ","\"","just","ification","\"",": ","\"","the"," user"," asked"," to"," escalate"," this"," write","\"","}"]},{"type":"chunk","time":1789535738231,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to create a file using the write tool with sandbox_permissions. Let me do that."}}},{"type":"chunk","time":1789535738231,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","arguments":"{\"file_path\": \"escalated.md\", \"content\": \"escalated\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"use the current permissions\"}"}}},{"type":"chunk","time":1789535738231,"chunk":{"type":"usage","usage":{"inputTokens":3871,"outputTokens":132,"cacheReadTokens":0,"reasoningTokens":23}}},{"type":"chunk","time":1789535738231,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","arguments":"{\"file_path\": \"escalated.md\", \"content\": \"escalated\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"use the current permissions\"}"}} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_Fnymmavpr4klMDy4Fdej3227"},"content":[{"type":"tool-result","toolCallId":"call_00_Fnymmavpr4klMDy4Fdej3227","content":[{"type":"text","text":"{{cwd}}/escalated.md\nfile\n\nCreated file\n"}],"isError":false}],"role":"user","id":"{{message:5}}"},"meta":{"diffs":[]}},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":1}} +{"type":"step/start","data":{"turn":1,"step":2}} +{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The file was created successfully. The user asked me to reply with exactly the single word DONE."},{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:6}}"},"usage":{"inputTokens":107,"outputTokens":23,"cacheReadTokens":3968,"reasoningTokens":20},"stream":[{"type":"chunk","time":1789535738266,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1789535738266,"index":0,"dt":[0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," file"," was"," created"," successfully","."," The"," user"," asked"," me"," to"," reply"," with"," exactly"," the"," single"," word"," D","ONE","."]},{"type":"chunk","time":1789535738267,"chunk":{"type":"block-start","index":1,"blockType":"text"}},{"type":"text-chunks","time0":1789535738267,"index":1,"dt":[0],"texts":["D","ONE"]},{"type":"chunk","time":1789535738267,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The file was created successfully. The user asked me to reply with exactly the single word DONE."}}},{"type":"chunk","time":1789535738267,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}},{"type":"chunk","time":1789535738267,"chunk":{"type":"usage","usage":{"inputTokens":107,"outputTokens":23,"cacheReadTokens":3968,"reasoningTokens":20}}},{"type":"chunk","time":1789535738267,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":2}} +{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/snapshots/acp/fs-same-mode/snapshot.yml b/snapshots/acp/fs-same-mode/snapshot.yml new file mode 100644 index 0000000000..6fc0ceca16 --- /dev/null +++ b/snapshots/acp/fs-same-mode/snapshot.yml @@ -0,0 +1,10 @@ +version: 1 +scenario: fs-same-mode +profile: acp +composition: acp-default +recording: authored +header: + class: acp-default +permission: danger-full-access +workspace: + final: true diff --git a/snapshots/acp/fs-same-mode/stdout.expected.jsonl b/snapshots/acp/fs-same-mode/stdout.expected.jsonl new file mode 100644 index 0000000000..a9d346eee2 --- /dev/null +++ b/snapshots/acp/fs-same-mode/stdout.expected.jsonl @@ -0,0 +1,8 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"mcpCapabilities":{"http":true},"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false},"sessionCapabilities":{"close":{},"list":{},"resume":{}}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-flash\"]","options":[{"group":"deepseek-official","name":"DeepSeek","options":[{"value":"[\"deepseek-official\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek-official\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"The user wants me to create a file using the write tool with sandbox_permissions. Let me do that."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_Fnymmavpr4klMDy4Fdej3227","title":"write","kind":"other","status":"in_progress","rawInput":{"file_path":"escalated.md","content":"escalated","sandbox_permissions":"danger-full-access","justification":"use the current permissions"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_Fnymmavpr4klMDy4Fdej3227","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{{cwd}}/escalated.md\nfile\n\nCreated file\n"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"The file was created successfully. The user asked me to reply with exactly the single word DONE."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"DONE"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/snapshots/acp/fs-same-mode/workspace.expected/escalated.md b/snapshots/acp/fs-same-mode/workspace.expected/escalated.md new file mode 100644 index 0000000000..3957fd209b --- /dev/null +++ b/snapshots/acp/fs-same-mode/workspace.expected/escalated.md @@ -0,0 +1 @@ +escalated \ No newline at end of file From aea150a17ede800f7065a9e485cde32b2193af1a Mon Sep 17 00:00:00 2001 From: turtle1999 Date: Wed, 16 Sep 2026 13:35:31 +0800 Subject: [PATCH 05/15] test(pwsh): align repeated sandbox mode expectations --- packages/shell/tool-pwsh/tests/tools.spec.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/shell/tool-pwsh/tests/tools.spec.ts b/packages/shell/tool-pwsh/tests/tools.spec.ts index b0895adc7e..34e095d49c 100644 --- a/packages/shell/tool-pwsh/tests/tools.spec.ts +++ b/packages/shell/tool-pwsh/tests/tools.spec.ts @@ -623,14 +623,14 @@ describe('sandbox escalation through ctx.approval', () => { expect(schema.parameters.properties).not.toHaveProperty('sandbox_permissions') }) - it('rejects injected escalation without a sandbox and non-widening escalation without prompting', async () => { + it('rejects injected escalation without a sandbox and narrower escalation without prompting', async () => { const plain = await setup() expect(text(await call(plain.ctx, 'pwsh', escalate))).toContain('not available in this composition') const { ctx } = await setupSandboxed(true) const prompted = vi.fn() ctx.on('approval/request', () => { prompted(); return Promise.resolve('allowed-once') }) - const result = await call(ctx, 'pwsh', { ...escalate, sandbox_permissions: 'workspace-write' }, sandboxAgent('workspace-write')) + const result = await call(ctx, 'pwsh', { ...escalate, sandbox_permissions: 'workspace-write' }, sandboxAgent('danger-full-access')) expect(text(result)).toContain('not strictly wider') expect(prompted).not.toHaveBeenCalled() @@ -642,6 +642,13 @@ describe('sandbox escalation through ctx.approval', () => { expect(text(await call(ctx, 'pwsh', escalate, malformed))).toContain('not strictly wider') }) + it.each(['workspace-write', 'danger-full-access'] as const)('runs a repeated %s request without approval', async (mode) => { + const { ctx, bash } = await setupSandboxed() + const result = await call(ctx, 'pwsh', { ...escalate, sandbox_permissions: mode }, sandboxAgent(mode)) + expect(result.isError).toBe(false) + expect(bash.modes).toEqual([mode]) + }) + it('fails closed when approval cannot be routed', async () => { const withoutService = await setupSandboxed() expect(text(await call(withoutService.ctx, 'pwsh', escalate, sandboxAgent()))).toContain('no approval service') From 73dcae2915192262138d94765dafb5f8f2300f2e Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Tue, 15 Sep 2026 16:30:15 +0800 Subject: [PATCH 06/15] fix(desktop,client): attach workspace folder dialogs to the application window --- .../2026-09-10-desktop-web-wrapper.i18n.yaml | 4 +- .../2026-09-10-desktop-web-wrapper.md | 4 + .../2026-09-10-desktop-web-wrapper.zh.md | 4 + apps/desktop/README.i18n.yaml | 4 +- apps/desktop/README.md | 2 + apps/desktop/README.zh.md | 2 + apps/desktop/src/directory-picker.ts | 32 ++++++++ apps/desktop/src/ipc.ts | 1 + apps/desktop/src/main.ts | 3 + apps/desktop/src/preload-app.ts | 5 +- apps/desktop/tests/directory-picker.spec.ts | 80 +++++++++++++++++++ apps/desktop/tests/preload-app.spec.ts | 18 ++++- .../README.i18n.yaml | 4 +- .../ui-directory-picker-native/README.md | 2 + .../ui-directory-picker-native/README.zh.md | 2 + .../src/client/flow.ts | 8 +- .../src/client/index.ts | 16 ++-- .../tests/client-flow.client.spec.tsx | 23 +++++- 18 files changed, 191 insertions(+), 23 deletions(-) create mode 100644 apps/desktop/src/directory-picker.ts create mode 100644 apps/desktop/tests/directory-picker.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.i18n.yaml index e89314e4be..64c94da391 100644 --- a/.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.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-10-desktop-web-wrapper.md -2026-09-10-desktop-web-wrapper.md: 13b2a36643198f649d6c4eb56df3e01aa98b50ac -2026-09-10-desktop-web-wrapper.zh.md: de1f5e671c88ea03d6bd36e88692c81ff6f58404 +2026-09-10-desktop-web-wrapper.md: ab3a1d653107fa1b11ece01dc5e7b8b9adbe0bde +2026-09-10-desktop-web-wrapper.zh.md: 08080a17f37d550a06e2e4634bf80bfbd304261d diff --git a/.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.md b/.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.md index 13b2a36643..ab3a1d6531 100644 --- a/.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.md +++ b/.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.md @@ -26,8 +26,12 @@ Shared `initProfile` creates missing profile files and preserves existing conten This partially supersedes the private composition and portless transport in the [packaging decision](2026-08-25-electron-desktop-packaging-and-updates.md). That design avoided listening ports and used framed byte pipes to avoid Base64 expansion and cross-version V8 serialization. Shared HTTP gives up the portless guarantee and assigns serving and authentication to the existing Web implementation. Release identity, signing, process ownership, and native shell features remain active decisions. +Native directory selection in the local application uses a narrow preload IPC call to Electron’s window-owned dialog. Main admits only the current application window’s main frame at `dsh-app://app`; shell, remote, and child frames cannot request it. Concurrent requests share the pending dialog and destroyed windows discard selections. Web backend selection and Host browse remain shared. + ## Alternatives considered +**Use the Host OS chooser in Electron.** The Host’s macOS AppleScript dialog has no Electron parent window and cannot reliably follow application focus. Electron owns the local dialog while Web keeps its Host chooser; cancellation and errors do not launch a second chooser. + **Maintain a second backend composition and carrier.** This permits a portless application, but every Web route, reload behavior, authentication change, and stream capability needs a Desktop implementation or explicit omission. Reintroduction requires a desktop product requirement that cannot use the Web implementation and justifies that continuing cost. **Merge CLI and Desktop plugin installations.** Shared boot code does not require shared executable dependencies. Separate installations allow independently qualified releases and plugin versions while their existing data owners govern shared sessions and settings. diff --git a/.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.zh.md b/.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.zh.md index de1f5e671c..08080a17f3 100644 --- a/.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.zh.md @@ -26,8 +26,12 @@ App-boot 负责已安装依赖发现、安装目录优先的 bundle 声明解析 本记录部分取代[打包决策](2026-08-25-electron-desktop-packaging-and-updates.zh.md)中的私有组合与无端口传输。该设计避免监听端口,并使用分帧字节管道避免 Base64 膨胀与跨版本 V8 序列化。共享 HTTP 放弃无端口保证,将服务与认证交给已有 Web 实现。发布身份、签名、进程归属及原生壳功能仍是有效决策。 +本地应用的原生目录选择通过窄 preload IPC 调用 Electron 的窗口所属对话框。Main 仅接受当前应用窗口中位于 `dsh-app://app` 的主框架请求;shell、远程页面和子框架均不能调用。并发请求共用待完成的对话框,窗口销毁后丢弃选择结果。Web 后端选择和 Host 浏览仍由共享实现负责。 + ## Alternatives considered +**在 Electron 中使用 Host 操作系统选择器。** Host 的 macOS AppleScript 对话框没有 Electron 父窗口,无法可靠跟随应用焦点。Electron 负责本地对话框,Web 保留 Host 选择器;取消和错误不会启动第二个选择器。 + **维护第二套后端组合与传输。** 这允许应用不监听端口,但每项 Web 路由、重载行为、认证变化和流式能力都需要 Desktop 实现或明确省略。只有无法使用 Web 实现、且足以承担持续维护成本的桌面产品需求,才支持重新引入这种方案。 **合并 CLI 与 Desktop 插件安装。** 共享启动代码不要求共享可执行依赖。独立安装允许分别验收发布与插件版本,共享会话和设置则仍由已有数据归属方负责。 diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 27af127d08..e9145fc5b9 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: 609ecba962f6efe41abb6e18abcda79aec1692d4 -README.zh.md: 3e2e9c5280484e28f3faec298d928c93c21e717f +README.md: 2d05361644753436cc87fbb0e6ffc25cbae4a335 +README.zh.md: 07569ccc405d35de953f0965723b0f3638f92c67 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 609ecba962..2d05361644 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -4,6 +4,8 @@ English | [中文](README.zh.md) The desktop application is an Electron shell around the complete dsh Web application. An Electron RunAsNode child starts the shared profile runner, and Electron immediately loads the packaged Web entry at `dsh-app://app/`. Its shared loading page waits for Host boot injections, then starts the client without navigating to another document. Electron forwards application HTTP requests to the authenticated Web Host; WebSocket streams connect to that Host with credentials attached only for the owned application window. Node IPC carries boot injections, readiness, and shutdown. Desktop defaults to port `19387`, separate from Web’s `3080`; a `webserver.config.port` patch can override it. +Desktop’s local native directory flow opens an Electron folder dialog attached to the application window, restoring and focusing that window first. Concurrent requests share one dialog; cancellation returns no path and failures remain retryable. Ordinary Web uses the Host chooser. Browse mode still lists Host directories, including Linux auto selection without zenity or kdialog. + ## Key technical decisions The original artwork lives in `resources/icon.png` and `resources/icon.svg`; platform adaptations retain the whale and gradients in `resources/icon-windows.*` and `resources/icon-macos.*`. Export each platform SVG as a transparent 1024×1024 PNG. Electron-builder generates the multi-size ICO for the Windows application, installer, and uninstaller ([Windows icon requirements](https://learn.microsoft.com/en-us/windows/apps/design/iconography/app-icon-construction)). The installation pages use matching artwork in both themes; the uninstaller's welcome and finish pages share `installer/assets/uninstaller-sidebar.png`, converted to a 164×314 BMP during preparation. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 3e2e9c5280..07569ccc40 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -4,6 +4,8 @@ 桌面应用是完整 dsh Web 应用外的一层 Electron 壳。Electron RunAsNode 子进程启动共享 profile runner,Electron 立即从 `dsh-app://app/` 加载打包内的 Web 入口。共享加载页等待 Host 启动注入,然后在同一文档中启动客户端。Electron 将应用 HTTP 请求转发给已认证的 Web Host;WebSocket 流连接到该 Host,仅为归属的应用窗口附加凭据。Node IPC 承载启动注入、就绪与关闭。Desktop 默认使用端口 `19387`,与 Web 的 `3080` 分开;可通过 `webserver.config.port` patch 覆盖。 +Desktop 的本地原生目录流程打开绑定应用窗口的 Electron 文件夹对话框,并先恢复和聚焦该窗口。并发请求共用一个对话框;取消不返回路径,失败后可以重试。普通 Web 使用 Host 选择器。浏览模式仍列出 Host 目录,包括 Linux 缺少 zenity 或 kdialog 时的自动选择。 + ## 关键技术决策 设计师原稿位于 `resources/icon.png` 和 `resources/icon.svg`;平台适配保留鲸鱼与渐变,分别位于 `resources/icon-windows.*` 和 `resources/icon-macos.*`。将各平台 SVG 导出为透明的 1024×1024 PNG。electron-builder 为 Windows 应用、安装程序和卸载程序生成多尺寸 ICO([Windows 图标要求](https://learn.microsoft.com/en-us/windows/apps/design/iconography/app-icon-construction))。安装页面在两种主题下使用匹配的图案;卸载程序的欢迎和完成页共用 `installer/assets/uninstaller-sidebar.png`,准备阶段将其转换为 164×314 BMP。 diff --git a/apps/desktop/src/directory-picker.ts b/apps/desktop/src/directory-picker.ts new file mode 100644 index 0000000000..ce562ab933 --- /dev/null +++ b/apps/desktop/src/directory-picker.ts @@ -0,0 +1,32 @@ +/** Window-owned workspace directory dialogs for the local Desktop renderer. */ + +import { dialog, ipcMain, type BrowserWindow } from 'electron' +import { DESKTOP_IPC } from './ipc.ts' + +/** + * Install the application-lifetime directory picker IPC handler. + * @param getWindow - Current local application window; shell pages and subframes cannot open dialogs. + */ +export function installDesktopDirectoryPicker(getWindow: () => BrowserWindow | undefined): void { + const pending = new WeakMap>() + ipcMain.handle(DESKTOP_IPC.directoryPick, async (event) => { + const window = getWindow() + if (window === undefined || window.isDestroyed() || event.sender !== window.webContents + || event.senderFrame !== window.webContents.mainFrame) { + throw new Error('dsh desktop: rejected directory picker from an unowned renderer') + } + const url = new URL(event.senderFrame.url) + if (url.protocol !== 'dsh-app:' || url.hostname !== 'app') { + throw new Error('dsh desktop: directory picker requires the local application page') + } + const existing = pending.get(window) + if (existing !== undefined) return existing + if (window.isMinimized()) window.restore() + window.focus() + const result = dialog.showOpenDialog(window, { properties: ['openDirectory'] }).then( + ({ canceled, filePaths }) => window.isDestroyed() || canceled ? null : filePaths[0] ?? null, + ).finally(() => { pending.delete(window) }) + pending.set(window, result) + return result + }) +} diff --git a/apps/desktop/src/ipc.ts b/apps/desktop/src/ipc.ts index a303799d95..0f3138ba0d 100644 --- a/apps/desktop/src/ipc.ts +++ b/apps/desktop/src/ipc.ts @@ -8,6 +8,7 @@ export const DESKTOP_IPC = { localeGet: 'dsh-desktop:locale-get', boot: 'dsh-desktop:boot', bootFailed: 'dsh-desktop:boot-failed', + directoryPick: 'dsh-desktop:directory-pick', pluginsList: 'dsh-desktop:plugins-list', pluginsAdd: 'dsh-desktop:plugins-add', pluginsRemove: 'dsh-desktop:plugins-remove', diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index fdcf251f89..59a1d39f92 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -19,6 +19,7 @@ import { import { resolveDesktopPaths } from './paths.ts' import { DesktopProjectManager, type DesktopProjectHooks } from './project-manager.ts' import { DesktopHostProcess } from './host-process.ts' +import { installDesktopDirectoryPicker } from './directory-picker.ts' import { desktopNodeEnvironment } from './node-environment.ts' import { DesktopBackendController } from './backend-controller.ts' import { DESKTOP_IPC, type DesktopUpdateState } from './ipc.ts' @@ -297,6 +298,8 @@ async function main(): Promise { return Promise.resolve(new Response(null, { status: 404 })) }) + installDesktopDirectoryPicker(() => mainWindow) + ipcMain.handle(DESKTOP_IPC.boot, async (event) => { assertDesktopSender(event, ['app']) await startup diff --git a/apps/desktop/src/preload-app.ts b/apps/desktop/src/preload-app.ts index cafeaabdf8..b8ad1869b6 100644 --- a/apps/desktop/src/preload-app.ts +++ b/apps/desktop/src/preload-app.ts @@ -1,4 +1,4 @@ -/** Context-isolated application boot bridge and desktop carrier marker. */ +/** Context-isolated application boot, local directory picker, and desktop carrier marker. */ import { contextBridge, ipcRenderer } from 'electron' import { DESKTOP_IPC } from './ipc.ts' @@ -6,6 +6,9 @@ import { markDocumentPlatform } from './preload-platform.ts' import { syncNativeTheme } from './preload-theme.ts' if (location.protocol === 'dsh-app:' && location.hostname === 'app') { + contextBridge.exposeInMainWorld('__DSH_DIRECTORY_PICKER__', { + pick: () => ipcRenderer.invoke(DESKTOP_IPC.directoryPick) as Promise, + }) contextBridge.exposeInMainWorld('dshDesktopBoot', { ready: () => ipcRenderer.invoke(DESKTOP_IPC.boot) as Promise, failed: (message: string) => ipcRenderer.invoke(DESKTOP_IPC.bootFailed, message) as Promise, diff --git a/apps/desktop/tests/directory-picker.spec.ts b/apps/desktop/tests/directory-picker.spec.ts new file mode 100644 index 0000000000..d9ca99aac8 --- /dev/null +++ b/apps/desktop/tests/directory-picker.spec.ts @@ -0,0 +1,80 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { BrowserWindow, IpcMainInvokeEvent, OpenDialogReturnValue } from 'electron' +import { DESKTOP_IPC } from '../src/ipc.ts' + +const electron = vi.hoisted(() => ({ + handle: vi.fn<(channel: string, handler: (event: IpcMainInvokeEvent) => Promise) => void>(), + showOpenDialog: vi.fn<(window: BrowserWindow, options: unknown) => Promise>(), +})) +vi.mock('electron', () => ({ ipcMain: { handle: electron.handle }, dialog: electron })) +const { installDesktopDirectoryPicker } = await import('../src/directory-picker.ts') + +beforeEach(() => { vi.resetAllMocks() }) + +function fixture() { + const frame = { url: 'dsh-app://app/' } + const window = { + webContents: { mainFrame: frame }, + isDestroyed: vi.fn(() => false), + isMinimized: vi.fn(() => false), + restore: vi.fn(), + focus: vi.fn(), + } + let current: BrowserWindow | undefined = window as unknown as BrowserWindow + installDesktopDirectoryPicker(() => current) + expect(electron.handle.mock.calls[0]?.[0]).toBe(DESKTOP_IPC.directoryPick) + const handler = electron.handle.mock.calls[0]![1] + const event = { sender: window.webContents, senderFrame: frame } as unknown as IpcMainInvokeEvent + return { window, frame, event, handler, detach: () => { current = undefined } } +} + +describe('Desktop directory picker', () => { + it('restores and focuses the parent window and shares an unanswered dialog', async () => { + const f = fixture() + f.window.isMinimized.mockReturnValue(true) + let settle!: (value: OpenDialogReturnValue) => void + electron.showOpenDialog.mockImplementation(() => new Promise((resolve) => { settle = resolve })) + const first = f.handler(f.event) + const second = f.handler(f.event) + expect(electron.showOpenDialog).toHaveBeenCalledExactlyOnceWith(f.window, { properties: ['openDirectory'] }) + expect(f.window.restore).toHaveBeenCalledOnce() + expect(f.window.focus).toHaveBeenCalledOnce() + settle({ canceled: false, filePaths: ['/workspace'] }) + await expect(Promise.all([first, second])).resolves.toEqual(['/workspace', '/workspace']) + electron.showOpenDialog.mockResolvedValue({ canceled: true, filePaths: [] }) + await expect(f.handler(f.event)).resolves.toBeNull() + expect(electron.showOpenDialog).toHaveBeenCalledTimes(2) + }) + + it('releases a failed dialog so the user can retry', async () => { + const f = fixture() + electron.showOpenDialog.mockRejectedValueOnce(new Error('chooser failed')) + await expect(f.handler(f.event)).rejects.toThrow('chooser failed') + electron.showOpenDialog.mockResolvedValue({ canceled: false, filePaths: [] }) + await expect(f.handler(f.event)).resolves.toBeNull() + }) + + it('discards the selected path after the parent is destroyed', async () => { + const f = fixture() + electron.showOpenDialog.mockImplementation(async () => { + f.window.isDestroyed.mockReturnValue(true) + return { canceled: false, filePaths: ['/workspace'] } + }) + await expect(f.handler(f.event)).resolves.toBeNull() + }) + + it('rejects other windows, subframes, shell pages, remote pages, and missing windows', async () => { + const f = fixture() + await expect(f.handler({ ...f.event, sender: {} } as IpcMainInvokeEvent)).rejects.toThrow('unowned renderer') + await expect(f.handler({ ...f.event, senderFrame: {} } as IpcMainInvokeEvent)).rejects.toThrow('unowned renderer') + for (const url of ['dsh-app://shell/startup.html', 'https://example.com/', 'http://127.0.0.1/']) { + f.frame.url = url + await expect(f.handler(f.event)).rejects.toThrow('local application page') + } + f.window.isDestroyed.mockReturnValue(true) + await expect(f.handler(f.event)).rejects.toThrow('unowned renderer') + f.detach() + await expect(f.handler(f.event)).rejects.toThrow('unowned renderer') + expect(electron.showOpenDialog).not.toHaveBeenCalled() + }) +}) diff --git a/apps/desktop/tests/preload-app.spec.ts b/apps/desktop/tests/preload-app.spec.ts index a4a5a3a536..a3c42d38e1 100644 --- a/apps/desktop/tests/preload-app.spec.ts +++ b/apps/desktop/tests/preload-app.spec.ts @@ -11,7 +11,7 @@ vi.mock('../src/preload-theme.ts', () => ({ syncNativeTheme: vi.fn() })) afterEach(() => { vi.unstubAllGlobals(); vi.clearAllMocks(); vi.resetModules() }) -it.each(['dsh-app://app/index.html', 'dsh-app://shell/plugin-manager.html'])('exposes only the carrier marker to %s', async (url) => { +it.each(['dsh-app://app/index.html', 'dsh-app://shell/plugin-manager.html'])('exposes the carrier marker to %s', async (url) => { vi.stubGlobal('location', new URL(url)) await import('../src/preload-app.ts') expect(electron.contextBridge.exposeInMainWorld).toHaveBeenCalledWith('dshDesktop', { protocolVersion: 1 }) @@ -31,3 +31,19 @@ it('exposes asynchronous boot only to the local application document', async () await import('../src/preload-app.ts') expect(electron.contextBridge.exposeInMainWorld.mock.calls.some(([name]) => name === 'dshDesktopBoot')).toBe(false) }) + +it('exposes a directory picker only to the local application document', async () => { + vi.stubGlobal('location', new URL('dsh-app://app/')) + await import('../src/preload-app.ts') + const api = electron.contextBridge.exposeInMainWorld.mock.calls.find(([name]) => name === '__DSH_DIRECTORY_PICKER__')?.[1] as { pick(): Promise } + electron.ipcRenderer.invoke.mockResolvedValue('/workspace') + await expect(api.pick()).resolves.toBe('/workspace') + expect(electron.ipcRenderer.invoke).toHaveBeenCalledExactlyOnceWith(DESKTOP_IPC.directoryPick) + for (const url of ['dsh-app://shell/startup.html', 'https://example.com/']) { + vi.resetModules() + electron.contextBridge.exposeInMainWorld.mockClear() + vi.stubGlobal('location', new URL(url)) + await import('../src/preload-app.ts') + expect(electron.contextBridge.exposeInMainWorld.mock.calls.some(([name]) => name === '__DSH_DIRECTORY_PICKER__')).toBe(false) + } +}) diff --git a/packages/client/ui-directory-picker-native/README.i18n.yaml b/packages/client/ui-directory-picker-native/README.i18n.yaml index 089e9c865d..4272364764 100644 --- a/packages/client/ui-directory-picker-native/README.i18n.yaml +++ b/packages/client/ui-directory-picker-native/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/client/ui-directory-picker-native/README.md -README.md: 4d35b30e093f06e939d0831a1ea45c050c43bf03 -README.zh.md: 98bdf1b15eea5de9dee2f0a7dab583f0f124b504 +README.md: d6749dd7847aa821c12d81d989984a1bd832f320 +README.zh.md: cbeccf433fe5b0def28d945fde9b7b35780f1b86 diff --git a/packages/client/ui-directory-picker-native/README.md b/packages/client/ui-directory-picker-native/README.md index 4d35b30e09..d6749dd784 100644 --- a/packages/client/ui-directory-picker-native/README.md +++ b/packages/client/ui-directory-picker-native/README.md @@ -27,6 +27,8 @@ This package provides the native directory-picking surface for the Web GUI: when Mount this plugin alongside `ui-workspace` and the host backend [`dsh-host-directory-picker-native`](../../host/directory-picker-native/README.md); one `cordis.yml` row then composes the whole native picking interaction. When a workspace add or picker flow opens a directory request, the user sees the operating system's folder dialog; the picked path is adopted by the workspace flow, and cancelling closes the dialog. +In the local Electron application, this flow uses the narrow preload directory-picker bridge. Cancellation and failure never retry through the Host chooser. Ordinary Web retains the Host call; the separate browse composition always lists Host directories. Desktop keeps automatic backend selection, so Linux without zenity or kdialog still uses browse. + ### When to choose it Choose this surface when the browser runs on the same machine as the Host, so an OS dialog can open there. Choose the [`-browse`](../ui-directory-picker-browse/README.md) surface when the browser is remote or in-process and no local chooser exists. The two surfaces fill the same slots, so switching is a composition change, not a code change. diff --git a/packages/client/ui-directory-picker-native/README.zh.md b/packages/client/ui-directory-picker-native/README.zh.md index 98bdf1b15e..cbeccf433f 100644 --- a/packages/client/ui-directory-picker-native/README.zh.md +++ b/packages/client/ui-directory-picker-native/README.zh.md @@ -27,6 +27,8 @@ kind: "package-reference" 与 `ui-workspace` 及 Host 后端 [`dsh-host-directory-picker-native`](../../host/directory-picker-native/README.zh.md) 一起挂载本插件;一行 `cordis.yml` 随即组合出完整的原生拾取交互。当工作区添加或选择器流程发起目录请求时,用户看到操作系统的文件夹对话框;拾取的路径被工作区流程采纳,取消则关闭对话框。 +在本地 Electron 应用中,此流程使用 preload 提供的窄目录选择接口。取消和失败都不会改用 Host 选择器重试。普通 Web 保留 Host 调用;独立的浏览组合始终列出 Host 目录。Desktop 保留后端自动选择,因此 Linux 缺少 zenity 或 kdialog 时仍使用浏览模式。 + ### 何时选择 当浏览器与 Host 运行在同一台机器上、操作系统对话框可以在那里打开时,选择此表面。当浏览器为远程或进程内、没有本地选择器时,选择 [`-browse`](../ui-directory-picker-browse/README.zh.md) 表面。两个表面填充相同的 slot,因此切换只是组合改动,而非代码改动。 diff --git a/packages/client/ui-directory-picker-native/src/client/flow.ts b/packages/client/ui-directory-picker-native/src/client/flow.ts index 14f705d071..a02be30e76 100644 --- a/packages/client/ui-directory-picker-native/src/client/flow.ts +++ b/packages/client/ui-directory-picker-native/src/client/flow.ts @@ -8,9 +8,9 @@ import type { ReactElement } from 'react' // Type-only: the owner contract of the directory-flow holes. import type { DirectoryFlowOwnerProps } from '@deepseek-ai/dsh-client-ui-workspace/client' -/** Injected face: the wire call the flow drives (bound in apply's closure). */ +/** Injected face: the native chooser call the flow drives (bound in apply's closure). */ export interface NativeFlowInjected { - /** Ask the local Host to open its native single-directory chooser. */ + /** Open the local desktop or Host single-directory chooser. */ pick: () => Promise } @@ -31,11 +31,11 @@ export function NativeDirectoryFlow(props: DirectoryFlowOwnerProps & NativeFlowI outcome.current = props // Unmount (HMR replacing the occupant) discards settlements wholesale: the // dead instance must neither adopt a path nor drive the owner's error - // surface. The wire carries no per-request abort, so the host-side chooser + // surface. The wire carries no per-request abort, so the native chooser // survives until answered — its answer just lands nowhere; the replacement // instance re-arms under the owner's still-open request. An injected-face // identity change alone (re-registration) keeps the pending settlement: - // the chooser on the host display is still the same dialog. + // the native chooser is still the same dialog. const alive = useRef(true) useEffect(() => { // StrictMode's development replay runs the cleanup once before the real diff --git a/packages/client/ui-directory-picker-native/src/client/index.ts b/packages/client/ui-directory-picker-native/src/client/index.ts index 68589c2982..efec56fc49 100644 --- a/packages/client/ui-directory-picker-native/src/client/index.ts +++ b/packages/client/ui-directory-picker-native/src/client/index.ts @@ -1,12 +1,4 @@ -/** - * Browser half of the native directory-picker backend: fills ui-workspace's - * two directory-flow holes with a renderless occupant that answers each - * `open` by driving `directoryPicker/pick` (the node half's OS chooser) and - * reporting the one outcome — picked path, cancellation, or failure — back - * through the owner conversation. Mounting this package therefore composes - * both sides of the native interaction with one cordis.yml row; no client - * code branches on a capability kind. - */ +/** Native directory flow using the local desktop bridge or the Host's OS chooser. */ import type { Context as ClientContext } from '@deepseek-ai/cordis' // Type-only: pulls the SlotMap merge declaring the directory-flow holes. import type {} from '@deepseek-ai/dsh-client-ui-workspace/client' @@ -26,7 +18,11 @@ export const inject = ['slots', 'uiWorkspace'] * @param ctx - client root context. */ export function apply(ctx: ClientContext): void { - const injected = (): NativeFlowInjected => ({ pick: () => ctx.uiWorkspace.pickDirectory() }) + const desktop = (globalThis as typeof globalThis & { + __DSH_DIRECTORY_PICKER__?: NativeFlowInjected + }).__DSH_DIRECTORY_PICKER__ + const pick = desktop === undefined ? () => ctx.uiWorkspace.pickDirectory() : () => desktop.pick() + const injected = (): NativeFlowInjected => ({ pick }) // Both declaration lifetimes must be live before the pair installs; the // generator makes the two registrations one transactional effect. The // outer/inner nesting order is arbitrary; neither hole has precedence. diff --git a/packages/client/ui-directory-picker-native/tests/client-flow.client.spec.tsx b/packages/client/ui-directory-picker-native/tests/client-flow.client.spec.tsx index 14b800a5df..2fb59e78ed 100644 --- a/packages/client/ui-directory-picker-native/tests/client-flow.client.spec.tsx +++ b/packages/client/ui-directory-picker-native/tests/client-flow.client.spec.tsx @@ -9,7 +9,7 @@ import { apply, inject } from '../src/client/index.ts' import { NativeDirectoryFlow } from '../src/client/flow.ts' import { apply as nodeApply } from '../src/index.ts' -afterEach(cleanup) +afterEach(() => { cleanup(); vi.unstubAllGlobals() }) const HOLES = ['conversation.hero.workspace.directoryFlow', 'sidebar.workspaces.directoryFlow'] as const @@ -149,6 +149,27 @@ describe('directory-picker-native client half', () => { expect(b.pickDirectory).toHaveBeenCalledOnce() }) + it('uses the desktop bridge without calling the Host and preserves cancellation and errors', async () => { + const pick = vi.fn<() => Promise>().mockResolvedValue('/desktop/workspace') + vi.stubGlobal('__DSH_DIRECTORY_PICKER__', { pick }) + const b = await bench() + b.declare() + const fiber = b.ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + try { + const entry = b.slots.entries(HOLES[0])[0]! + const injected = (entry.inject as () => { pick: () => Promise })() + await expect(injected.pick()).resolves.toBe('/desktop/workspace') + pick.mockResolvedValue(null) + await expect(injected.pick()).resolves.toBeNull() + pick.mockRejectedValue(new Error('desktop dialog failed')) + await expect(injected.pick()).rejects.toThrow('desktop dialog failed') + expect(b.pickDirectory).not.toHaveBeenCalled() + } finally { + await fiber.dispose() + } + }) + it('runs one pick per open edge and reports the path to the latest onPicked', async () => { let resolve!: (path: string | null) => void const pick = vi.fn(() => new Promise((settle) => { resolve = settle })) From ea5f1dce4ad4f8c87281ea943c6fd60f36d0a05b Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Tue, 15 Sep 2026 16:59:20 +0800 Subject: [PATCH 07/15] fix(desktop,client): preserve folder creation and verify picker wiring --- .../2026-09-10-desktop-web-wrapper.i18n.yaml | 4 +-- .../2026-09-10-desktop-web-wrapper.md | 2 +- .../2026-09-10-desktop-web-wrapper.zh.md | 2 +- apps/desktop/README.i18n.yaml | 4 +-- apps/desktop/README.md | 2 +- apps/desktop/README.zh.md | 2 +- apps/desktop/src/directory-picker.ts | 10 +++--- apps/desktop/src/ipc.ts | 18 +++++++++++ apps/desktop/src/main.ts | 12 +------ apps/desktop/tests/directory-picker.spec.ts | 6 ++-- apps/desktop/tests/main-startup.spec.ts | 20 ++++++++++-- .../README.i18n.yaml | 4 +-- .../ui-directory-picker-native/README.md | 11 ++++--- .../ui-directory-picker-native/README.zh.md | 9 +++--- .../tests/client-flow.client.spec.tsx | 30 +++++++++++++++++ .../tests/desktop-picker.client.spec.tsx | 32 +++++++++++++++++++ .../verify-package-readme-model-experience.ts | 2 +- 17 files changed, 129 insertions(+), 41 deletions(-) create mode 100644 packages/test-support/client-runtime/tests/desktop-picker.client.spec.tsx diff --git a/.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.i18n.yaml index 64c94da391..1a3ff86ba9 100644 --- a/.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.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-10-desktop-web-wrapper.md -2026-09-10-desktop-web-wrapper.md: ab3a1d653107fa1b11ece01dc5e7b8b9adbe0bde -2026-09-10-desktop-web-wrapper.zh.md: 08080a17f37d550a06e2e4634bf80bfbd304261d +2026-09-10-desktop-web-wrapper.md: 445c574f86d6e465763603e7d447d92ffefb4f99 +2026-09-10-desktop-web-wrapper.zh.md: a41ce3c2f18237ddf580777e88fe05507998982f diff --git a/.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.md b/.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.md index ab3a1d6531..445c574f86 100644 --- a/.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.md +++ b/.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.md @@ -26,7 +26,7 @@ Shared `initProfile` creates missing profile files and preserves existing conten This partially supersedes the private composition and portless transport in the [packaging decision](2026-08-25-electron-desktop-packaging-and-updates.md). That design avoided listening ports and used framed byte pipes to avoid Base64 expansion and cross-version V8 serialization. Shared HTTP gives up the portless guarantee and assigns serving and authentication to the existing Web implementation. Release identity, signing, process ownership, and native shell features remain active decisions. -Native directory selection in the local application uses a narrow preload IPC call to Electron’s window-owned dialog. Main admits only the current application window’s main frame at `dsh-app://app`; shell, remote, and child frames cannot request it. Concurrent requests share the pending dialog and destroyed windows discard selections. Web backend selection and Host browse remain shared. +Native directory selection in the local application uses a narrow preload IPC call to Electron’s window-owned dialog. Main admits only the current application window’s main frame at `dsh-app://app`; shell, remote, and child frames cannot request it. Concurrent requests share the pending dialog and destroyed windows discard selections. Web backend selection and Host browse are shared. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.zh.md b/.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.zh.md index 08080a17f3..a41ce3c2f1 100644 --- a/.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.zh.md @@ -26,7 +26,7 @@ App-boot 负责已安装依赖发现、安装目录优先的 bundle 声明解析 本记录部分取代[打包决策](2026-08-25-electron-desktop-packaging-and-updates.zh.md)中的私有组合与无端口传输。该设计避免监听端口,并使用分帧字节管道避免 Base64 膨胀与跨版本 V8 序列化。共享 HTTP 放弃无端口保证,将服务与认证交给已有 Web 实现。发布身份、签名、进程归属及原生壳功能仍是有效决策。 -本地应用的原生目录选择通过窄 preload IPC 调用 Electron 的窗口所属对话框。Main 仅接受当前应用窗口中位于 `dsh-app://app` 的主框架请求;shell、远程页面和子框架均不能调用。并发请求共用待完成的对话框,窗口销毁后丢弃选择结果。Web 后端选择和 Host 浏览仍由共享实现负责。 +本地应用的原生目录选择通过窄 preload IPC 调用 Electron 的窗口所属对话框。Main 仅接受当前应用窗口中位于 `dsh-app://app` 的主框架请求;shell、远程页面和子框架均不能调用。并发请求共用待完成的对话框,窗口销毁后丢弃选择结果。Web 后端选择和 Host 浏览由共享实现负责。 ## Alternatives considered diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index e9145fc5b9..fc26671fdf 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: 2d05361644753436cc87fbb0e6ffc25cbae4a335 -README.zh.md: 07569ccc405d35de953f0965723b0f3638f92c67 +README.md: 122498ad310b4ac803b80249066ddcbe4247005d +README.zh.md: d7a37e111f311b11f98253308fe760ea5d6637b2 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 2d05361644..122498ad31 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) The desktop application is an Electron shell around the complete dsh Web application. An Electron RunAsNode child starts the shared profile runner, and Electron immediately loads the packaged Web entry at `dsh-app://app/`. Its shared loading page waits for Host boot injections, then starts the client without navigating to another document. Electron forwards application HTTP requests to the authenticated Web Host; WebSocket streams connect to that Host with credentials attached only for the owned application window. Node IPC carries boot injections, readiness, and shutdown. Desktop defaults to port `19387`, separate from Web’s `3080`; a `webserver.config.port` patch can override it. -Desktop’s local native directory flow opens an Electron folder dialog attached to the application window, restoring and focusing that window first. Concurrent requests share one dialog; cancellation returns no path and failures remain retryable. Ordinary Web uses the Host chooser. Browse mode still lists Host directories, including Linux auto selection without zenity or kdialog. +Desktop’s local native directory flow opens an Electron folder dialog attached to the application window, restoring and focusing that window first. Concurrent requests share one dialog; cancellation returns no path and failures remain retryable. Ordinary Web uses the Host chooser. Browse mode lists Host directories. On Linux without zenity or kdialog, automatic selection uses browse instead of the Electron dialog. ## Key technical decisions diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 07569ccc40..d7a37e111f 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -4,7 +4,7 @@ 桌面应用是完整 dsh Web 应用外的一层 Electron 壳。Electron RunAsNode 子进程启动共享 profile runner,Electron 立即从 `dsh-app://app/` 加载打包内的 Web 入口。共享加载页等待 Host 启动注入,然后在同一文档中启动客户端。Electron 将应用 HTTP 请求转发给已认证的 Web Host;WebSocket 流连接到该 Host,仅为归属的应用窗口附加凭据。Node IPC 承载启动注入、就绪与关闭。Desktop 默认使用端口 `19387`,与 Web 的 `3080` 分开;可通过 `webserver.config.port` patch 覆盖。 -Desktop 的本地原生目录流程打开绑定应用窗口的 Electron 文件夹对话框,并先恢复和聚焦该窗口。并发请求共用一个对话框;取消不返回路径,失败后可以重试。普通 Web 使用 Host 选择器。浏览模式仍列出 Host 目录,包括 Linux 缺少 zenity 或 kdialog 时的自动选择。 +Desktop 的本地原生目录流程打开绑定应用窗口的 Electron 文件夹对话框,并先恢复和聚焦该窗口。并发请求共用一个对话框;取消不返回路径,失败后可以重试。普通 Web 使用 Host 选择器。浏览模式列出 Host 目录。Linux 缺少 zenity 或 kdialog 时,自动选择使用浏览模式,不使用 Electron 对话框。 ## 关键技术决策 diff --git a/apps/desktop/src/directory-picker.ts b/apps/desktop/src/directory-picker.ts index ce562ab933..e7cf84c393 100644 --- a/apps/desktop/src/directory-picker.ts +++ b/apps/desktop/src/directory-picker.ts @@ -1,7 +1,7 @@ /** Window-owned workspace directory dialogs for the local Desktop renderer. */ import { dialog, ipcMain, type BrowserWindow } from 'electron' -import { DESKTOP_IPC } from './ipc.ts' +import { DESKTOP_IPC, assertDesktopSender } from './ipc.ts' /** * Install the application-lifetime directory picker IPC handler. @@ -15,15 +15,13 @@ export function installDesktopDirectoryPicker(getWindow: () => BrowserWindow | u || event.senderFrame !== window.webContents.mainFrame) { throw new Error('dsh desktop: rejected directory picker from an unowned renderer') } - const url = new URL(event.senderFrame.url) - if (url.protocol !== 'dsh-app:' || url.hostname !== 'app') { - throw new Error('dsh desktop: directory picker requires the local application page') - } + assertDesktopSender(event, ['app']) const existing = pending.get(window) if (existing !== undefined) return existing if (window.isMinimized()) window.restore() + window.show() window.focus() - const result = dialog.showOpenDialog(window, { properties: ['openDirectory'] }).then( + const result = dialog.showOpenDialog(window, { properties: ['openDirectory', 'createDirectory'] }).then( ({ canceled, filePaths }) => window.isDestroyed() || canceled ? null : filePaths[0] ?? null, ).finally(() => { pending.delete(window) }) pending.set(window, result) diff --git a/apps/desktop/src/ipc.ts b/apps/desktop/src/ipc.ts index 0f3138ba0d..9c7329d0d4 100644 --- a/apps/desktop/src/ipc.ts +++ b/apps/desktop/src/ipc.ts @@ -1,5 +1,6 @@ /** Typed preload operations exposed only by the Electron shell. */ +import type { IpcMainInvokeEvent } from 'electron' import type { DesktopPluginRecord } from './project-manager.ts' import type { DesktopLocale } from './locale.ts' @@ -44,3 +45,20 @@ export interface DshDesktopApi { subscribe(listener: (state: DesktopUpdateState) => void): () => void } } + +/** Scheme of Desktop-owned application and shell documents. */ +export const SCHEME = 'dsh-app' + +/** + * Reject IPC outside the allowed Desktop document origins. + * @param event - IPC caller whose frame URL supplies the origin. + * @param hostnames - Desktop document hosts allowed for this operation. + */ +export function assertDesktopSender(event: IpcMainInvokeEvent, hostnames: readonly string[]): void { + const senderFrame = event.senderFrame + if (senderFrame === null) throw new Error('dsh desktop: rejected IPC without a sender frame') + const url = new URL(senderFrame.url) + if (url.protocol !== `${SCHEME}:` || !hostnames.includes(url.hostname)) { + throw new Error('dsh desktop: rejected IPC from an unowned renderer') + } +} diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 59a1d39f92..916920bd92 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -22,14 +22,13 @@ import { DesktopHostProcess } from './host-process.ts' import { installDesktopDirectoryPicker } from './directory-picker.ts' import { desktopNodeEnvironment } from './node-environment.ts' import { DesktopBackendController } from './backend-controller.ts' -import { DESKTOP_IPC, type DesktopUpdateState } from './ipc.ts' +import { DESKTOP_IPC, SCHEME, assertDesktopSender, type DesktopUpdateState } from './ipc.ts' import { formatDesktopMessage, resolveDesktopLocale } from './locale.ts' import { claimDesktopSingleInstance } from './single-instance.ts' import { DesktopUpdateCoordinator } from './update-coordinator.ts' import { serveWebDocument, authenticateWebHost, forwardWebRequest } from './web-document.ts' import { DesktopFatalRecovery } from './fatal-recovery.ts' -const SCHEME = 'dsh-app' let focusPrimaryWindow = (): void => {} let stopForRecovery = async (): Promise => {} let shuttingDown = false @@ -160,15 +159,6 @@ function createWindow(preload: string, show = false): BrowserWindow { return window } -function assertDesktopSender(event: IpcMainInvokeEvent, hostnames: readonly string[]): void { - const senderFrame = event.senderFrame - if (senderFrame === null) throw new Error('dsh desktop: rejected IPC without a sender frame') - const url = new URL(senderFrame.url) - if (url.protocol !== `${SCHEME}:` || !hostnames.includes(url.hostname)) { - throw new Error('dsh desktop: rejected IPC from an unowned renderer') - } -} - async function serveShellAsset(request: Request): Promise { if (request.method !== 'GET' && request.method !== 'HEAD') return new Response(null, { status: 405 }) const root = resolve(app.getAppPath(), 'renderer') diff --git a/apps/desktop/tests/directory-picker.spec.ts b/apps/desktop/tests/directory-picker.spec.ts index d9ca99aac8..2f4a9b4ba6 100644 --- a/apps/desktop/tests/directory-picker.spec.ts +++ b/apps/desktop/tests/directory-picker.spec.ts @@ -18,6 +18,7 @@ function fixture() { isDestroyed: vi.fn(() => false), isMinimized: vi.fn(() => false), restore: vi.fn(), + show: vi.fn(), focus: vi.fn(), } let current: BrowserWindow | undefined = window as unknown as BrowserWindow @@ -36,8 +37,9 @@ describe('Desktop directory picker', () => { electron.showOpenDialog.mockImplementation(() => new Promise((resolve) => { settle = resolve })) const first = f.handler(f.event) const second = f.handler(f.event) - expect(electron.showOpenDialog).toHaveBeenCalledExactlyOnceWith(f.window, { properties: ['openDirectory'] }) + expect(electron.showOpenDialog).toHaveBeenCalledExactlyOnceWith(f.window, { properties: ['openDirectory', 'createDirectory'] }) expect(f.window.restore).toHaveBeenCalledOnce() + expect(f.window.show).toHaveBeenCalledOnce() expect(f.window.focus).toHaveBeenCalledOnce() settle({ canceled: false, filePaths: ['/workspace'] }) await expect(Promise.all([first, second])).resolves.toEqual(['/workspace', '/workspace']) @@ -69,7 +71,7 @@ describe('Desktop directory picker', () => { await expect(f.handler({ ...f.event, senderFrame: {} } as IpcMainInvokeEvent)).rejects.toThrow('unowned renderer') for (const url of ['dsh-app://shell/startup.html', 'https://example.com/', 'http://127.0.0.1/']) { f.frame.url = url - await expect(f.handler(f.event)).rejects.toThrow('local application page') + await expect(f.handler(f.event)).rejects.toThrow('unowned renderer') } f.window.isDestroyed.mockReturnValue(true) await expect(f.handler(f.event)).rejects.toThrow('unowned renderer') diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts index 57c01a75fc..667428997d 100644 --- a/apps/desktop/tests/main-startup.spec.ts +++ b/apps/desktop/tests/main-startup.spec.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { IpcMainInvokeEvent } from 'electron' import { join } from 'node:path' import type { MessageBoxOptions, MessageBoxReturnValue } from 'electron' import { DESKTOP_IPC } from '../src/ipc.ts' @@ -29,6 +30,7 @@ const harness = await vi.hoisted(async () => { readonly urls: string[] = [] readonly webContents = Object.assign(new EventEmitter(), { id: 42, + mainFrame: { url: 'dsh-app://app/' }, setWindowOpenHandler: vi.fn(), openDevTools: vi.fn(), getURL: () => this.urls.at(-1) ?? '', @@ -87,7 +89,7 @@ const harness = await vi.hoisted(async () => { popup, socketHeaders: vi.fn(), menu: { setApplicationMenu: vi.fn(), buildFromTemplate: vi.fn(() => ({ popup })) }, - dialog: { showErrorBox: vi.fn(), showMessageBox: vi.fn<(options: MessageBoxOptions) => Promise>() }, + dialog: { showOpenDialog: vi.fn(), showErrorBox: vi.fn(), showMessageBox: vi.fn<(options: MessageBoxOptions) => Promise>() }, openExternal: vi.fn(), applyRelease: vi.fn(() => { preparing.resolve(); return prepared.promise }), mutateFailure: vi.fn<() => void>(), @@ -118,7 +120,7 @@ vi.mock('electron', () => ({ nativeTheme: { themeSource: 'system' }, ipcMain: { on: vi.fn(), - handle: (channel: string, handler: (event: { senderFrame: { url: string } }) => unknown) => { harness.handlers.set(channel, handler) }, + handle: (channel: string, handler: (event: { senderFrame: { url: string } }) => unknown) => { if (harness.handlers.has(channel)) throw new Error(`duplicate IPC handler ${channel}`); harness.handlers.set(channel, handler) }, }, Menu: harness.menu, session: { defaultSession: { webRequest: { onBeforeSendHeaders: harness.socketHeaders } } }, @@ -220,6 +222,20 @@ describe('desktop main startup', () => { expect(callback).toHaveBeenLastCalledWith({}) }) + it('registers the window-owned directory picker during startup and rejects foreign callers', async () => { + await import('../src/main.ts') + await harness.preparing.promise + const window = harness.windows[0]! + const handler = harness.handlers.get(DESKTOP_IPC.directoryPick) as (event: IpcMainInvokeEvent) => Promise + expect(handler).toBeTypeOf('function') + const event = { sender: window.webContents, senderFrame: window.webContents.mainFrame } as unknown as IpcMainInvokeEvent + harness.dialog.showOpenDialog.mockResolvedValue({ canceled: false, filePaths: ['/workspace'] }) + await expect(handler(event)).resolves.toBe('/workspace') + expect(harness.dialog.showOpenDialog).toHaveBeenCalledExactlyOnceWith(window, { properties: ['openDirectory', 'createDirectory'] }) + window.webContents.mainFrame.url = 'https://other.example/' + await expect(handler(event)).rejects.toThrow('unowned renderer') + }) + it('holds boot injections until the Host is ready and rejects foreign boot callers', async () => { await import('../src/main.ts') await harness.preparing.promise diff --git a/packages/client/ui-directory-picker-native/README.i18n.yaml b/packages/client/ui-directory-picker-native/README.i18n.yaml index 4272364764..b1a5bb087e 100644 --- a/packages/client/ui-directory-picker-native/README.i18n.yaml +++ b/packages/client/ui-directory-picker-native/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/client/ui-directory-picker-native/README.md -README.md: d6749dd7847aa821c12d81d989984a1bd832f320 -README.zh.md: cbeccf433fe5b0def28d945fde9b7b35780f1b86 +README.md: c7adf9e68798c8bb8ead843847506ae39b6d11a3 +README.zh.md: ff4f96bc48b5db8ec5ce2f917513d57556dbd29c diff --git a/packages/client/ui-directory-picker-native/README.md b/packages/client/ui-directory-picker-native/README.md index d6749dd784..c7adf9e687 100644 --- a/packages/client/ui-directory-picker-native/README.md +++ b/packages/client/ui-directory-picker-native/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -This package provides the native directory-picking surface for the Web GUI: when a workspace flow asks for a directory, a renderless browser occupant opens the operating system's own chooser on the machine running the Host and reports the single outcome — a picked path, a cancellation, or a failure. It fills the two directory-flow slots declared by `ui-workspace`, composing the client side of the native picking interaction in one `cordis.yml` row. Choose it when the browser runs on the same machine as the Host; in-process and remote-browser deployments need the [`-browse`](../ui-directory-picker-browse/README.md) surface instead. +This package provides the native directory-picking surface for the Web GUI: when a workspace flow asks for a directory, a renderless browser occupant opens the operating system's own chooser on the local machine and reports the single outcome — a picked path, a cancellation, or a failure. It fills the two directory-flow slots declared by `ui-workspace`, composing the client side of the native picking interaction in one `cordis.yml` row. Choose it when the browser runs on the same machine as the Host; in-process and remote-browser deployments need the [`-browse`](../ui-directory-picker-browse/README.md) surface instead. ## Table of Contents @@ -27,7 +27,7 @@ This package provides the native directory-picking surface for the Web GUI: when Mount this plugin alongside `ui-workspace` and the host backend [`dsh-host-directory-picker-native`](../../host/directory-picker-native/README.md); one `cordis.yml` row then composes the whole native picking interaction. When a workspace add or picker flow opens a directory request, the user sees the operating system's folder dialog; the picked path is adopted by the workspace flow, and cancelling closes the dialog. -In the local Electron application, this flow uses the narrow preload directory-picker bridge. Cancellation and failure never retry through the Host chooser. Ordinary Web retains the Host call; the separate browse composition always lists Host directories. Desktop keeps automatic backend selection, so Linux without zenity or kdialog still uses browse. +In the local Electron application, this flow uses the narrow preload directory-picker bridge. Cancellation and failure never retry through the Host chooser. Ordinary Web uses the Host call; the separate browse composition always lists Host directories. ### When to choose it @@ -41,7 +41,7 @@ Choose this surface when the browser runs on the same machine as the Host, so an
    Implementation internals — click to expand -Both slot registrations install as one transactional effect through nested `ctx.slots.inject()` calls, because either declaring entry may activate later or replace its declaration. The occupant arms once per rising `open` edge, so re-renders never launch a second chooser; settlements ride a ref so the answer reaches the owner's latest handlers. An unmount (HMR replacing the occupant) discards the settlement wholesale: the wire carries no per-request abort, so the host-side chooser survives until answered and its answer lands nowhere. The node half is an empty `apply` that keeps the plugin on the host roster. +Both slot registrations install as one transactional effect through nested `ctx.slots.inject()` calls, because either declaring entry may activate later or replace its declaration. The occupant arms once per rising `open` edge, so re-renders never launch a second chooser; settlements ride a ref so the answer reaches the owner's latest handlers. An unmount (HMR replacing the occupant) discards the settlement wholesale: the wire carries no per-request abort, so the native chooser survives until answered and its answer lands nowhere. The node half is an empty `apply` that keeps the plugin on the host roster.
    @@ -75,8 +75,9 @@ None; this package neither assembles nor sends a provider request. These limits define when the native chooser fits. They are current package constraints, not a general picker comparison or a task backlog. -- **No cancellation of an open chooser** — the wire has no per-request abort, so a chooser already on the host display cannot be closed from the browser; a discarded settlement is ignored. -- **Local Host carriers only** — an OS dialog opens on the machine running the Host, so in-process and remote-browser deployments need the `-browse` composition instead. Platform failures surface through the owner's retryable folder dialog. +- **No cancellation of an open chooser** — the wire has no per-request abort, so a chooser already on the local display cannot be closed from the browser; a discarded settlement is ignored. +- **Local Host carriers only** — the Electron dialog selects local paths; ordinary Web opens the Host chooser. Remote-browser and in-process deployments use the `-browse` composition. Platform failures surface through the owner's retryable folder dialog. +- **Linux automatic selection** — without zenity or kdialog, the Host selects browse even in Desktop; the Electron dialog is not used. ### Dev Note diff --git a/packages/client/ui-directory-picker-native/README.zh.md b/packages/client/ui-directory-picker-native/README.zh.md index cbeccf433f..ff4f96bc48 100644 --- a/packages/client/ui-directory-picker-native/README.zh.md +++ b/packages/client/ui-directory-picker-native/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -本包提供 Web GUI 的原生目录拾取表面:当工作区流程请求一个目录时,一个无渲染的浏览器填充会在运行 Host 的机器上打开操作系统自带的选择器,并回报唯一结果——拾取的路径、取消或失败。它填充 `ui-workspace` 声明的两个目录流程 slot,用一行 `cordis.yml` 组合出原生拾取交互的客户端一侧。当浏览器与 Host 运行在同一台机器上时选择它;进程内与远程浏览器部署则需要 [`-browse`](../ui-directory-picker-browse/README.zh.md) 表面。 +本包提供 Web GUI 的原生目录拾取表面:当工作区流程请求一个目录时,一个无渲染的浏览器填充会在本地机器上打开操作系统自带的选择器,并回报唯一结果——拾取的路径、取消或失败。它填充 `ui-workspace` 声明的两个目录流程 slot,用一行 `cordis.yml` 组合出原生拾取交互的客户端一侧。当浏览器与 Host 运行在同一台机器上时选择它;进程内与远程浏览器部署则需要 [`-browse`](../ui-directory-picker-browse/README.zh.md) 表面。 ## 目录 @@ -27,7 +27,7 @@ kind: "package-reference" 与 `ui-workspace` 及 Host 后端 [`dsh-host-directory-picker-native`](../../host/directory-picker-native/README.zh.md) 一起挂载本插件;一行 `cordis.yml` 随即组合出完整的原生拾取交互。当工作区添加或选择器流程发起目录请求时,用户看到操作系统的文件夹对话框;拾取的路径被工作区流程采纳,取消则关闭对话框。 -在本地 Electron 应用中,此流程使用 preload 提供的窄目录选择接口。取消和失败都不会改用 Host 选择器重试。普通 Web 保留 Host 调用;独立的浏览组合始终列出 Host 目录。Desktop 保留后端自动选择,因此 Linux 缺少 zenity 或 kdialog 时仍使用浏览模式。 +在本地 Electron 应用中,此流程使用 preload 提供的窄目录选择接口。取消和失败都不会改用 Host 选择器重试。普通 Web 使用 Host 调用;独立的浏览组合始终列出 Host 目录。 ### 何时选择 @@ -75,8 +75,9 @@ kind: "package-reference" 这些限制界定了原生选择器的适用时机。它们是当前包约束,不是通用选择器对比或任务积压。 -- **无法取消已打开的选择器**——wire 没有按请求中止的机制,因此已显示在 Host 上的选择器无法从浏览器关闭;被丢弃的结算会被忽略。 -- **仅限本地 Host 承载**——操作系统对话框在运行 Host 的机器上打开,因此进程内与远程浏览器部署需要 `-browse` 组合。平台失败经由持有方的可重试文件夹对话框呈现。 +- **无法取消已打开的选择器**——wire 没有按请求中止的机制,因此已显示在本地的选择器无法从浏览器关闭;被丢弃的结算会被忽略。 +- **仅限本地 Host 承载**——Electron 对话框选择本地路径;普通 Web 打开 Host 选择器。远程浏览器与进程内部署使用 `-browse` 组合。平台失败经由持有方的可重试文件夹对话框呈现。 +- **Linux 自动选择**——缺少 zenity 或 kdialog 时,Host 即使在 Desktop 中也选择浏览模式,不使用 Electron 对话框。 ### 开发备注 diff --git a/packages/client/ui-directory-picker-native/tests/client-flow.client.spec.tsx b/packages/client/ui-directory-picker-native/tests/client-flow.client.spec.tsx index 2fb59e78ed..209266c1df 100644 --- a/packages/client/ui-directory-picker-native/tests/client-flow.client.spec.tsx +++ b/packages/client/ui-directory-picker-native/tests/client-flow.client.spec.tsx @@ -9,6 +9,14 @@ import { apply, inject } from '../src/client/index.ts' import { NativeDirectoryFlow } from '../src/client/flow.ts' import { apply as nodeApply } from '../src/index.ts' +const desktopIpc = vi.hoisted(() => ({ invoke: vi.fn() })) +vi.mock('../../../../apps/desktop/node_modules/electron/index.js', () => ({ + ipcRenderer: { invoke: desktopIpc.invoke }, + contextBridge: { exposeInMainWorld: (name: string, value: unknown) => { vi.stubGlobal(name, value) } }, +})) +vi.mock('../../../../apps/desktop/src/preload-platform.ts', () => ({ markDocumentPlatform: vi.fn() })) +vi.mock('../../../../apps/desktop/src/preload-theme.ts', () => ({ syncNativeTheme: vi.fn() })) + afterEach(() => { cleanup(); vi.unstubAllGlobals() }) const HOLES = ['conversation.hero.workspace.directoryFlow', 'sidebar.workspaces.directoryFlow'] as const @@ -149,6 +157,28 @@ describe('directory-picker-native client half', () => { expect(b.pickDirectory).toHaveBeenCalledOnce() }) + it('consumes the actual Desktop preload bridge and sends its directory-pick IPC', async () => { + vi.stubGlobal('location', new URL('dsh-app://app/')) + // Desktop's preload is typechecked by its own compiler program. + const preload = '../../../../apps/desktop/src/preload-app.ts' + await import(/* @vite-ignore */ preload) + desktopIpc.invoke.mockResolvedValue('/desktop/workspace') + const b = await bench() + const dispose = b.declare() + const fiber = b.ctx.plugin({ inject: [...inject], apply }) + try { + await fiber.await() + const entry = b.slots.entries(HOLES[0])[0]! + const injected = (entry.inject as () => { pick: () => Promise })() + await expect(injected.pick()).resolves.toBe('/desktop/workspace') + expect(desktopIpc.invoke).toHaveBeenCalledExactlyOnceWith('dsh-desktop:directory-pick') + expect(b.pickDirectory).not.toHaveBeenCalled() + } finally { + await fiber.dispose() + dispose() + } + }) + it('uses the desktop bridge without calling the Host and preserves cancellation and errors', async () => { const pick = vi.fn<() => Promise>().mockResolvedValue('/desktop/workspace') vi.stubGlobal('__DSH_DIRECTORY_PICKER__', { pick }) diff --git a/packages/test-support/client-runtime/tests/desktop-picker.client.spec.tsx b/packages/test-support/client-runtime/tests/desktop-picker.client.spec.tsx new file mode 100644 index 0000000000..d711d25225 --- /dev/null +++ b/packages/test-support/client-runtime/tests/desktop-picker.client.spec.tsx @@ -0,0 +1,32 @@ +// @vitest-environment jsdom +/** Desktop directory flow through the Web bundle roster and production client boot. */ +import { readFileSync } from 'node:fs' +import { afterEach, expect, vi } from 'vitest' +import { cleanup, render, waitFor } from '@testing-library/react' +import type { ComponentType } from 'react' +import type { DirectoryFlowOwnerProps } from '@deepseek-ai/dsh-client-ui-workspace/client' +import { ClientRoster, createClientTest, webApp } from '../src/assembly/index.ts' + +const manifest = JSON.parse(readFileSync('packages/client/ui-directory-picker-native/package.json', 'utf8')) as { + name: string + dsh: { client: { inject: string[] } } +} +// Auto mounts the native row dynamically; use that package's actual dependency declaration. +const test = createClientTest({ roster: ClientRoster.of([...webApp.rows, { + name: manifest.name, inject: manifest.dsh.client.inject, immediately: false, +}]) }) +afterEach(() => { cleanup(); vi.unstubAllGlobals() }) + +test('the composed native flow cancels through Desktop without invoking the Host chooser', async ({ start, remote }) => { + const pick = vi.fn<() => Promise>().mockResolvedValue(null) + vi.stubGlobal('__DSH_DIRECTORY_PICKER__', { pick }) + const client = await start() + const entry = client.ctx.slots.entries('sidebar.workspaces.directoryFlow')[0]! + const injected = (entry.inject as () => { pick: () => Promise })() + const Component = entry.component as ComponentType + const onCancel = vi.fn() + render() + await waitFor(() => { expect(onCancel).toHaveBeenCalledOnce() }) + expect(pick).toHaveBeenCalledOnce() + expect(remote.directoryPicker.pick).not.toHaveBeenCalled() +}, 60_000) diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 465d8eb813..0804913cc7 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -119,7 +119,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/client/ui-trajectory': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, 'packages/client/ui-workspace': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, 'packages/client/ui-directory-picker-browse': { kind: 'none', reason: 'Browser-side directory-browsing surface; registers nothing model-facing.' }, - 'packages/client/ui-directory-picker-native': { kind: 'none', reason: 'Browser-side surface driving the host OS chooser; registers nothing model-facing.' }, + 'packages/client/ui-directory-picker-native': { kind: 'none', reason: 'Browser-side surface driving the local Desktop or Host OS chooser; registers nothing model-facing.' }, 'packages/client/ui-theme': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, 'packages/client/ui-sidebar-documentpreview': { kind: 'none', reason: 'Browser-only text viewer; registers no tool, prompt section, or session event, and what the user reads never enters a model request.' }, 'packages/client/ui-sidebar-files': { kind: 'none', reason: 'Browser-only workspace file tree; registers no tool, prompt section, or session event.' }, From 48dd4b3bea9c87091de3db413312099be258b7cf Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Tue, 15 Sep 2026 20:08:49 +0800 Subject: [PATCH 08/15] test(desktop,client): keep picker composition coverage with its feature --- apps/desktop/src/preload-app.ts | 4 ++-- .../client/ui-directory-picker-native/README.i18n.yaml | 4 ++-- packages/client/ui-directory-picker-native/README.md | 4 ++-- .../client/ui-directory-picker-native/README.zh.md | 4 ++-- .../client/ui-directory-picker-native/package.json | 3 ++- .../tests/client-flow.client.spec.tsx | 10 ++++++++-- .../tests/desktop-picker.client.spec.tsx | 5 +++-- pnpm-lock.yaml | 3 +++ 8 files changed, 24 insertions(+), 13 deletions(-) rename packages/{test-support/client-runtime => client/ui-directory-picker-native}/tests/desktop-picker.client.spec.tsx (86%) diff --git a/apps/desktop/src/preload-app.ts b/apps/desktop/src/preload-app.ts index b8ad1869b6..2ab066cc81 100644 --- a/apps/desktop/src/preload-app.ts +++ b/apps/desktop/src/preload-app.ts @@ -1,11 +1,11 @@ /** Context-isolated application boot, local directory picker, and desktop carrier marker. */ import { contextBridge, ipcRenderer } from 'electron' -import { DESKTOP_IPC } from './ipc.ts' +import { DESKTOP_IPC, SCHEME } from './ipc.ts' import { markDocumentPlatform } from './preload-platform.ts' import { syncNativeTheme } from './preload-theme.ts' -if (location.protocol === 'dsh-app:' && location.hostname === 'app') { +if (location.protocol === `${SCHEME}:` && location.hostname === 'app') { contextBridge.exposeInMainWorld('__DSH_DIRECTORY_PICKER__', { pick: () => ipcRenderer.invoke(DESKTOP_IPC.directoryPick) as Promise, }) diff --git a/packages/client/ui-directory-picker-native/README.i18n.yaml b/packages/client/ui-directory-picker-native/README.i18n.yaml index b1a5bb087e..ba8fc1414d 100644 --- a/packages/client/ui-directory-picker-native/README.i18n.yaml +++ b/packages/client/ui-directory-picker-native/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/client/ui-directory-picker-native/README.md -README.md: c7adf9e68798c8bb8ead843847506ae39b6d11a3 -README.zh.md: ff4f96bc48b5db8ec5ce2f917513d57556dbd29c +README.md: 73115c441399fca56d0fd208307240226faf653a +README.zh.md: 5cf92adfb43eb3b0e3b7cc4e441b503e9a0757af diff --git a/packages/client/ui-directory-picker-native/README.md b/packages/client/ui-directory-picker-native/README.md index c7adf9e687..73115c4413 100644 --- a/packages/client/ui-directory-picker-native/README.md +++ b/packages/client/ui-directory-picker-native/README.md @@ -1,5 +1,5 @@ --- -description: "Native directory-picker surface: the browser half that drives the host OS chooser for workspace-directory flows; for users and maintainers choosing a picking interaction." +description: "Native directory-picker surface: the browser half that drives the local Desktop or Host OS chooser for workspace-directory flows; for users and maintainers choosing a picking interaction." kind: "package-reference" --- @@ -76,7 +76,7 @@ None; this package neither assembles nor sends a provider request. These limits define when the native chooser fits. They are current package constraints, not a general picker comparison or a task backlog. - **No cancellation of an open chooser** — the wire has no per-request abort, so a chooser already on the local display cannot be closed from the browser; a discarded settlement is ignored. -- **Local Host carriers only** — the Electron dialog selects local paths; ordinary Web opens the Host chooser. Remote-browser and in-process deployments use the `-browse` composition. Platform failures surface through the owner's retryable folder dialog. +- **Local carriers only** — the Electron dialog selects local paths; ordinary Web opens the Host chooser. Remote-browser and in-process deployments use the `-browse` composition. Platform failures surface through the owner's retryable folder dialog. - **Linux automatic selection** — without zenity or kdialog, the Host selects browse even in Desktop; the Electron dialog is not used. diff --git a/packages/client/ui-directory-picker-native/README.zh.md b/packages/client/ui-directory-picker-native/README.zh.md index ff4f96bc48..5cf92adfb4 100644 --- a/packages/client/ui-directory-picker-native/README.zh.md +++ b/packages/client/ui-directory-picker-native/README.zh.md @@ -1,5 +1,5 @@ --- -description: "原生目录选择表面:驱动 Host 操作系统选择器的浏览器半部,用于工作区目录流程;供选择拾取交互的用户与维护者阅读。" +description: "原生目录选择表面:驱动本地 Desktop 或 Host 操作系统选择器的浏览器半部,用于工作区目录流程;供选择拾取交互的用户与维护者阅读。" kind: "package-reference" --- @@ -76,7 +76,7 @@ kind: "package-reference" 这些限制界定了原生选择器的适用时机。它们是当前包约束,不是通用选择器对比或任务积压。 - **无法取消已打开的选择器**——wire 没有按请求中止的机制,因此已显示在本地的选择器无法从浏览器关闭;被丢弃的结算会被忽略。 -- **仅限本地 Host 承载**——Electron 对话框选择本地路径;普通 Web 打开 Host 选择器。远程浏览器与进程内部署使用 `-browse` 组合。平台失败经由持有方的可重试文件夹对话框呈现。 +- **仅限本地承载**——Electron 对话框选择本地路径;普通 Web 打开 Host 选择器。远程浏览器与进程内部署使用 `-browse` 组合。平台失败经由持有方的可重试文件夹对话框呈现。 - **Linux 自动选择**——缺少 zenity 或 kdialog 时,Host 即使在 Desktop 中也选择浏览模式,不使用 Electron 对话框。 diff --git a/packages/client/ui-directory-picker-native/package.json b/packages/client/ui-directory-picker-native/package.json index facf8cd78b..3f9fd7a43e 100644 --- a/packages/client/ui-directory-picker-native/package.json +++ b/packages/client/ui-directory-picker-native/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-client-ui-directory-picker-native", - "description": "Native directory-picker surface: the renderless workspace directory-flow occupant driving the host's OS chooser", + "description": "Native directory-picker surface: the renderless workspace directory-flow occupant driving the local Desktop or Host OS chooser", "version": "0.1.6-alpha.1", "publishConfig": { "access": "public" @@ -43,6 +43,7 @@ "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { + "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", "@testing-library/react": "^16.1.0", diff --git a/packages/client/ui-directory-picker-native/tests/client-flow.client.spec.tsx b/packages/client/ui-directory-picker-native/tests/client-flow.client.spec.tsx index 209266c1df..5e31689057 100644 --- a/packages/client/ui-directory-picker-native/tests/client-flow.client.spec.tsx +++ b/packages/client/ui-directory-picker-native/tests/client-flow.client.spec.tsx @@ -9,8 +9,14 @@ import { apply, inject } from '../src/client/index.ts' import { NativeDirectoryFlow } from '../src/client/flow.ts' import { apply as nodeApply } from '../src/index.ts' -const desktopIpc = vi.hoisted(() => ({ invoke: vi.fn() })) -vi.mock('../../../../apps/desktop/node_modules/electron/index.js', () => ({ +const desktopIpc = await vi.hoisted(async () => { + const { createRequire } = await import('node:module') + const path = await import('node:path') + // Electron belongs to the Desktop app; resolve its mock from that workspace. + const electron = createRequire(path.resolve(import.meta.dirname, '../../../../apps/desktop/package.json')).resolve('electron') + return { invoke: vi.fn(), electron } +}) +vi.mock(desktopIpc.electron, () => ({ ipcRenderer: { invoke: desktopIpc.invoke }, contextBridge: { exposeInMainWorld: (name: string, value: unknown) => { vi.stubGlobal(name, value) } }, })) diff --git a/packages/test-support/client-runtime/tests/desktop-picker.client.spec.tsx b/packages/client/ui-directory-picker-native/tests/desktop-picker.client.spec.tsx similarity index 86% rename from packages/test-support/client-runtime/tests/desktop-picker.client.spec.tsx rename to packages/client/ui-directory-picker-native/tests/desktop-picker.client.spec.tsx index d711d25225..f2a51a04af 100644 --- a/packages/test-support/client-runtime/tests/desktop-picker.client.spec.tsx +++ b/packages/client/ui-directory-picker-native/tests/desktop-picker.client.spec.tsx @@ -1,13 +1,14 @@ // @vitest-environment jsdom /** Desktop directory flow through the Web bundle roster and production client boot. */ import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' import { afterEach, expect, vi } from 'vitest' import { cleanup, render, waitFor } from '@testing-library/react' import type { ComponentType } from 'react' import type { DirectoryFlowOwnerProps } from '@deepseek-ai/dsh-client-ui-workspace/client' -import { ClientRoster, createClientTest, webApp } from '../src/assembly/index.ts' +import { ClientRoster, createClientTest, webApp } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts' -const manifest = JSON.parse(readFileSync('packages/client/ui-directory-picker-native/package.json', 'utf8')) as { +const manifest = JSON.parse(readFileSync(resolve(import.meta.dirname, '../package.json'), 'utf8')) as { name: string dsh: { client: { inject: string[] } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 015fc0c1a2..361805bbcc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3080,6 +3080,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../../test-support/client-runtime '@deepseek-ai/dsh-client-ui-renderer': specifier: workspace:^ version: link:../ui-renderer From 21c782867843b960d8138444d678fb33b14b72a3 Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Tue, 15 Sep 2026 20:26:18 +0800 Subject: [PATCH 09/15] docs(desktop,client): align native directory picker summaries --- apps/desktop/README.i18n.yaml | 4 ++-- apps/desktop/README.md | 2 +- apps/desktop/README.zh.md | 2 +- packages/client/README.i18n.yaml | 4 ++-- packages/client/README.md | 2 +- packages/client/README.zh.md | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index fc26671fdf..181c0f2a50 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: 122498ad310b4ac803b80249066ddcbe4247005d -README.zh.md: d7a37e111f311b11f98253308fe760ea5d6637b2 +README.md: ae353ab4fb540e1f27ed54f9edeb0bec29aa38a6 +README.zh.md: 4e81695aafe5e7a2be54d43b8cab72b387b9a4bc diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 122498ad31..ae353ab4fb 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) The desktop application is an Electron shell around the complete dsh Web application. An Electron RunAsNode child starts the shared profile runner, and Electron immediately loads the packaged Web entry at `dsh-app://app/`. Its shared loading page waits for Host boot injections, then starts the client without navigating to another document. Electron forwards application HTTP requests to the authenticated Web Host; WebSocket streams connect to that Host with credentials attached only for the owned application window. Node IPC carries boot injections, readiness, and shutdown. Desktop defaults to port `19387`, separate from Web’s `3080`; a `webserver.config.port` patch can override it. -Desktop’s local native directory flow opens an Electron folder dialog attached to the application window, restoring and focusing that window first. Concurrent requests share one dialog; cancellation returns no path and failures remain retryable. Ordinary Web uses the Host chooser. Browse mode lists Host directories. On Linux without zenity or kdialog, automatic selection uses browse instead of the Electron dialog. +Desktop’s local native directory flow opens an Electron folder dialog attached to the application window, restoring, showing, and focusing that window first. Concurrent requests share one dialog; cancellation returns no path and failures remain retryable. Ordinary Web uses the Host chooser. Browse mode lists Host directories. On Linux without zenity or kdialog, automatic selection uses browse instead of the Electron dialog. ## Key technical decisions diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index d7a37e111f..4e81695aaf 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -4,7 +4,7 @@ 桌面应用是完整 dsh Web 应用外的一层 Electron 壳。Electron RunAsNode 子进程启动共享 profile runner,Electron 立即从 `dsh-app://app/` 加载打包内的 Web 入口。共享加载页等待 Host 启动注入,然后在同一文档中启动客户端。Electron 将应用 HTTP 请求转发给已认证的 Web Host;WebSocket 流连接到该 Host,仅为归属的应用窗口附加凭据。Node IPC 承载启动注入、就绪与关闭。Desktop 默认使用端口 `19387`,与 Web 的 `3080` 分开;可通过 `webserver.config.port` patch 覆盖。 -Desktop 的本地原生目录流程打开绑定应用窗口的 Electron 文件夹对话框,并先恢复和聚焦该窗口。并发请求共用一个对话框;取消不返回路径,失败后可以重试。普通 Web 使用 Host 选择器。浏览模式列出 Host 目录。Linux 缺少 zenity 或 kdialog 时,自动选择使用浏览模式,不使用 Electron 对话框。 +Desktop 的本地原生目录流程打开绑定应用窗口的 Electron 文件夹对话框,并先恢复、显示和聚焦该窗口。并发请求共用一个对话框;取消不返回路径,失败后可以重试。普通 Web 使用 Host 选择器。浏览模式列出 Host 目录。Linux 缺少 zenity 或 kdialog 时,自动选择使用浏览模式,不使用 Electron 对话框。 ## 关键技术决策 diff --git a/packages/client/README.i18n.yaml b/packages/client/README.i18n.yaml index d8bd6f1f2a..38f772c499 100644 --- a/packages/client/README.i18n.yaml +++ b/packages/client/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/client/README.md -README.md: 42b9fc8bf7592a9e9d47281cf11c602a09028136 -README.zh.md: 5e135c26ec37579026352402b73cc021eafcbc68 +README.md: 6df95c24c8e9a70f8beb0e1cdd066c9f0facae2b +README.zh.md: 12024690b09e4a013dae3b6dcffa58e2040d9251 diff --git a/packages/client/README.md b/packages/client/README.md index 42b9fc8bf7..6df95c24c8 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -77,7 +77,7 @@ The kernel packages boot and serve the page; the UI feature packages present it. | [`ui-deliverables/`](ui-deliverables/README.md) | Produces the produced-files turn tail and clickable final-response file references | — | | [`ui-message-feedback/`](ui-message-feedback/README.md) | The feedback surface: per-message Like/Dislike in the assistant-message action strip, and the feedback dialog behind both ratings and `/feedback` | — | | [`ui-directory-picker-browse/`](ui-directory-picker-browse/README.md) | In-app directory browsing surface for the workspace directory flow | — | -| [`ui-directory-picker-native/`](ui-directory-picker-native/README.md) | Native directory-picker surface driving the host's OS chooser | — | +| [`ui-directory-picker-native/`](ui-directory-picker-native/README.md) | Native directory-picker surface driving the local Desktop or Host OS chooser | — | | [`ui-open-in-app/`](ui-open-in-app/README.md) | Session-header split button opening the workspace directory in an installed application | — | ----- diff --git a/packages/client/README.zh.md b/packages/client/README.zh.md index 5e135c26ec..12024690b0 100644 --- a/packages/client/README.zh.md +++ b/packages/client/README.zh.md @@ -77,7 +77,7 @@ kind: "package-group" | [`ui-deliverables/`](ui-deliverables/README.zh.md) | 生成已产出文件的轮次尾部与可点击的最终响应文件引用 | — | | [`ui-message-feedback/`](ui-message-feedback/README.zh.md) | 反馈界面:助手消息操作条中的逐消息赞踩,以及点赞、点踩与 `/feedback` 背后的反馈弹窗 | — | | [`ui-directory-picker-browse/`](ui-directory-picker-browse/README.zh.md) | 面向工作区目录流程的应用内目录浏览界面 | — | -| [`ui-directory-picker-native/`](ui-directory-picker-native/README.zh.md) | 驱动宿主 OS 选择器的原生目录选择界面 | — | +| [`ui-directory-picker-native/`](ui-directory-picker-native/README.zh.md) | 驱动本地 Desktop 或 Host OS 选择器的原生目录选择界面 | — | | [`ui-open-in-app/`](ui-open-in-app/README.zh.md) | 在已安装应用中打开工作区目录的会话标题栏拆分按钮 | — | ----- From ab695ef4cf4c798840506e45503ffd75c38cc47a Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 16 Sep 2026 13:52:23 +0800 Subject: [PATCH 10/15] feat(web): give user terminals system-user permissions --- ...-09-16-user-terminal-permissions.i18n.yaml | 6 ++ .../2026-09-16-user-terminal-permissions.md | 29 +++++++++ ...2026-09-16-user-terminal-permissions.zh.md | 29 +++++++++ .../2026-09-09-web-sidebar-terminal.i18n.yaml | 4 +- .../2026-09-09-web-sidebar-terminal.md | 4 +- .../2026-09-09-web-sidebar-terminal.zh.md | 4 +- .../tests/permission-policy-context.e2e.ts | 60 +++++++++++++++++-- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 4 +- docs/config-catalog.zh.md | 4 +- docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 2 +- docs/event-producer-consumer.zh.md | 2 +- docs/subsystems/workspace.i18n.yaml | 4 +- docs/subsystems/workspace.md | 2 +- docs/subsystems/workspace.zh.md | 2 +- .../api/terminal-controller/README.i18n.yaml | 4 +- packages/api/terminal-controller/README.md | 7 ++- packages/api/terminal-controller/README.zh.md | 7 ++- packages/api/terminal-controller/src/index.ts | 32 +++------- .../tests/controller.spec.ts | 53 ++++++++-------- .../ui-sidebar-terminal/README.i18n.yaml | 4 +- packages/client/ui-sidebar-terminal/README.md | 2 +- .../client/ui-sidebar-terminal/README.zh.md | 2 +- .../extensions/tool-cordis/src/api-catalog.ts | 2 +- 25 files changed, 185 insertions(+), 92 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-09-16-user-terminal-permissions.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-09-16-user-terminal-permissions.md create mode 100644 .agents/notes/implemented/architecture/2026-09-16-user-terminal-permissions.zh.md diff --git a/.agents/notes/implemented/architecture/2026-09-16-user-terminal-permissions.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-16-user-terminal-permissions.i18n.yaml new file mode 100644 index 0000000000..e8df386371 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-16-user-terminal-permissions.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-16-user-terminal-permissions.md +2026-09-16-user-terminal-permissions.md: f45b96419c34f8ebddc30431b42c9859c46de6b9 +2026-09-16-user-terminal-permissions.zh.md: 62bb152a1b899d026c8d7bddb350317ba12f14cf diff --git a/.agents/notes/implemented/architecture/2026-09-16-user-terminal-permissions.md b/.agents/notes/implemented/architecture/2026-09-16-user-terminal-permissions.md new file mode 100644 index 0000000000..f45b96419c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-16-user-terminal-permissions.md @@ -0,0 +1,29 @@ +# Agent Note: User-terminal permissions + +Status: implemented + +English | [中文](2026-09-16-user-terminal-permissions.zh.md) + +## Problem + +Users need to run commands themselves while keeping the Agent restricted. Sharing the Agent's sandbox mode forces a user to widen Agent access for manual work, and retaining an interactive shell prevents later mode changes because its process confinement cannot follow a new Session setting. + +## Decision + +The Web sidebar terminal runs directly through the Session's subprocess provider with the execution environment's system-user permissions. It neither confines the shell through the Agent sandbox nor requests Agent approval. Operating-system permissions, container isolation and the provider's credential-environment scrubbing continue to apply. Session identity owns access, process cleanup and the initial directory; sandbox policy supplies only the configured directory fallback when the Session has no cwd. + +Agent permission changes leave user terminals running. Agent-owned shell and terminal tools retain their own sandbox enforcement. User terminal input and output create no model input or Session events. + +This decision supersedes only the shared sandbox policy and mode-switch restriction in the [Web sidebar terminal decision](../feature/2026-09-09-web-sidebar-terminal.md). That note remains active for process ownership, transport, screen recovery and shell selection. OpenCode's `packages/core/src/pty.ts` and `packages/core/src/pty/pty.node.ts` provide adjacent evidence: its interactive terminal creates a PTY directly with the selected shell and working directory. + +## Alternatives considered + +**Inherit Agent permissions.** One Session mode describes both processes, but users must also grant the Agent access needed only for manual commands. Persistent user shells then obstruct changes to Agent permissions. + +**Add a separate terminal permission selector.** The product treats this terminal as a user-operated system shell. Another selector adds policy state and process-restart semantics without a current requirement; deployment and operating-system controls already determine the execution environment. + +## Consequences + +Access to the Web terminal grants command execution as the subprocess provider's system user, including writes outside the Session workspace where that user has permission. It does not grant root or escape a container. Session ownership remains useful for grouping and cleanup without implying Agent authority over user actions. + +Controller tests pin direct shell launch across all Agent sandbox modes and continued ownership during mode changes. The recorded Web permission-policy scenario keeps one real user PTY open across read-only, full-access and workspace-write transitions, verifies writes inside and outside the workspace, and retains the Agent's read-only denial and approval assertions. The Bash browser assertions run on macOS and Linux; Windows retains the portable controller checks and Agent-policy replay. diff --git a/.agents/notes/implemented/architecture/2026-09-16-user-terminal-permissions.zh.md b/.agents/notes/implemented/architecture/2026-09-16-user-terminal-permissions.zh.md new file mode 100644 index 0000000000..62bb152a1b --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-16-user-terminal-permissions.zh.md @@ -0,0 +1,29 @@ +# Agent Note: User-terminal permissions + +Status: implemented + +[English](2026-09-16-user-terminal-permissions.md) | 中文 + +## 问题 + +用户需要在限制 Agent(智能体)权限的同时亲自运行命令。共享 Agent 的沙箱模式会迫使用户为了手动操作而扩大 Agent 权限;保留交互式 shell 又会阻止后续模式切换,因为已有进程的沙箱限制无法跟随新的 Session 设置改变。 + +## 决策 + +Web 侧栏终端直接通过 Session 的 subprocess provider 运行,使用执行环境中系统用户的权限。它不通过 Agent 沙箱限制 shell,也不请求 Agent 审批。操作系统权限、容器隔离和 provider 对环境凭据的清除仍然生效。Session 标识负责访问范围、进程清理和初始目录;sandbox policy 仅在 Session 没有 cwd 时提供配置的默认目录。 + +改变 Agent 权限时,用户终端继续运行。Agent 使用的 shell 和 terminal 工具保留各自的沙箱限制。用户终端的输入和输出不产生模型输入或 Session 事件。 + +本决策仅取代 [Web 侧栏终端决策](../feature/2026-09-09-web-sidebar-terminal.zh.md)中的共享沙箱策略和模式切换限制。原记录继续负责进程所有权、传输、屏幕恢复和 shell 选择。OpenCode 的 `packages/core/src/pty.ts` 和 `packages/core/src/pty/pty.node.ts` 提供相邻实现依据:其交互式终端使用选定的 shell 和工作目录直接创建 PTY。 + +## 考虑过的替代方案 + +**继承 Agent 权限。** 一个 Session 模式可以描述两类进程,但用户必须同时授予 Agent 仅用于手动命令的权限。持久用户 shell 随之阻碍 Agent 权限切换。 + +**增加独立的终端权限选择器。** 产品将此终端视为用户操作的系统 shell。另一个选择器会增加策略状态和进程重启语义,目前没有对应需求;部署和操作系统控制已经确定执行环境。 + +## 影响 + +访问 Web 终端即可作为 subprocess provider 的系统用户执行命令,包括在该用户有权限时写入 Session 工作区之外的路径。它不会授予 root 权限或逃逸容器。Session 所有权继续用于分组和清理,不意味着 Agent 决定用户操作的权限。 + +Controller 测试覆盖全部 Agent 沙箱模式下的直接 shell 启动,以及模式改变后的持续所有权。录制的 Web 权限策略场景在只读、完全访问和工作区写入之间切换时保持同一个真实用户 PTY,验证工作区内外的写入,并保留 Agent 的只读拒绝和审批断言。Bash 浏览器断言在 macOS 和 Linux 运行;Windows 保留可移植的 controller 检查和 Agent 策略回放。 diff --git a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.i18n.yaml b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.i18n.yaml index 02e8712973..af7fc09ddb 100644 --- a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.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-09-web-sidebar-terminal.md -2026-09-09-web-sidebar-terminal.md: 2649610e776029b10b11fc4ea87d75a24a58d32b -2026-09-09-web-sidebar-terminal.zh.md: 9a3fd828ff52142cb945ff14d84d13c9e8522ecf +2026-09-09-web-sidebar-terminal.md: 5757aed3702dbcc7752e99912714bd1356a96206 +2026-09-09-web-sidebar-terminal.zh.md: 39870d1ba26cbc1d655b35a3d2dba61692d0fdb9 diff --git a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md index 2649610e77..5757aed370 100644 --- a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md +++ b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md @@ -14,7 +14,7 @@ Guide entries declare stable ids within their provider. A keyed `sidebar.right.t The application theme supplies terminal default colors. The body reads resolved CSS tokens, and updates xterm only when those colors change. Public OSC parser observers retain indexed and default-color overrides separately from the DSH defaults; resets remove the corresponding override before restoring the current theme. Observers delegate queries and color handling to xterm. xterm's minimum contrast adjustment improves text legibility without remapping ANSI backgrounds. The DOM cursor reads the rendered cell background after each render and uses a contrasting fill through scoped CSS variables, so cursor movement never resets the palette. Browser checks cover indexed, true-color and inverse cells, light/dark switching, OSC retention and reset, and blinking cursor styles. -`api-terminal-controller` owns user terminals by Session and exposes the `terminal` Remote namespace. `ui-sidebar-terminal` registers native right-sidebar tabs, xterm.js rendering and FitAddon sizing. The terminal guide card has a primary action for the remembered available shell and a separate installed-shell menu. Selecting a menu item records its path and opens a new terminal immediately; discovery alone allocates no process. Each tab owns its startup and close lifecycle. Host discovery verifies the configured candidates, with the execution default first; creation accepts only a currently discovered path. The browser remembers the last selected shell path in origin-scoped localStorage and falls back to the current default if that path is unavailable. The terminal type declares independent instances, so ordinary page deduplication cannot collapse separate processes when opening or docking tabs. The existing sidebar controls open additional tabs; double-clicking a tab title renames its terminal. Terminal processes use the composed subprocess provider and Session sandbox policy. Shell resolution occurs during discovery and creation; reading limits and reconnecting an existing process do not depend on the default executable remaining available. Interactive shell configuration supplies Tab completion and optional inline suggestions. +`api-terminal-controller` owns user terminals by Session and exposes the `terminal` Remote namespace. `ui-sidebar-terminal` registers native right-sidebar tabs, xterm.js rendering and FitAddon sizing. The terminal guide card has a primary action for the remembered available shell and a separate installed-shell menu. Selecting a menu item records its path and opens a new terminal immediately; discovery alone allocates no process. Each tab owns its startup and close lifecycle. Host discovery verifies the configured candidates, with the execution default first; creation accepts only a currently discovered path. The browser remembers the last selected shell path in origin-scoped localStorage and falls back to the current default if that path is unavailable. The terminal type declares independent instances, so ordinary page deduplication cannot collapse separate processes when opening or docking tabs. The existing sidebar controls open additional tabs; double-clicking a tab title renames its terminal. Terminal processes use the composed subprocess provider; [user-terminal permissions](../architecture/2026-09-16-user-terminal-permissions.md) govern their execution permissions independently of the Agent. Shell resolution occurs during discovery and creation; reading limits and reconnecting an existing process do not depend on the default executable remaining available. Interactive shell configuration supplies Tab completion and optional inline suggestions. Close and replacement remove the tab synchronously and run process cleanup in the background. The Client first records the unfinished close request under a terminal-specific localStorage key; success removes it, and startup retries requests that remain. A cleanup failure produces a lightweight notification with a retry action without reopening the tab. Independent keys prevent another window from overwriting unrelated cleanup requests. Collapse, tab/Session switching, floating, fullscreen and browser disconnection preserve the process. Component cleanup and `TabDomain.signal` only detach browser work because the same lifetime can end during plugin reload. Failed process cleanup retains ownership, including failures after allocation but before create publication. Session owner disposal and Host plugin disposal also clean up terminals. A definitive missing-Session response retires its saved close request because the Session owns process cleanup; transport failures remain retryable. Client plugin disposal awaits every detached stream so a replacement plugin does not inherit unfinished Client cleanup. @@ -44,7 +44,7 @@ The latest attachment controls input and dimensions; other attachments remain re ## Consequences -A kept-open terminal retains a process and bounded screen memory. Reload restores the sidebar layout and reconnects Host-retained terminals; Host restart does not restore processes. An exited shell remains visible without automatic respawn. Background cleanup may outlive its tab, and unavailable browser storage limits retry recovery to the current page. Native PTY support and descendant cleanup guarantees remain provider-specific. One writable attachment avoids competing resize and input streams, while explicit takeover permits recovery from another page. Changing sandbox mode requires closing retained terminals first. +A kept-open terminal retains a process and bounded screen memory. Reload restores the sidebar layout and reconnects Host-retained terminals; Host restart does not restore processes. An exited shell remains visible without automatic respawn. Background cleanup may outlive its tab, and unavailable browser storage limits retry recovery to the current page. Native PTY support and descendant cleanup guarantees remain provider-specific. One writable attachment avoids competing resize and input streams, while explicit takeover permits recovery from another page. User terminals remain open when the Session sandbox mode changes. The implementation retains the Agent-terminal and portable-execution notes because their ownership and provider decisions remain independently useful; neither is superseded by browser terminals. diff --git a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md index 9a3fd828ff..39870d1ba2 100644 --- a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md +++ b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md @@ -14,7 +14,7 @@ Web 用户需要在 Session 旁使用交互式 shell 检查工作区和运行命 应用主题提供终端的默认颜色。终端正文读取解析后的 CSS 令牌,仅在颜色变化时更新 xterm。公开的 OSC 解析观察器将索引色和默认颜色覆盖与 DSH 默认值分开保存;重置命令先删除对应覆盖,再恢复当前主题。观察器将查询和颜色处理交给 xterm。xterm 的最小对比度调整改善文字可读性,同时不重新映射 ANSI 背景色。DOM 光标在每次渲染后读取单元格实际背景,通过局部 CSS 变量使用有足够对比度的填充色,因此光标移动不会重置调色板。浏览器检查覆盖索引色、真彩色、反色单元格、明暗主题切换、OSC 保留和重置,以及闪烁光标样式。 -`api-terminal-controller` 按 Session 管理用户终端并提供 `terminal` Remote namespace。`ui-sidebar-terminal` 注册原生右侧栏标签页,使用 xterm.js 渲染和 FitAddon 测量尺寸。终端开始页卡片的主操作打开上次选择且仍可用的 shell,独立菜单提供已安装 shell。选择菜单项会记录路径并立即打开新终端;仅探测 shell 不分配进程。每个标签页拥有自己的启动和关闭生命周期。Host 探测会验证配置的候选,并把执行环境默认项放在首位;创建只接受当前探测返回的路径。浏览器在当前站点 localStorage 中记住上次选择的 shell 路径,该路径不可用时回到当前默认项。终端类型声明独立实例,因此打开或停靠标签页时,普通页面的去重规则不会合并不同进程。已有侧栏控件负责打开更多标签页,双击标签页标题可重命名终端。终端进程使用组合的 subprocess provider 和 Session sandbox policy。shell 在探测和创建时解析;读取限制和重新连接已有进程不依赖默认可执行文件仍然可用。交互式 shell 配置提供 Tab 补全和可选的内联建议。 +`api-terminal-controller` 按 Session 管理用户终端并提供 `terminal` Remote namespace。`ui-sidebar-terminal` 注册原生右侧栏标签页,使用 xterm.js 渲染和 FitAddon 测量尺寸。终端开始页卡片的主操作打开上次选择且仍可用的 shell,独立菜单提供已安装 shell。选择菜单项会记录路径并立即打开新终端;仅探测 shell 不分配进程。每个标签页拥有自己的启动和关闭生命周期。Host 探测会验证配置的候选,并把执行环境默认项放在首位;创建只接受当前探测返回的路径。浏览器在当前站点 localStorage 中记住上次选择的 shell 路径,该路径不可用时回到当前默认项。终端类型声明独立实例,因此打开或停靠标签页时,普通页面的去重规则不会合并不同进程。已有侧栏控件负责打开更多标签页,双击标签页标题可重命名终端。终端进程使用组合的 subprocess provider;[用户终端权限](../architecture/2026-09-16-user-terminal-permissions.zh.md)规定其独立于 Agent 的执行权限。shell 在探测和创建时解析;读取限制和重新连接已有进程不依赖默认可执行文件仍然可用。交互式 shell 配置提供 Tab 补全和可选的内联建议。 关闭和替换会同步移除标签页,并在后台清理进程。Client 先以终端独立的 localStorage key 保存未完成的关闭请求;成功后删除,启动时重试剩余请求。清理失败时显示带重试操作的轻量通知,不重新打开标签页。独立 key 避免其他窗口覆盖无关的清理请求。折叠、切换标签页或 Session、浮动、全屏和浏览器断线均保留进程。组件清理和 `TabDomain.signal` 只停止浏览器工作,因为插件重新加载也会结束这些生命周期。进程清理失败时保留所有权,包括分配完成但 create 尚未发布时的失败。Session owner 和 Host 插件卸载也会清理终端。 明确的 Session 不存在响应会清除已保存的关闭请求,因为进程清理由 Session 负责;传输失败仍可重试。Client 插件卸载等待所有断开的流结束,避免替换插件继承未完成的 Client 清理。 @@ -44,7 +44,7 @@ Web 用户需要在 Session 旁使用交互式 shell 检查工作区和运行命 ## 影响 -保留终端会保留进程和有界屏幕内存。刷新恢复侧栏布局并重连 Host 保留的终端;Host 重启不恢复进程。shell 退出后保持可见,不自动重启。后台清理可能比标签页存活更久,浏览器存储不可用时只能在当前页面保留重试能力。原生 PTY 支持和后代进程清理保证仍由 provider 决定。单一可写连接避免竞争的输入和尺寸流,显式接管允许从另一页面恢复操作。改变 sandbox mode 前需要关闭保留的终端。 +保留终端会保留进程和有界屏幕内存。刷新恢复侧栏布局并重连 Host 保留的终端;Host 重启不恢复进程。shell 退出后保持可见,不自动重启。后台清理可能比标签页存活更久,浏览器存储不可用时只能在当前页面保留重试能力。原生 PTY 支持和后代进程清理保证仍由 provider 决定。单一可写连接避免竞争的输入和尺寸流,显式接管允许从另一页面恢复操作。Session 的沙箱模式改变时,用户终端保持打开。 Agent 终端和可移植执行环境两篇记录仍保留,其所有权与 provider 决策继续独立有效,不被浏览器终端取代。 diff --git a/apps/web/tests/permission-policy-context.e2e.ts b/apps/web/tests/permission-policy-context.e2e.ts index f0b9448470..e946968359 100644 --- a/apps/web/tests/permission-policy-context.e2e.ts +++ b/apps/web/tests/permission-policy-context.e2e.ts @@ -3,7 +3,8 @@ // the real provider, while replay keeps the same provider-authored behavior // keyless. Assertions read the exact durable header, runtime-context messages, // and tool calls, so assistant prose alone cannot satisfy the scenario. -import { readFile } from 'node:fs/promises' +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import type { Browser, Page } from 'playwright' @@ -11,6 +12,8 @@ import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { canonicalPath } from '@deepseek-ai/dsh-sandbox' import type { SessionEvent } from '@deepseek-ai/dsh-session' +import type { WebTerminalId } from '@deepseek-ai/dsh-api-terminal-controller/types' +import type {} from '@deepseek-ai/dsh-api-terminal-controller' import { assertFinalWorkspaceSnapshot, assertFixtureInventory, fixtureUserPrompts, launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, @@ -65,10 +68,18 @@ describe('web e2e: current sandbox policy reaches the model before tools', () => let tripwire: ReturnType let disposeApproval: (() => void) | undefined let sessionWorkspace: string | undefined + let outsideWorkspace: string | undefined + let terminalId: WebTerminalId | undefined const sessionEvents: SessionEvent[] = [] beforeAll(async () => { - scaffold = await launchWebScaffold(MODE === 'record' ? {} : { replayFixture: FIXTURE, compareReplaySession: true }) + scaffold = await launchWebScaffold({ + ...MODE === 'record' ? {} : { replayFixture: FIXTURE, compareReplaySession: true }, + ...process.platform === 'win32' ? {} : { + extraOverlayPath: fileURLToPath(new URL('./fixtures/sidebar-terminal.patch.yml', import.meta.url)), + }, + }) + outsideWorkspace = await mkdtemp(join(tmpdir(), 'dsh-user-terminal-')) disposeApproval = scaffold.ctx.on('approval/request', () => Promise.resolve('allowed-once'), { prepend: true }) scaffold.ctx.on('session/event', (session, event: SessionEvent) => { sessionWorkspace = session.header.cwd @@ -83,11 +94,48 @@ describe('web e2e: current sandbox policy reaches the model before tools', () => }, 120_000) afterAll(async () => { - await browser?.close() - disposeApproval?.() - await scaffold?.close() + try { await browser?.close() } finally { + disposeApproval?.() + try { await scaffold?.close() } finally { + if (outsideWorkspace !== undefined) await rm(outsideWorkspace, { recursive: true, force: true }) + } + } }) + async function verifyUserTerminal(preset: string): Promise { + // The pinned interactive Bash profile is POSIX-only; Windows still replays every Agent policy assertion. + if (process.platform === 'win32') return + if (terminalId === undefined) { + const expand = page.locator('[data-sidebar-right-expand]') + if (await expand.isVisible()) await expand.click() + await page.locator('[data-sidebar-right-guide-entry="terminal"]').getByRole('button', { name: /^New terminal/u }).click() + await expect.poll(() => page.locator('.xterm-rows:visible').innerText()).toContain('bash-') + } + const agent = scaffold.ctx.agents.list()[0] + if (agent === undefined || sessionWorkspace === undefined || outsideWorkspace === undefined) throw new Error('Terminal test has no Session workspace') + const terminals = scaffold.ctx.terminalController.list(agent.id) + expect(terminals).toHaveLength(1) + terminalId ??= terminals[0]!.id + expect(terminals[0]).toMatchObject({ id: terminalId, state: 'running', cwd: sessionWorkspace }) + const outsideFile = join(outsideWorkspace, 'terminal-access.txt') + const quotedOutside = `'${outsideFile.replaceAll("'", "'\\''")}'` + const beforeInput = sessionEvents.length + await page.locator('.xterm-helper-textarea:visible').click() + await page.keyboard.insertText(`printf '%s' '${preset}' > terminal-access.txt; printf '%s' '${preset}' > ${quotedOutside}`) + await page.keyboard.press('Enter') + await expect.poll(() => readFile(join(sessionWorkspace!, 'terminal-access.txt'), 'utf8')).toBe(preset) + await expect.poll(() => readFile(outsideFile, 'utf8')).toBe(preset) + expect(sessionEvents).toHaveLength(beforeInput) + await page.keyboard.insertText('rm terminal-access.txt') + await page.keyboard.press('Enter') + await expect.poll(async () => { + try { await readFile(join(sessionWorkspace!, 'terminal-access.txt')); return false } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + return true + } + }).toBe(true) + } + it('switches read-only, danger-full-access, and workspace-write through the real GUI command path', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-permission-policy-context')) if (MODE !== 'record') { @@ -107,11 +155,13 @@ describe('web e2e: current sandbox policy reaches the model before tools', () => await input.press('Enter') sessionId = await settled await input.waitFor({ timeout: 10_000 }) + await verifyUserTerminal(preset) } await writeComposerDraft(page, input, '/permission read-only') await input.press('Enter') await page.getByRole('button', { name: 'Access mode, current: Read Only' }).waitFor({ timeout: 10_000 }) + await verifyUserTerminal('read-only') const settled = scaffold.whenTurnSettled() await writeComposerDraft(page, input, PROMPTS[3]) await input.press('Enter') diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index dca1dd45b8..aaab47c994 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: 57cb0c6b5db3177dfd4a777448cbd726a8e0957c -config-catalog.zh.md: b34da72c2f3496748073171aa0dd3cceefb019bc +config-catalog.md: 21172068c5be7c3e7515228a728517fe073bb328 +config-catalog.zh.md: 8ce16c5e502edb6e4571b50dc28711776f2912eb diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 57cb0c6b5d..21172068c5 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -233,7 +233,7 @@ Source: [`packages/api/settings-controller/src/index.ts:36`](../packages/api/set ## `@deepseek-ai/dsh-api-terminal-controller` -Requires: `subprocess` · `sandboxPolicy` · `sessionProjections` · `typert` +Requires: `subprocess` · `sandboxPolicy` · `typert` ```ts config-catalog /** Deployment limits and an optional shell profile. */ @@ -272,7 +272,7 @@ export interface Config { } ``` -Source: [`packages/api/terminal-controller/src/index.ts:28`](../packages/api/terminal-controller/src/index.ts) +Source: [`packages/api/terminal-controller/src/index.ts:26`](../packages/api/terminal-controller/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index b34da72c2f..8ce16c5e50 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -235,7 +235,7 @@ export interface Config { ## `@deepseek-ai/dsh-api-terminal-controller` -Requires: `subprocess` · `sandboxPolicy` · `sessionProjections` · `typert` +Requires: `subprocess` · `sandboxPolicy` · `typert` ```ts config-catalog /** Deployment limits and an optional shell profile. */ @@ -274,7 +274,7 @@ export interface Config { } ``` -来源: [`packages/api/terminal-controller/src/index.ts:28`](../packages/api/terminal-controller/src/index.ts) +来源: [`packages/api/terminal-controller/src/index.ts:26`](../packages/api/terminal-controller/src/index.ts) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 78b75271c4..18708d461b 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.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/event-producer-consumer.md -event-producer-consumer.md: db0f7d4bbe6a0e3a513863fb2d16f56cdeec0488 -event-producer-consumer.zh.md: e0f8db40dad9be12c0568e151dc0f40342f71b6d +event-producer-consumer.md: 7997fd2ed50db0e375ea0c40eb76cf402494bb06 +event-producer-consumer.zh.md: 2c3ddb928cae1a1e53822aaacdd09ed3ab2c2d46 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index db0f7d4bbe..7997fd2ed5 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -86,7 +86,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event string | Dispatchers | Listeners | | --- | --- | --- | -| `internal/dispatch` | - | `agent-team`, [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`schedule`](../packages/schedule/schedule), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`terminal-bash`](../packages/terminal/terminal-bash), `terminal-controller`, [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), `ui-renderer`, [`user-approval`](../packages/interaction/user-approval), [`webhook`](../packages/webhook/webhook), [`workflow`](../packages/workflow/workflow) | +| `internal/dispatch` | - | `agent-team`, [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`schedule`](../packages/schedule/schedule), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`terminal-bash`](../packages/terminal/terminal-bash), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), `ui-renderer`, [`user-approval`](../packages/interaction/user-approval), [`webhook`](../packages/webhook/webhook), [`workflow`](../packages/workflow/workflow) | | `internal/plugin` | - | `computer-use-cua-driver-native`, `inspector`, `loader`, [`lsp-stdio`](../packages/lsp/lsp-stdio), [`mcp-client`](../packages/mcp/mcp-client), `modules` | | `internal/service` | - | [`agent-presets`](../packages/preset/agent-presets), `gateway` | | `internal/status` | - | [`agent`](../packages/core/agent), `inspector`, [`web`](../packages/web/web) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index e0f8db40da..2c3ddb928c 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -88,7 +88,7 @@ | Event string | Dispatchers | Listeners | | --- | --- | --- | -| `internal/dispatch` | - | `agent-team`, [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`schedule`](../packages/schedule/schedule), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`terminal-bash`](../packages/terminal/terminal-bash), `terminal-controller`, [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), `ui-renderer`, [`user-approval`](../packages/interaction/user-approval), [`webhook`](../packages/webhook/webhook), [`workflow`](../packages/workflow/workflow) | +| `internal/dispatch` | - | `agent-team`, [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`schedule`](../packages/schedule/schedule), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`terminal-bash`](../packages/terminal/terminal-bash), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), `ui-renderer`, [`user-approval`](../packages/interaction/user-approval), [`webhook`](../packages/webhook/webhook), [`workflow`](../packages/workflow/workflow) | | `internal/plugin` | - | `computer-use-cua-driver-native`, `inspector`, `loader`, [`lsp-stdio`](../packages/lsp/lsp-stdio), [`mcp-client`](../packages/mcp/mcp-client), `modules` | | `internal/service` | - | [`agent-presets`](../packages/preset/agent-presets), `gateway` | | `internal/status` | - | [`agent`](../packages/core/agent), `inspector`, [`web`](../packages/web/web) | diff --git a/docs/subsystems/workspace.i18n.yaml b/docs/subsystems/workspace.i18n.yaml index 634591860d..b362e8d475 100644 --- a/docs/subsystems/workspace.i18n.yaml +++ b/docs/subsystems/workspace.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/subsystems/workspace.md -workspace.md: 93cce66801d123edd3a4b27e79f025c6935121a2 -workspace.zh.md: 9727c2efa1f0d1abc840f5ce9a377f57af01985a +workspace.md: 80ea3157dbd904e168e2809ce8aa0b8641c1b2d9 +workspace.zh.md: 727c612e58cdbe54b0ba0e6292803d4543e5a32e diff --git a/docs/subsystems/workspace.md b/docs/subsystems/workspace.md index 93cce66801..80ea3157db 100644 --- a/docs/subsystems/workspace.md +++ b/docs/subsystems/workspace.md @@ -214,7 +214,7 @@ Typed Remote control of transient Session-owned terminal processes. @Remote list(sessionId: SessionId): WebTerminalInfo[] /** - * Allocate an interactive shell once for a caller-generated identity. + * Allocate a user shell once for a caller-generated identity, without Agent sandbox or approval restrictions. * @param agent - Session owner supplied by the Gateway. * @param request - initial dimensions and idempotency identity. * @param signal - allocation cancellation; committed terminals survive disconnection. diff --git a/docs/subsystems/workspace.zh.md b/docs/subsystems/workspace.zh.md index 9727c2efa1..727c612e58 100644 --- a/docs/subsystems/workspace.zh.md +++ b/docs/subsystems/workspace.zh.md @@ -214,7 +214,7 @@ Typed Remote control of transient Session-owned terminal processes. @Remote list(sessionId: SessionId): WebTerminalInfo[] /** - * Allocate an interactive shell once for a caller-generated identity. + * Allocate a user shell once for a caller-generated identity, without Agent sandbox or approval restrictions. * @param agent - Session owner supplied by the Gateway. * @param request - initial dimensions and idempotency identity. * @param signal - allocation cancellation; committed terminals survive disconnection. diff --git a/packages/api/terminal-controller/README.i18n.yaml b/packages/api/terminal-controller/README.i18n.yaml index b286a982e1..9cd90a2565 100644 --- a/packages/api/terminal-controller/README.i18n.yaml +++ b/packages/api/terminal-controller/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/api/terminal-controller/README.md -README.md: bc73007faa1b846e327742d2bfd5b44dcd23737d -README.zh.md: 4dfd2181c518234df9517ae7afbeb57e4438b75c +README.md: dd93237a94f0cc9bdbd6e6d51f9a2762898399a7 +README.zh.md: b3bda5f4040ab6f9bb9cd74a8fd32f48dbdb1362 diff --git a/packages/api/terminal-controller/README.md b/packages/api/terminal-controller/README.md index bc73007faa..dd93237a94 100644 --- a/packages/api/terminal-controller/README.md +++ b/packages/api/terminal-controller/README.md @@ -25,9 +25,9 @@ Open the execution environment's default shell in a Session workspace from the W ## Use this package -The Web bundle mounts this package with the subprocess provider, sandbox policy and Typert Gateway. `remote.terminal` exposes `environment`, `shells`, `list`, `create`, `retain`, `follow`, `write`, `resize`, `rename` and `close`; each operation is scoped by Session identity. Listing reads retained Host terminals directly, so viewing an offline Session neither activates an Agent nor produces a recovery error. +The Web bundle mounts this package with the subprocess provider, sandbox policy and Typert Gateway. Sandbox policy supplies only the fallback working directory for Sessions without a cwd. `remote.terminal` exposes `environment`, `shells`, `list`, `create`, `retain`, `follow`, `write`, `resize`, `rename` and `close`; each operation is scoped by Session identity. Listing reads retained Host terminals directly, so viewing an offline Session neither activates an Agent nor produces a recovery error. -Shell discovery lists the execution environment's declared default shell first. Only when the provider omits that default does resolution use `/bin/sh` on POSIX or `cmd.exe` on Windows. An optional `shell` profile overrides that choice with executable `path`, display `name` and `args` (default `[]`). The selector also probes `shellCandidates` through the execution provider and omits only confirmed lookup misses. Creation accepts a discovered `shellPath` and verifies it again; resolution or transport failure is reported without launching a different shell. Environment lookup returns the working directory and limits without resolving a shell, so an unavailable default does not prevent reattaching to an existing process. Automatic POSIX profiles start interactively, and PowerShell uses `-NoLogo`, so completion and startup configuration remain shell-owned. The Session workspace supplies the initial directory; its sandbox policy also applies to the terminal. +Shell discovery lists the execution environment's declared default shell first. Only when the provider omits that default does resolution use `/bin/sh` on POSIX or `cmd.exe` on Windows. An optional `shell` profile overrides that choice with executable `path`, display `name` and `args` (default `[]`). The selector also probes `shellCandidates` through the execution provider and omits only confirmed lookup misses. Creation accepts a discovered `shellPath` and verifies it again; resolution or transport failure is reported without launching a different shell. Environment lookup returns the working directory and limits without resolving a shell, so an unavailable default does not prevent reattaching to an existing process. Automatic POSIX profiles start interactively, and PowerShell uses `-NoLogo`, so completion and startup configuration remain shell-owned. The Session workspace supplies the initial directory. User terminals run with the execution environment’s system-user permissions, independently of the Agent’s sandbox mode and approval policy. Operating-system and container restrictions still apply; DSH does not elevate the user. The subprocess provider retains its credential-environment scrubbing. | Configuration | Default | Meaning | |---|---|---| @@ -53,7 +53,7 @@ An open tab in any connected window retains its terminal, including hidden tabs The Host uses `ctx.subprocess.spawnTerminal` with `TERM=xterm-256color`; it never launches a desktop terminal application. Streaming UTF-8 decoding preserves split characters and leading BOMs, and replaces incomplete trailing bytes at EOF. Unary control uses the Gateway, and `follow` uses its multiplexed Remote stream transport. Headless xterm and its serializer produce each opening screen after all preceding output writes, then monotone output sequences identify subsequent frames. Slow followers fail explicitly; a new attachment restores the current screen. -The latest attachment owns input and resize. Detachment releases input control without killing the process. Explicit close awaits process cleanup and final output; cleanup failure retains the resource for retry. The Session remembers closed identities and rejects their delayed or repeated creation, including creation already in progress when close arrives. A new terminal uses a new identity. Pending allocations remain owned even if cancellation and cleanup both fail. Session owner disposal and controller disposal also terminate owned processes. An open or pending terminal prevents changing that Session's sandbox mode. Input or resize refused after control transfer or process exit leaves the output attachment intact and disables input; rejected input is not replayed. +The latest attachment owns input and resize. Detachment releases input control without killing the process. Explicit close awaits process cleanup and final output; cleanup failure retains the resource for retry. The Session remembers closed identities and rejects their delayed or repeated creation, including creation already in progress when close arrives. A new terminal uses a new identity. Pending allocations remain owned even if cancellation and cleanup both fail. Session owner disposal and controller disposal also terminate owned processes. Changing the Session’s sandbox mode leaves user terminals running with the same permissions. Input or resize refused after control transfer or process exit leaves the output attachment intact and disables input; rejected input is not replayed. The Client saves each Session/content-to-terminal association before allocation under its own `dsh.terminal.binding.v1.*` localStorage key. The content identity is globally unique; layout-local tab ids only identify live view occurrences. Independent record writes and deletes preserve other windows' bindings. Restored views reuse that identity; the sidebar terminal provider restores its views before querying unrepresented Host terminals. A new view may create a process, while a recovered view reports a missing target without creating a replacement. Explicit close removes the association after saving its cleanup request. The Host supplies current process metadata and screen contents; neither is saved in the browser. The Client model acknowledges screen writes after the browser emulator processes them, serializes input and ignores stale attachment responses. Client-owned errors carry locale keys. Plugin disposal awaits active and previously detached output streams without closing Host processes. @@ -70,6 +70,7 @@ Closing saves an unfinished cleanup request before releasing the tab, then await - [Subprocess](../../subprocess/subprocess/README.md) - [Right Sidebar](../../client/ui-sidebar-right/README.md) +- [User-terminal permissions](../../../.agents/notes/implemented/architecture/2026-09-16-user-terminal-permissions.md) - [Web terminal decision](../../../.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md) diff --git a/packages/api/terminal-controller/README.zh.md b/packages/api/terminal-controller/README.zh.md index 4dfd2181c5..b3bda5f404 100644 --- a/packages/api/terminal-controller/README.zh.md +++ b/packages/api/terminal-controller/README.zh.md @@ -25,9 +25,9 @@ kind: "package-reference" ## 使用此包 -Web bundle 将此包与 subprocess provider、sandbox policy 和 Typert Gateway 一起挂载。`remote.terminal` 提供 `environment`、`shells`、`list`、`create`、`retain`、`follow`、`write`、`resize`、`rename` 和 `close`;每个操作均按 Session 标识限定范围。列表直接读取 Host 保留的终端,因此查看离线 Session 不会激活 Agent,也不会产生恢复错误。 +Web bundle 将此包与 subprocess provider、sandbox policy 和 Typert Gateway 一起挂载。Sandbox policy 仅为没有 cwd 的 Session 提供默认工作目录。`remote.terminal` 提供 `environment`、`shells`、`list`、`create`、`retain`、`follow`、`write`、`resize`、`rename` 和 `close`;每个操作均按 Session 标识限定范围。列表直接读取 Host 保留的终端,因此查看离线 Session 不会激活 Agent,也不会产生恢复错误。 -Shell 探测结果首先列出执行环境声明的默认 shell。仅当 provider 未声明默认值时,才在 POSIX 使用 `/bin/sh`,在 Windows 使用 `cmd.exe`。可选的 `shell` profile 通过可执行路径 `path`、显示名称 `name` 和参数 `args`(默认 `[]`)覆盖这一选择。选择器还会通过执行 provider 探测 `shellCandidates`,仅省略确定未找到的候选。创建请求接受探测返回的 `shellPath` 并再次验证;解析或传输失败会直接报告,不启动其他 shell。环境查询只返回工作目录和限制,不解析 shell,因此默认 shell 不可用时仍可重新连接已有进程。POSIX 自动 profile 以交互模式启动,PowerShell 使用 `-NoLogo`,补全和启动配置仍由 shell 提供。初始目录来自 Session 工作区,终端遵循同一 sandbox policy。 +Shell 探测结果首先列出执行环境声明的默认 shell。仅当 provider 未声明默认值时,才在 POSIX 使用 `/bin/sh`,在 Windows 使用 `cmd.exe`。可选的 `shell` profile 通过可执行路径 `path`、显示名称 `name` 和参数 `args`(默认 `[]`)覆盖这一选择。选择器还会通过执行 provider 探测 `shellCandidates`,仅省略确定未找到的候选。创建请求接受探测返回的 `shellPath` 并再次验证;解析或传输失败会直接报告,不启动其他 shell。环境查询只返回工作目录和限制,不解析 shell,因此默认 shell 不可用时仍可重新连接已有进程。POSIX 自动 profile 以交互模式启动,PowerShell 使用 `-NoLogo`,补全和启动配置仍由 shell 提供。初始目录来自 Session 工作区。用户终端使用执行环境中系统用户的权限,独立于 Agent 的沙箱模式和审批策略。操作系统和容器的限制仍然生效;DSH 不提升用户权限。Subprocess provider 继续清除环境中的凭据变量。 | 配置 | 默认值 | 含义 | |---|---|---| @@ -53,7 +53,7 @@ Shell 探测结果首先列出执行环境声明的默认 shell。仅当 provide Host 通过 `ctx.subprocess.spawnTerminal` 创建 `TERM=xterm-256color` 的终端,不启动桌面终端应用。流式 UTF-8 解码保留跨块字符和开头的 BOM,并在 EOF 将不完整的尾部字节替换为替代字符。控制请求走 Gateway,`follow` 使用其复用的 Remote stream。Headless xterm 和序列化 addon 在此前输出写入后生成初始屏幕,后续增量携带单调序号。过慢的订阅者明确失败;重新连接恢复当前屏幕。 -最新连接持有输入和尺寸控制权。断开连接只释放输入权,不结束进程。显式关闭等待进程清理和最后输出;清理失败时保留资源以便重试。Session 记住已关闭的标识并拒绝迟到或重复的创建请求,包括关闭到达时仍在进行的创建。新终端使用新标识。取消创建且清理失败时,已分配的进程仍有所有者。Session owner 和 controller 卸载也会终止所拥有的进程。存在终端或创建请求时不能改变该 Session 的 sandbox mode。 控制权转移或进程退出后被拒绝的输入和尺寸请求保留输出连接并禁用输入,不重发被拒绝的输入。 +最新连接持有输入和尺寸控制权。断开连接只释放输入权,不结束进程。显式关闭等待进程清理和最后输出;清理失败时保留资源以便重试。Session 记住已关闭的标识并拒绝迟到或重复的创建请求,包括关闭到达时仍在进行的创建。新终端使用新标识。取消创建且清理失败时,已分配的进程仍有所有者。Session owner 和 controller 卸载也会终止所拥有的进程。改变 Session 的沙箱模式时,用户终端继续以原有权限运行。控制权转移或进程退出后被拒绝的输入和尺寸请求保留输出连接并禁用输入,不重发被拒绝的输入。 Client 在分配前将每条 Session/内容与终端身份的关联保存到独立的 localStorage key `dsh.terminal.binding.v1.*`。内容身份全局唯一;布局内的 tab id 只标识活动视图 occurrence。逐条记录的写入和删除会保留其他窗口的关联。恢复视图复用该身份;侧栏 terminal provider 先恢复自己的视图,再查询尚无视图的 Host 终端。新视图可以创建进程,恢复视图在目标缺失时显示错误,不创建替代进程。显式关闭先保存清理请求,再删除关联。当前进程元数据和屏幕内容由 Host 提供,不保存在浏览器中。Client 模型在浏览器完成屏幕解析后确认帧,按序发送输入,并忽略旧连接迟到的响应。Client 自产错误携带本地化键。插件卸载等待活跃及先前断开的输出流结束,不关闭 Host 进程。 @@ -70,6 +70,7 @@ Client 在分配前将每条 Session/内容与终端身份的关联保存到独 - [Subprocess](../../subprocess/subprocess/README.zh.md) - [Right Sidebar](../../client/ui-sidebar-right/README.zh.md) +- [用户终端权限](../../../.agents/notes/implemented/architecture/2026-09-16-user-terminal-permissions.zh.md) - [Web terminal decision](../../../.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md) diff --git a/packages/api/terminal-controller/src/index.ts b/packages/api/terminal-controller/src/index.ts index 7dce4a6fdb..24bf1a1f01 100644 --- a/packages/api/terminal-controller/src/index.ts +++ b/packages/api/terminal-controller/src/index.ts @@ -1,11 +1,9 @@ -/** Session-scoped browser terminals over the composed subprocess and sandbox providers. */ +/** Session-owned user terminals with the execution environment's system-user permissions. */ import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import type { Agent } from '@deepseek-ai/dsh-agent' -import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session' +import type { SessionId } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-sandbox-policy' -import type {} from '@deepseek-ai/dsh-sandbox' -import type {} from '@deepseek-ai/dsh-session-projection' import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' import { discoverShells, resolveShell } from './shells.ts' import { BrowserTerminal } from './terminal.ts' @@ -75,7 +73,7 @@ interface OwnedSession { /** Typed Remote control of transient Session-owned terminal processes. */ export class TerminalController extends TypertRemoteService { - static inject = ['subprocess', 'sandboxPolicy', 'sessionProjections', 'typert'] + static inject = ['subprocess', 'sandboxPolicy', 'typert'] static Config: z = z.object({ shell: z.union([z.object({ path: z.string().required(), name: z.string().required(), args: z.array(z.string()).default([]), @@ -102,15 +100,6 @@ export class TerminalController extends TypertRemoteService { */ constructor(ctx: Context, private readonly config: Config) { super(ctx, 'terminalController', { namespace: 'terminal' }) - ctx.on('internal/dispatch', (_mode, eventName, args) => { - if (eventName !== 'session/event') return - const [session, event] = args as [Session, SessionEvent] - if (event.type !== 'sandbox/mode') return - const owner = this.owners.get(session.id) - if (owner === undefined || owner.terminals.size + owner.pending.size + owner.allocations.size === 0) return - const current = ctx.sessionProjections.stateOf(session, 'sandboxMode') ?? ctx.sandboxPolicy.defaultMode - if (event.data.mode !== current) throw new Error('Close browser terminals before changing the Session sandbox mode') - }, { global: true }) ctx.effect(() => async () => { this.lifetime.abort(new Error('Terminal controller disposed')) const results = await Promise.allSettled([...this.owners].map(([id, owner]) => this.disposeOwner(id, owner))) @@ -129,7 +118,7 @@ export class TerminalController extends TypertRemoteService { environment(agent: Agent, signal: AbortSignal): TerminalEnvironment { signal.throwIfAborted() const { sandboxPolicy } = this.execution(agent) - return { cwd: sandboxPolicy.resolve({ session: agent.session }).workspaceRoot, + return { cwd: agent.session.header.cwd ?? sandboxPolicy.workspaceRoot, maxInputBytes: this.config.maxInputBytes, maxCols: this.config.maxCols, maxRows: this.config.maxRows, scrollback: this.config.scrollback } } @@ -159,7 +148,7 @@ export class TerminalController extends TypertRemoteService { } /** - * Allocate an interactive shell once for a caller-generated identity. + * Allocate a user shell once for a caller-generated identity, without Agent sandbox or approval restrictions. * @param agent - Session owner supplied by the Gateway. * @param request - initial dimensions and idempotency identity. * @param signal - allocation cancellation; committed terminals survive disconnection. @@ -349,20 +338,13 @@ export class TerminalController extends TypertRemoteService { private async spawn(agent: Agent, owner: OwnedSession, request: TerminalCreateRequest, signal: AbortSignal): Promise { const environment = this.environment(agent, signal) - const { subprocess, sandboxPolicy } = this.execution(agent) + const { subprocess } = this.execution(agent) const shell = request.shellPath === undefined ? await resolveShell(subprocess, this.config.shell, signal) : (await this.shells(agent, signal)).find(candidate => candidate.path === request.shellPath) if (shell === undefined) throw new Error('Selected shell is not available in this execution environment') - const policy = sandboxPolicy.resolve({ session: agent.session }) - let argv = [shell.path, ...shell.args] - if (policy.mode !== 'danger-full-access') { - const sandbox = agent.ctx.get('sandbox') - if (sandbox === undefined) throw new Error('The Session sandbox mode requires an execution sandbox provider') - argv = (await sandbox.confine(argv, { ...policy, mode: policy.mode }, signal)).argv - } const handle = await subprocess.spawnTerminal({ - argv, cwd: environment.cwd, cols: request.cols, rows: request.rows, + argv: [shell.path, ...shell.args], cwd: environment.cwd, cols: request.cols, rows: request.rows, terminalType: 'xterm-256color', env: { DSH_SESSION_ID: agent.id }, shellActivity: true, graceMs: this.config.disposeGraceMs, signal, diff --git a/packages/api/terminal-controller/tests/controller.spec.ts b/packages/api/terminal-controller/tests/controller.spec.ts index c52d442bc6..3d7f4434d1 100644 --- a/packages/api/terminal-controller/tests/controller.spec.ts +++ b/packages/api/terminal-controller/tests/controller.spec.ts @@ -1,4 +1,4 @@ -/** Session identity, allocation races, confinement and real PTY behavior. */ +/** Session identity, allocation races, human execution permissions and real PTY behavior. */ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -21,18 +21,16 @@ const id = 'test-terminal' as WebTerminalId const request = { id, cols: 80, rows: 24 } const signal = (): AbortSignal => new AbortController().signal -function owner(ctx: Context, id = 'session'): Agent { - return { id: id as SessionId, ctx, session: { id: id as SessionId } } as unknown as Agent +function owner(ctx: Context, id = 'session', cwd?: string): Agent { + return { id: id as SessionId, ctx, session: { id: id as SessionId, header: { cwd } } } as unknown as Agent } function fixture(overrides: Partial = {}) { const ctx = new Context() roots.push(ctx) const effects = vi.spyOn(ctx.fiber, 'effect') - const sandboxPolicy = { defaultMode: 'danger-full-access', resolve: vi.fn((): SandboxExecutionPolicy => ({ mode: 'danger-full-access', workspaceRoot: '/workspace' })) } - const projections = { stateOf: vi.fn((): SandboxMode | null => null) } + const sandboxPolicy = { defaultMode: 'danger-full-access', workspaceRoot: '/workspace', resolve: vi.fn((): SandboxExecutionPolicy => ({ mode: 'danger-full-access', workspaceRoot: '/workspace' })) } ctx.provide('sandboxPolicy', sandboxPolicy as never) - ctx.provide('sessionProjections', projections as never) const output = new PassThrough() const done = Promise.withResolvers<{ exitCode: number; signal: null }>() const handle = { @@ -50,7 +48,7 @@ function fixture(overrides: Partial = {}) { if (result?.type !== 'return' || typeof result.value !== 'function') throw new Error(`Missing effect: ${label}`) return result.value() } - return { ctx, agent: owner(ctx), controller, subprocess, handle, sandboxPolicy, projections, disposeEffect } + return { ctx, agent: owner(ctx), controller, subprocess, handle, sandboxPolicy, disposeEffect } } describe('TerminalController', () => { @@ -282,22 +280,23 @@ describe('TerminalController', () => { expect(controller.list(agent.id)).toEqual([]) }) - it('uses the Session sandbox policy to confine its selected shell', async () => { + it.each(['read-only', 'workspace-write', 'danger-full-access'] as const)('starts a user shell without confinement under %s Agent permissions', async (mode) => { const { controller, agent, ctx, sandboxPolicy, subprocess } = fixture() - const policy: SandboxExecutionPolicy = { mode: 'workspace-write', workspaceRoot: '/workspace', sessionId: agent.id } - sandboxPolicy.resolve.mockReturnValue(policy) + sandboxPolicy.resolve.mockReturnValue({ mode, workspaceRoot: '/workspace', sessionId: agent.id }) const confine = vi.fn((argv: readonly string[]) => ({ argv: ['sandbox-runner', ...argv] })) ctx.provide('sandbox', { confine } as never) await controller.create(agent, request, signal()) - expect(confine).toHaveBeenCalledWith(['/bin/bash', '--noprofile', '--norc', '-i'], policy, expect.any(AbortSignal)) - expect(subprocess.spawnTerminal).toHaveBeenCalledWith(expect.objectContaining({ argv: ['sandbox-runner', '/bin/bash', '--noprofile', '--norc', '-i'], env: { DSH_SESSION_ID: agent.id }, graceMs: 100 })) + expect(confine).not.toHaveBeenCalled() + expect(sandboxPolicy.resolve).not.toHaveBeenCalled() + expect(subprocess.spawnTerminal).toHaveBeenCalledWith(expect.objectContaining({ argv: ['/bin/bash', '--noprofile', '--norc', '-i'], env: { DSH_SESSION_ID: agent.id }, graceMs: 100 })) }) - it('rejects a confined Session without a sandbox provider before spawning', async () => { - const { controller, agent, sandboxPolicy, subprocess } = fixture() - sandboxPolicy.resolve.mockReturnValue({ mode: 'read-only', workspaceRoot: '/workspace' }) - await expect(controller.create(agent, request, signal())).rejects.toThrow('requires an execution sandbox provider') - expect(subprocess.spawnTerminal).not.toHaveBeenCalled() + it('uses the Session working directory without requiring a sandbox provider', async () => { + const { controller, ctx, subprocess } = fixture() + const agent = owner(ctx, 'workspace-session', '/another-workspace') + expect(controller.environment(agent, signal())).toMatchObject({ cwd: '/another-workspace' }) + await controller.create(agent, request, signal()) + expect(subprocess.spawnTerminal).toHaveBeenCalledWith(expect.objectContaining({ cwd: '/another-workspace' })) }) it.each(['subprocess', 'sandboxPolicy'] as const)('fails clearly when the Session lacks %s', (missing) => { @@ -309,20 +308,16 @@ describe('TerminalController', () => { expect(() => controller.environment(owner(isolated), signal())).toThrow('requires subprocess and sandbox policy providers') }) - it('blocks sandbox-mode changes only while that Session retains a terminal', async () => { - const { controller, agent, ctx, projections } = fixture() + it('allows Agent sandbox-mode changes while retaining the same user terminal', async () => { + const { controller, agent, ctx, subprocess, handle } = fixture() const mode = (mode: SandboxMode): void => { ctx.emit('session/event', agent.session, { type: 'sandbox/mode', data: { mode } } as SessionEvent) } - ctx.emit('session/disposed', agent.session) - ctx.emit('session/event', agent.session, { type: 'turn/start', data: { turn: 1 } } as SessionEvent) - expect(() => { mode('workspace-write') }).not.toThrow() await controller.create(agent, request, signal()) - expect(() => { mode('danger-full-access') }).not.toThrow() - expect(() => { mode('workspace-write') }).toThrow('Close browser terminals') - projections.stateOf.mockReturnValue('read-only') - expect(() => { mode('read-only') }).not.toThrow() - expect(() => { mode('danger-full-access') }).toThrow('Close browser terminals') - await controller.close(agent, id) - expect(() => { mode('workspace-write') }).not.toThrow() + for (const value of ['read-only', 'workspace-write', 'danger-full-access'] as const) { + expect(() => { mode(value) }).not.toThrow() + expect(controller.list(agent.id)).toMatchObject([{ id, state: 'running' }]) + } + expect(subprocess.spawnTerminal).toHaveBeenCalledOnce() + expect(handle.terminate).not.toHaveBeenCalled() }) it('terminates committed processes when the Session effect ends', async () => { diff --git a/packages/client/ui-sidebar-terminal/README.i18n.yaml b/packages/client/ui-sidebar-terminal/README.i18n.yaml index 86c048a703..58594b2566 100644 --- a/packages/client/ui-sidebar-terminal/README.i18n.yaml +++ b/packages/client/ui-sidebar-terminal/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/client/ui-sidebar-terminal/README.md -README.md: 37c3706996a7083c14f10e1b29603698f6dc8689 -README.zh.md: 45563a1d717ff51ffe371a6a894e4376466c6d7d +README.md: a874eb3ce4fed4e66893bf309a36330c355ce843 +README.zh.md: f54c86f24158a1dcf68e82189c74e700cd8e94c5 diff --git a/packages/client/ui-sidebar-terminal/README.md b/packages/client/ui-sidebar-terminal/README.md index 37c3706996..a874eb3ce4 100644 --- a/packages/client/ui-sidebar-terminal/README.md +++ b/packages/client/ui-sidebar-terminal/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Choose an installed shell from the right sidebar's Start page to run commands in the Session workspace. Rename terminals in their tabs and recover retained processes after reloading the page. Collapse the sidebar to keep commands running; close a terminal tab to request process termination. Tab completion follows the shell configuration. +Choose an installed shell from the right sidebar's Start page to run commands in the Session workspace. Rename terminals in their tabs and recover retained processes after reloading the page. Collapse the sidebar to keep commands running; close a terminal tab to request process termination. Tab completion follows the shell configuration. Commands use the execution environment’s system-user permissions independently of Agent permissions; see [user-terminal execution](../../api/terminal-controller/README.md#use-this-package). ## Table of Contents diff --git a/packages/client/ui-sidebar-terminal/README.zh.md b/packages/client/ui-sidebar-terminal/README.zh.md index 45563a1d71..f54c86f241 100644 --- a/packages/client/ui-sidebar-terminal/README.zh.md +++ b/packages/client/ui-sidebar-terminal/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -从右侧栏开始页选择已安装的 shell,在会话工作区运行命令。在标签页上重命名终端,并在刷新页面后恢复保留的进程。折叠侧栏让命令继续运行,关闭终端标签页则请求结束进程。Tab 补全使用 shell 的配置。 +从右侧栏开始页选择已安装的 shell,在会话工作区运行命令。在标签页上重命名终端,并在刷新页面后恢复保留的进程。折叠侧栏让命令继续运行,关闭终端标签页则请求结束进程。Tab 补全使用 shell 的配置。命令使用执行环境中系统用户的权限,独立于 Agent 权限;详见[用户终端执行](../../api/terminal-controller/README.zh.md#use-this-package)。 ## 目录 diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 8611059309..2327081660 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -2667,7 +2667,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: '@Remote async create(agent: Agent, request: TerminalCreateRequest, signal: AbortSignal): Promise', - description: 'Allocate an interactive shell once for a caller-generated identity.', + description: 'Allocate a user shell once for a caller-generated identity, without Agent sandbox or approval restrictions.', parameters: [{ name: 'agent', description: 'Session owner supplied by the Gateway.' }, { name: 'request', description: 'initial dimensions and idempotency identity.' }, { name: 'signal', description: 'allocation cancellation; committed terminals survive disconnection.' }], returns: 'the existing or newly committed terminal.', }, From 285da0fa64230198fc439a7ebfea8b0ccdadcf82 Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Wed, 16 Sep 2026 14:03:25 +0800 Subject: [PATCH 11/15] style(desktop): wrap dialog mock after rebase --- apps/desktop/tests/main-startup.spec.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts index 667428997d..9acec38543 100644 --- a/apps/desktop/tests/main-startup.spec.ts +++ b/apps/desktop/tests/main-startup.spec.ts @@ -89,7 +89,11 @@ const harness = await vi.hoisted(async () => { popup, socketHeaders: vi.fn(), menu: { setApplicationMenu: vi.fn(), buildFromTemplate: vi.fn(() => ({ popup })) }, - dialog: { showOpenDialog: vi.fn(), showErrorBox: vi.fn(), showMessageBox: vi.fn<(options: MessageBoxOptions) => Promise>() }, + dialog: { + showOpenDialog: vi.fn(), + showErrorBox: vi.fn(), + showMessageBox: vi.fn<(options: MessageBoxOptions) => Promise>(), + }, openExternal: vi.fn(), applyRelease: vi.fn(() => { preparing.resolve(); return prepared.promise }), mutateFailure: vi.fn<() => void>(), From 3e6d93171d590cbd97208d9f3738b42dbb0f635a Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Wed, 16 Sep 2026 14:03:41 +0800 Subject: [PATCH 12/15] Keep desktop package launchers out of the Host execution PATH --- ...11-desktop-electron-node-runtime.i18n.yaml | 4 ++-- ...026-09-11-desktop-electron-node-runtime.md | 2 +- ...-09-11-desktop-electron-node-runtime.zh.md | 2 +- apps/desktop/README.i18n.yaml | 4 ++-- apps/desktop/README.md | 2 +- apps/desktop/README.zh.md | 2 +- apps/desktop/src/main.ts | 3 +-- apps/desktop/src/node-environment.ts | 5 ++--- apps/desktop/tests/main-startup.spec.ts | 1 + apps/desktop/tests/node-environment.spec.ts | 19 +++++++++++++++++++ 10 files changed, 31 insertions(+), 13 deletions(-) create mode 100644 apps/desktop/tests/node-environment.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-09-11-desktop-electron-node-runtime.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-11-desktop-electron-node-runtime.i18n.yaml index f3cba229c7..d8b4bc7ee7 100644 --- a/.agents/notes/implemented/architecture/2026-09-11-desktop-electron-node-runtime.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-11-desktop-electron-node-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-11-desktop-electron-node-runtime.md -2026-09-11-desktop-electron-node-runtime.md: 6743bbfcc9acffb04d28ef9f1540d516b10dd2fc -2026-09-11-desktop-electron-node-runtime.zh.md: 73622e66183eb7ed94f654e861cb061e02be7a3d +2026-09-11-desktop-electron-node-runtime.md: 71516bb930f8eee22c1c6a988b6dab596a53a215 +2026-09-11-desktop-electron-node-runtime.zh.md: 162908392b98ee6a666ca414b602207ad8344937 diff --git a/.agents/notes/implemented/architecture/2026-09-11-desktop-electron-node-runtime.md b/.agents/notes/implemented/architecture/2026-09-11-desktop-electron-node-runtime.md index 6743bbfcc9..71516bb930 100644 --- a/.agents/notes/implemented/architecture/2026-09-11-desktop-electron-node-runtime.md +++ b/.agents/notes/implemented/architecture/2026-09-11-desktop-electron-node-runtime.md @@ -18,7 +18,7 @@ This supersedes the separate-Node choice in the [packaging decision](2026-08-25- Host and pnpm launches pass `--expose-internals`: the bundled Cordis loader uses Node's internal ESM loader, while its native builtin accessor cannot locate the required symbol in Electron 44. The explicit flag makes that loader available without modifying Cordis. The RunAsNode fuse remains enabled. -Package-script environments prepend a small `node` shell launcher that forwards arguments to the current Electron executable. This supports shell lifecycle scripts without a system Node installation. On Windows it is `node.cmd`, not a replacement `node.exe`; third-party code that directly spawns the literal `node` executable without a shell must use `process.execPath` or provide its own runtime. Child processes inherit RunAsNode; worker threads inherit the Host's arguments. Desktop does not emulate upstream OpenSSL behavior or rebuild arbitrary third-party native addons automatically. +The Host inherits the caller PATH without Desktop’s private `bin` directory, so PTC and agent shells cannot resolve internal launchers through that directory. `DSH_DESKTOP_NODE_EXECUTABLE` is injected only for package installation. Package-script environments prepend a small `node` shell launcher that forwards arguments to the current Electron executable. This supports shell lifecycle scripts without a system Node installation. On Windows it is `node.cmd`, not a replacement `node.exe`; third-party code that directly spawns the literal `node` executable without a shell must use `process.execPath` or provide its own runtime. Child processes inherit RunAsNode; worker threads inherit the Host's arguments. Desktop does not emulate upstream OpenSSL behavior or rebuild arbitrary third-party native addons automatically. Electron's Node patches and native ABI are release compatibility obligations. The packaged native smoke exercises pnpm shell scripts without system Node on PATH, terminal output through the Windows shell, Koffi, Sharp, and HTML conversion. The Host smoke loads an external plugin sharing Cordis and serves its route through the real Web application. Platform signing and installed-application qualification remain required; Windows results do not establish macOS compatibility. Windows token signing runs serially and retains the first failure, preventing queued tasks from repeating a rejected PIN. diff --git a/.agents/notes/implemented/architecture/2026-09-11-desktop-electron-node-runtime.zh.md b/.agents/notes/implemented/architecture/2026-09-11-desktop-electron-node-runtime.zh.md index 73622e6618..162908392b 100644 --- a/.agents/notes/implemented/architecture/2026-09-11-desktop-electron-node-runtime.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-11-desktop-electron-node-runtime.zh.md @@ -18,7 +18,7 @@ Desktop 通过自己的 Electron 可执行文件运行共享 Web Host 和内置 Host 和 pnpm 启动时传入 `--expose-internals`:内置 Cordis 加载器使用 Node 内部 ESM 加载器,而其原生 builtin 访问器无法在 Electron 44 中找到所需符号。显式参数使加载器可用,无需修改 Cordis。RunAsNode fuse 保持启用。 -包脚本环境在 PATH 前添加一个小型 `node` shell 启动器,把参数转发给当前 Electron 可执行文件。这支持没有系统 Node 的 shell 生命周期脚本。Windows 上它是 `node.cmd`,并非替代的 `node.exe`;第三方代码若绕过 shell 直接启动名为 `node` 的可执行文件,必须使用 `process.execPath` 或提供自己的运行时。子进程继承 RunAsNode;worker 线程继承 Host 参数。Desktop 不模拟上游 OpenSSL 行为,也不自动重编译任意第三方原生扩展。 +Host 继承调用者的 PATH,不加入 Desktop 私有的 `bin` 目录,因此 PTC 和 agent shell 不会通过该目录解析内部启动器。`DSH_DESKTOP_NODE_EXECUTABLE` 仅为包安装注入。包脚本环境在 PATH 前添加一个小型 `node` shell 启动器,把参数转发给当前 Electron 可执行文件。这支持没有系统 Node 的 shell 生命周期脚本。Windows 上它是 `node.cmd`,并非替代的 `node.exe`;第三方代码若绕过 shell 直接启动名为 `node` 的可执行文件,必须使用 `process.execPath` 或提供自己的运行时。子进程继承 RunAsNode;worker 线程继承 Host 参数。Desktop 不模拟上游 OpenSSL 行为,也不自动重编译任意第三方原生扩展。 Electron 的 Node 补丁和原生 ABI 属于发布兼容性责任。打包原生 smoke 在 PATH 不含系统 Node 的情况下验证 pnpm shell 脚本,并验证 Windows shell 终端输出、Koffi、Sharp 和 HTML 转换。Host smoke 加载共享 Cordis 的外部插件,通过真实 Web 应用提供其路由。各平台仍需完成签名和已安装应用验收;Windows 结果不能证明 macOS 兼容性。Windows Token 签名串行执行并保留首次失败,阻止排队任务重复提交被拒绝的 PIN。 diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index f21e458396..bc9e39fff0 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: 9075e86b0817853b28b28aa1cee45f35904a38d0 -README.zh.md: c25cc17a89ff65a7deedceb78778651b2d4a5c1f +README.md: caab5f0ba3cbc3be7a846b029f3cc10b36393fd9 +README.zh.md: 275fdf5e275961717f2d145dc5cb7a7b344b2088 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 9075e86b08..caab5f0ba3 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -18,7 +18,7 @@ Desktop carries independent Python, Node.js and pnpm distributions, with numpy a The payload follows the Desktop release. `runtime.json` records the Desktop version, target and component versions; a matching installation is reused, and a different release replaces the directory after a complete staged copy. Python packages added to that directory are retained within the same release and replaced with the application baseline on upgrade. A failed directory replacement retains the previous installation; Windows may refuse replacement while an interpreter is still running. -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. +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 and target-specific wheel URLs and hashes; pnpm follows the Desktop build dependency lock. The supported library wheels unpack directly into site-packages; wheels requiring other installation directories are rejected, and package command-line wrappers are not generated. Native-target checks execute the bundled interpreters and numpy/pandas operations 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. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index c25cc17a89..275fdf5e27 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -18,7 +18,7 @@ Desktop 携带独立的 Python、Node.js 和 pnpm 分发包,并在 Python 的 该产物随 Desktop 版本发布。`runtime.json` 记录 Desktop 版本、目标平台和组件版本;匹配的安装会被复用,版本不同时在完整暂存副本完成后替换目录。添加到该目录的 Python 包在同一版本内保留,升级时随应用基线一起替换。目录替换失败时保留之前的安装;解释器仍在运行时,Windows 可能拒绝替换。 -该工具不修改 PATH、环境变量或用户包管理器配置。pnpm 的全局包、命令入口和 store 保留自身默认值及用户设置,包括环境不支持全局安装时的原生错误。不提供独立依赖更新器。[第一方 Runtime 决策](../../.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.zh.md)记录这些选择。 +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)固定解释器压缩包及目标平台 wheel 的 URL 和哈希;pnpm 使用 Desktop 构建依赖锁。支持的库 wheel 直接解压到 site-packages;需要其他安装目录的 wheel 会被拒绝,不生成包的命令行包装器。本机目标检查在清理暂存目录后以及 macOS 签名后执行内置解释器及 numpy/pandas 运算。独立 Node 可执行文件获得 V8 所需的 JIT 权限。跨目标执行和签名安装需要对应的发布主机。`dev:desktop` 和 `start:desktop` 都会在启动 Electron 前准备 `.desktop-build/targets//runtime/primary-runtime`;首次准备可能需要下载锁定的依赖。准备未完成时,启动命令不能报告成功退出。 diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index fdcf251f89..0773159b8b 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -19,7 +19,6 @@ import { import { resolveDesktopPaths } from './paths.ts' import { DesktopProjectManager, type DesktopProjectHooks } from './project-manager.ts' import { DesktopHostProcess } from './host-process.ts' -import { desktopNodeEnvironment } from './node-environment.ts' import { DesktopBackendController } from './backend-controller.ts' import { DESKTOP_IPC, type DesktopUpdateState } from './ipc.ts' import { formatDesktopMessage, resolveDesktopLocale } from './locale.ts' @@ -226,7 +225,7 @@ async function main(): Promise { const backend = new DesktopBackendController((onFailure) => { const hostInspectPort = developmentHostInspectPort(development) const host = new DesktopHostProcess(resources.node, resources.dsh, activeProject, - hostInspectPort, desktopNodeEnvironment(resources.node, resources.nodeBin, process.env), onFailure, + hostInspectPort, process.env, onFailure, development ? join(app.getAppPath(), '.desktop-build', 'targets', `${process.platform === 'darwin' ? 'mac' : 'win'}-${process.arch}`, 'runtime', 'primary-runtime') : join(process.resourcesPath, 'runtime', 'primary-runtime'), development ? 'link' : 'runtime') diff --git a/apps/desktop/src/node-environment.ts b/apps/desktop/src/node-environment.ts index 828752b80d..680b4395b8 100644 --- a/apps/desktop/src/node-environment.ts +++ b/apps/desktop/src/node-environment.ts @@ -1,4 +1,4 @@ -/** Electron's Node mode inherited by the Host, pnpm, and their subprocesses. */ +/** Electron Node-mode startup, with private shell launchers scoped to package installation. */ import { delimiter } from 'node:path' @@ -13,7 +13,6 @@ export function desktopNodeEnvironment(executable: string, bin: string | undefin return { ...environment, ELECTRON_RUN_AS_NODE: '1', - DSH_DESKTOP_NODE_EXECUTABLE: executable, - ...(bin === undefined ? {} : { PATH: `${bin}${delimiter}${environment.PATH ?? ''}` }), + ...(bin === undefined ? {} : { DSH_DESKTOP_NODE_EXECUTABLE: executable, PATH: `${bin}${delimiter}${environment.PATH ?? ''}` }), } } diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts index 57c01a75fc..c6c2d70aab 100644 --- a/apps/desktop/tests/main-startup.spec.ts +++ b/apps/desktop/tests/main-startup.spec.ts @@ -456,6 +456,7 @@ describe('desktop main startup', () => { profileResolution: 'runtime', profile: 'desktop-test-profile', }) + expect(harness.hosts[0]!.environment).toBe(process.env) expect(harness.hosts[0]!.start).toHaveBeenCalledTimes(1) expect(harness.windows).toHaveLength(1) expect(window.urls).toEqual(['dsh-app://app/']) diff --git a/apps/desktop/tests/node-environment.spec.ts b/apps/desktop/tests/node-environment.spec.ts new file mode 100644 index 0000000000..5050e4fbec --- /dev/null +++ b/apps/desktop/tests/node-environment.spec.ts @@ -0,0 +1,19 @@ +import { delimiter } from 'node:path' +import { expect, it } from 'vitest' +import { desktopNodeEnvironment } from '../src/node-environment.ts' + +it('keeps private Desktop launchers out of the Host environment inherited by PTC', () => { + const environment = { PATH: '/user/bin', HOME: '/user' } + expect(desktopNodeEnvironment('/desktop/electron', undefined, environment)).toEqual({ + ...environment, ELECTRON_RUN_AS_NODE: '1', + }) + expect(environment).toEqual({ PATH: '/user/bin', HOME: '/user' }) +}) + +it('provides private launchers only to package installation processes', () => { + expect(desktopNodeEnvironment('/desktop/electron', '/desktop/bin', { PATH: '/user/bin' })).toEqual({ + ELECTRON_RUN_AS_NODE: '1', + DSH_DESKTOP_NODE_EXECUTABLE: '/desktop/electron', + PATH: `/desktop/bin${delimiter}/user/bin`, + }) +}) From 1f030b3c1c5aa7164a42f483075c80fe29559f41 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 16 Sep 2026 14:28:20 +0800 Subject: [PATCH 13/15] fix(llm): tolerate malformed historical Messages tool input --- ...6-messages-historical-tool-input.i18n.yaml | 6 +++ ...26-09-16-messages-historical-tool-input.md | 29 ++++++++++++ ...09-16-messages-historical-tool-input.zh.md | 29 ++++++++++++ ...-09-07-deepseek-messages-adapter.i18n.yaml | 4 +- .../2026-09-07-deepseek-messages-adapter.md | 2 +- ...2026-09-07-deepseek-messages-adapter.zh.md | 2 +- packages/llm/llm-deepseek/README.i18n.yaml | 4 +- packages/llm/llm-deepseek/README.md | 2 +- packages/llm/llm-deepseek/README.zh.md | 2 +- .../src/protocols/messages/serialize.ts | 10 ++-- .../tests/messages/adapter.e2e.ts | 21 ++++++++- .../tests/messages/fixtures/history.ts | 15 ++++++ .../tests/messages/serialize.spec.ts | 27 +++++++++-- .../cordis.snapshot.yml | 47 +++++++++++++++++++ .../cordis.yml | 3 ++ .../session.v3.jsonl | 22 +++++++++ .../snapshot.yml | 10 ++++ 17 files changed, 217 insertions(+), 18 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-09-16-messages-historical-tool-input.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-09-16-messages-historical-tool-input.md create mode 100644 .agents/notes/implemented/bug-fix/2026-09-16-messages-historical-tool-input.zh.md create mode 100644 packages/llm/llm-deepseek/tests/messages/fixtures/history.ts create mode 100644 snapshots/session/deepseek-messages-invalid-tool-history/cordis.snapshot.yml create mode 100644 snapshots/session/deepseek-messages-invalid-tool-history/cordis.yml create mode 100644 snapshots/session/deepseek-messages-invalid-tool-history/session.v3.jsonl create mode 100644 snapshots/session/deepseek-messages-invalid-tool-history/snapshot.yml diff --git a/.agents/notes/implemented/bug-fix/2026-09-16-messages-historical-tool-input.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-16-messages-historical-tool-input.i18n.yaml new file mode 100644 index 0000000000..6bb2fcf803 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-16-messages-historical-tool-input.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/bug-fix/2026-09-16-messages-historical-tool-input.md +2026-09-16-messages-historical-tool-input.md: 201b0f21ad041395a4c4101d6919b2878e22c9ab +2026-09-16-messages-historical-tool-input.zh.md: bcc637982420124b401ef65c668b068394b6d915 diff --git a/.agents/notes/implemented/bug-fix/2026-09-16-messages-historical-tool-input.md b/.agents/notes/implemented/bug-fix/2026-09-16-messages-historical-tool-input.md new file mode 100644 index 0000000000..201b0f21ad --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-16-messages-historical-tool-input.md @@ -0,0 +1,29 @@ +# Agent Note: Replay malformed historical tool input through Messages + +Status: implemented + +English | [中文](2026-09-16-messages-historical-tool-input.zh.md) + +## Problem + +Chat Completions retains tool arguments as strings, including malformed JSON from failed calls. Switching that history to Messages requires an object for each `tool_use.input`. Rejecting one historical argument blocks every later request containing it, even after a successful tool retry; a summarization request containing the same call also fails. + +## Decision + +The [Messages serializer](../../../../packages/llm/llm-deepseek/src/protocols/messages/serialize.ts) follows the [pi-ai history conversion](../../../../packages/llm/llm-pi-ai/src/replay.ts): malformed JSON and non-object values become `{}` only in the outgoing historical tool input. Call ids, names, results, and original Session records remain intact. This applies with valid, absent, or unusable native replay metadata and does not execute the historical call again. + +This supersedes the historical argument rejection in the [Messages adapter decision](../feature/2026-09-07-deepseek-messages-adapter.md). New Messages responses still require valid object arguments before successful completion; output-limit truncation retains its existing pruning behavior. No Session event, persistence type, or protocol configuration changes. + +## Alternatives considered + +**Reject malformed history.** A failed call can remain relevant evidence without preventing all subsequent model requests. + +**Repair or overwrite stored arguments.** Guessing missing quotes or retaining a parsed prefix can change the requested operation. Request-only empty input preserves the original evidence and requires no migration. + +**Drop the call.** Its result still cites the call id; keeping both preserves the tool exchange without inventing arguments. + +## Consequences + +Messages continuation can omit unusable historical parameters without losing the call identity or result. The model sees `{}` rather than the original malformed text, and the fallback is silent, matching pi-ai. Original arguments remain available in the Session log; this does not claim lossless provider input or repair invalid newly generated calls. + +Verification covers object-only conversion, failed results followed by user input, JSON round trips, both valid and degraded replay metadata, a [recorded Session](../../../../snapshots/session/deepseek-messages-invalid-tool-history/snapshot.yml) through the shipped headless profile and real Messages serializer, and a credential-gated live Messages continuation. diff --git a/.agents/notes/implemented/bug-fix/2026-09-16-messages-historical-tool-input.zh.md b/.agents/notes/implemented/bug-fix/2026-09-16-messages-historical-tool-input.zh.md new file mode 100644 index 0000000000..bcc6379824 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-16-messages-historical-tool-input.zh.md @@ -0,0 +1,29 @@ +# Agent Note: 通过 Messages 回放非法历史工具输入 + +Status: implemented + +[English](2026-09-16-messages-historical-tool-input.md) | 中文 + +## 问题 + +Chat Completions 将工具参数保留为字符串,其中可能包含失败调用产生的非法 JSON。将这段历史切换到 Messages 时,每个 `tool_use.input` 都必须是对象。拒绝一条历史参数就会阻断包含它的所有后续请求,即使工具重试已经成功;包含同一调用的摘要请求也会失败。 + +## 决策 + +[Messages 序列化器](../../../../packages/llm/llm-deepseek/src/protocols/messages/serialize.ts) 遵循 [pi-ai 历史转换](../../../../packages/llm/llm-pi-ai/src/replay.ts)的做法:只在发出的历史工具输入中,将非法 JSON 和非对象值替换为 `{}`。调用 ID、名称、结果和原始 Session 记录保持不变。原生回放元数据有效、缺失或不可用时均采用此规则,也不会重新执行历史调用。 + +这取代了 [Messages 适配器决策](../feature/2026-09-07-deepseek-messages-adapter.zh.md)中的历史参数拒绝规则。新生成的 Messages 响应在成功完成前仍要求工具参数是有效对象;达到输出上限时仍按现有规则裁剪。不改变 Session 事件、持久化类型或协议配置。 + +## 考虑过的替代方案 + +**拒绝非法历史。** 失败调用可以继续作为相关证据保留,而不必阻断所有后续模型请求。 + +**修复或覆盖已存参数。** 猜测缺失的引号或保留部分解析结果可能改变请求的操作。只在请求中使用空输入可以保留原始证据,也不需要迁移。 + +**删除调用。** 对应结果仍引用调用 ID;同时保留调用和结果,可以保留工具交互而不编造参数。 + +## 后果 + +Messages 可以在省略不可用历史参数的同时继续会话,并保留调用身份和结果。模型看到的是 `{}`,而不是原始非法文本;该兜底与 pi-ai 一样不产生诊断。原始参数仍可在 Session 日志中查阅;这不保证提供方输入无损,也不修复新生成的非法调用。 + +验证覆盖仅接受对象的转换、失败结果后的用户输入、JSON 往返、有效与降级的回放元数据、通过已发布 headless profile 和真实 Messages 序列化器运行的[录制 Session](../../../../snapshots/session/deepseek-messages-invalid-tool-history/snapshot.yml),以及需要凭据的真实 Messages 续接。 diff --git a/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.i18n.yaml b/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.i18n.yaml index bee006748b..ddba9ebf1e 100644 --- a/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.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-07-deepseek-messages-adapter.md -2026-09-07-deepseek-messages-adapter.md: 6b7aff250aacd4bb7d0af3b4e68ee5b2002c13e3 -2026-09-07-deepseek-messages-adapter.zh.md: 829e2189cb48a2acaa1ec27f7ccade0f163b58ad +2026-09-07-deepseek-messages-adapter.md: c667ec3afdd9956282a532e748ad2e2488d26f5b +2026-09-07-deepseek-messages-adapter.zh.md: 0e3425e41d8a9585a1638eedeefcf6ead76a5a78 diff --git a/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.md b/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.md index 6b7aff250a..c667ec3afd 100644 --- a/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.md +++ b/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.md @@ -14,7 +14,7 @@ The [DeepSeek adapter](../../../../packages/llm/llm-deepseek/README.md) serves m The adapter follows the [DeepSeek compatibility documentation](https://api-docs.deepseek.com/zh-cn/guides/anthropic_api) and [Anthropic streaming protocol](https://platform.claude.com/docs/en/build-with-claude/streaming). The pi-ai Anthropic implementation informed the handling of adjacent user messages, cumulative usage, fragmented tool arguments, and optional thinking signatures. DeepSeek effort uses `output_config.effort`; an Anthropic thinking token budget does not control DeepSeek effort. Both protocols forward explicit `temperature` values; DeepSeek accepts that parameter with thinking enabled and ignores its value, so callers retain their existing thinking configuration. -Assistant blocks remain the durable model-visible content. A versioned `ReplayEnvelope` stores only the protocol format, model identity, aligned block kinds, and signatures absent from those blocks. Same-model Messages continuation restores signatures verbatim, including empty signatures; foreign history carries no invented signature. Unusable metadata follows the existing [replay degradation rule](../architecture/2026-07-14-provider-routed-llm-adapters.md): the request omits signatures with a warning while preserving durable content; content validation such as tool argument parsing still fails explicitly. This keeps provider replay data opaque to the loop while preserving it through Session persistence and block pruning. +Assistant blocks remain the durable model-visible content. A versioned `ReplayEnvelope` stores only the protocol format, model identity, aligned block kinds, and signatures absent from those blocks. Same-model Messages continuation restores signatures verbatim, including empty signatures; foreign history carries no invented signature. Unusable metadata follows the existing [replay degradation rule](../architecture/2026-07-14-provider-routed-llm-adapters.md): the request omits signatures with a warning while preserving durable content; historical tool arguments use the [empty-input fallback](../bug-fix/2026-09-16-messages-historical-tool-input.md) when Messages cannot represent them. This keeps provider replay data opaque to the loop while preserving it through Session persistence and block pruning. Both protocols prefer Files references for deterministic request images and share upload caching, refresh, quota recovery, and attachment offload. The Files client retains the selected protocol and configured endpoint: Messages follows the [exact `/v1` root rule](../bug-fix/2026-09-15-messages-v1-base-url.md), while Chat Completions appends `/files`. Messages Files requests carry the required beta header. Cached ids remain scoped by the resolved Files root and credential, so equivalent `/v1` and unversioned Messages roots share uploads. Messages metadata omits expiry, so local reuse is bounded from the original upload time without asserting remote deletion. A Files-resolution failure rebuilds the complete request under the independent inline-image budget; caller cancellation stops it. The shared image policy preserves the 128 MiB retained-image budget, 20 MiB inline base64 budget, and oldest-prefix offload in both requests and token measurement. diff --git a/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.zh.md b/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.zh.md index 829e2189cb..0e3425e41d 100644 --- a/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.zh.md +++ b/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.zh.md @@ -14,7 +14,7 @@ Status: implemented 适配器遵循 [DeepSeek 兼容文档](https://api-docs.deepseek.com/zh-cn/guides/anthropic_api) 和 [Anthropic 流协议](https://platform.claude.com/docs/en/build-with-claude/streaming)。pi-ai 的 Anthropic 实现为相邻用户消息、累计用量、工具参数分片和可选思考签名的处理提供参考。DeepSeek 通过 `output_config.effort` 设置思考强度;Anthropic 思考 token 预算不控制 DeepSeek 思考强度。两种协议都转发显式 `temperature` 值;DeepSeek 在启用思考时接受该参数但忽略其值,因此调用方可以保留已有思考配置。 -助手内容块保留持久化的模型可见内容。带版本的 `ReplayEnvelope` 仅保存协议格式、模型标识、对齐的块类型以及内容块未包含的签名。同模型续接原样恢复签名,包括空签名;外部历史不生成虚构签名。不可用的元数据遵循现有[回放降级规则](../architecture/2026-07-14-provider-routed-llm-adapters.zh.md):请求省略签名并记录警告,保留持久化内容;工具参数等内容校验仍会正常报错。提供者回放数据对循环保持不透明,同时能够随 Session 持久化和内容块裁剪保留。 +助手内容块保留持久化的模型可见内容。带版本的 `ReplayEnvelope` 仅保存协议格式、模型标识、对齐的块类型以及内容块未包含的签名。同模型续接原样恢复签名,包括空签名;外部历史不生成虚构签名。不可用的元数据遵循现有[回放降级规则](../architecture/2026-07-14-provider-routed-llm-adapters.zh.md):请求省略签名并记录警告,保留持久化内容;Messages 无法表示历史工具参数时使用[空输入兜底](../bug-fix/2026-09-16-messages-historical-tool-input.zh.md)。提供者回放数据对循环保持不透明,同时能够随 Session 持久化和内容块裁剪保留。 两种协议均优先为确定性请求图片使用 Files 引用,并共享上传缓存、刷新、配额恢复和附件卸载。Files 客户端保留所选协议与已配置端点:Messages 遵循[严格匹配 `/v1` 的根地址规则](../bug-fix/2026-09-15-messages-v1-base-url.zh.md),Chat Completions 则追加 `/files`。Messages Files 请求携带必需的 beta 标头。缓存 id 按解析后的 Files 根地址和凭据限定作用域,因此等价的 `/v1` 与无版本 Messages 根地址可以复用上传。Messages 元数据不含过期时间,因此本地复用从原始上传时间起受限,但不宣称远端文件已删除。Files 解析失败会按独立的内联图片预算重建完整请求;调用方取消则停止请求。共享图片策略在请求与 token 计量中保留 128 MiB 的保留图片预算、20 MiB 的内联 base64 预算,以及最旧前缀卸载。 diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index 134ca62d40..f3498bf0c3 100644 --- a/packages/llm/llm-deepseek/README.i18n.yaml +++ b/packages/llm/llm-deepseek/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/llm/llm-deepseek/README.md -README.md: f332d59b0fe63e5129e5205fbba1ab1901b34d7f -README.zh.md: 26ce84e2497c637cbfe42bbbaf72fc07f27c3e82 +README.md: 18f1b3de48d554dbc2225cfcadf4900f8bb17b09 +README.zh.md: 38d288d233a84c3bbf8170d560ea42d372fab3fc diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index f332d59b0f..18f1b3de48 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -171,7 +171,7 @@ Read these pages when the package-level contract is not enough. They move from t #### What the model sees -The selected DeepSeek model receives the harness system prompt, message history, tool schemas, stop sequences, and call config (`maxTokens`, `reasoningEffort`, `temperature`) without adapter-authored prompt prose. Provider-specific request-extension fields remain outside model input. The vision model normally receives retained user and tool-result images as Files API references beside attachment handles and request-preview dimensions. It also receives a normalized-object path when the current execution filesystem maps the attachment provider's host object; the descriptor marks this copy read-only and warns that normalization may have resized or re-encoded the upload. A Files resolution failure sends all retained images as inline base64 instead, and an over-budget older image keeps the access resolved for that request in its placeholder. Reasoning content from a prior assistant turn is passed back verbatim, whether or not that turn called a tool. +The selected DeepSeek model receives the harness system prompt, message history, tool schemas, stop sequences, and call config (`maxTokens`, `reasoningEffort`, `temperature`) without adapter-authored prompt prose. Provider-specific request-extension fields remain outside model input. The vision model normally receives retained user and tool-result images as Files API references beside attachment handles and request-preview dimensions. It also receives a normalized-object path when the current execution filesystem maps the attachment provider's host object; the descriptor marks this copy read-only and warns that normalization may have resized or re-encoded the upload. A Files resolution failure sends all retained images as inline base64 instead, and an over-budget older image keeps the access resolved for that request in its placeholder. Reasoning content from a prior assistant turn is passed back verbatim, whether or not that turn called a tool. Messages sends `{}` for historical tool arguments that are malformed JSON or are not objects. Call ids, tool names, and results remain intact; the original arguments stay in the Session log. This silent fallback also applies after switching from Chat Completions. Newly generated Messages tool arguments still require valid JSON objects. #### Token effect diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index 26ce84e249..38d288d233 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -171,7 +171,7 @@ Files 模式通过 `maxRequestFilesBytes` 与 `maxImagesPerRequest` 限制保留 #### 模型看到什么 -所选 DeepSeek 模型会收到 harness 系统提示词、消息历史、工具 schema、停止序列与调用配置(`maxTokens`、`reasoningEffort`、`temperature`),不包含适配器撰写的提示词散文。提供方专用请求扩展字段留在模型输入之外。视觉模型通常接收 Files API 引用形式的用户与工具结果图片,其旁带附件句柄和请求预览尺寸。当前执行文件系统可以映射附件提供方的宿主对象时,它还会收到规范化对象路径;描述符会把该副本标记为只读,并警告规范化可能缩放或重新编码上传内容。Files 解析失败时,全部保留图片改用内联 base64;超出预算的较旧图片则在占位文本中保留当前请求已解析的访问方式。此前 assistant 轮次的推理内容会原样传回,无论该轮次是否调用了工具。 +所选 DeepSeek 模型会收到 harness 系统提示词、消息历史、工具 schema、停止序列与调用配置(`maxTokens`、`reasoningEffort`、`temperature`),不包含适配器撰写的提示词散文。提供方专用请求扩展字段留在模型输入之外。视觉模型通常接收 Files API 引用形式的用户与工具结果图片,其旁带附件句柄和请求预览尺寸。当前执行文件系统可以映射附件提供方的宿主对象时,它还会收到规范化对象路径;描述符会把该副本标记为只读,并警告规范化可能缩放或重新编码上传内容。Files 解析失败时,全部保留图片改用内联 base64;超出预算的较旧图片则在占位文本中保留当前请求已解析的访问方式。此前 assistant 轮次的推理内容会原样传回,无论该轮次是否调用了工具。 对于非法 JSON 或非对象的历史工具参数,Messages 发送 `{}`。调用 ID、工具名和结果保持不变,原始参数仍保留在 Session 日志中。从 Chat Completions 切换后也适用此静默兜底。新生成的 Messages 工具参数仍须是有效 JSON 对象。 #### Token 影响 diff --git a/packages/llm/llm-deepseek/src/protocols/messages/serialize.ts b/packages/llm/llm-deepseek/src/protocols/messages/serialize.ts index 19869288eb..a7cd5bcd89 100644 --- a/packages/llm/llm-deepseek/src/protocols/messages/serialize.ts +++ b/packages/llm/llm-deepseek/src/protocols/messages/serialize.ts @@ -5,20 +5,22 @@ import type { ContentBlock, GenerateOptions, ImageAttachmentAccessResolver, Mess import type { ImageAttachmentRef, RequestImageAttachment } from '@deepseek-ai/dsh-attachment' import type { DeepSeekConnectionOptions as Connection } from '../../common/types.ts' import type { DeepSeekFileId } from '../../common/file-id.ts' -import { object, readReplay } from './replay.ts' +import { readReplay } from './replay.ts' import type { WireBlock, WireInput, WireMessage, WireRequest } from './types.ts' function unsupported(type: string): never { throw new LlmError(`DeepSeek Messages cannot represent ${type}`, 'UNSUPPORTED_CONTENT') } -/** Parse tool input only when constructing an outgoing native tool_use block. */ +/** Historical arguments that Messages cannot represent use empty input; durable content stays unchanged. */ function toolInput(raw: string): Record { let value: unknown try { value = JSON.parse(raw) } catch (_invalidToolHistoryJson) { - throw new LlmError('DeepSeek Messages historical tool input is invalid JSON', 'INVALID_REQUEST') + return {} } - return object(value, 'INVALID_REQUEST') + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? value as Record + : {} } function assistant(message: Message, model: string, onReplayDegrade?: (reason: string) => void): WireBlock[] { diff --git a/packages/llm/llm-deepseek/tests/messages/adapter.e2e.ts b/packages/llm/llm-deepseek/tests/messages/adapter.e2e.ts index 3665655b5e..fd085e373c 100644 --- a/packages/llm/llm-deepseek/tests/messages/adapter.e2e.ts +++ b/packages/llm/llm-deepseek/tests/messages/adapter.e2e.ts @@ -12,7 +12,7 @@ import Loader from '@deepseek-ai/cordis-plugin-loader' import AgentRegistry from '@deepseek-ai/dsh-agent' import LocalAttachments from '@deepseek-ai/dsh-attachment-local' import DeepSeekLlmApiExtensionRegistry from '@deepseek-ai/dsh-deepseek-llm-api-extensions' -import LlmRuntime, { BlockAssembler, createSystemMessage, createToolResultMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm' +import LlmRuntime, { BlockAssembler, createAssistantMessage, createSystemMessage, createToolResultMessage, ReasoningEffortId, ToolCallId } from '@deepseek-ai/dsh-llm' import type { Message } from '@deepseek-ai/dsh-llm' import * as PluginPackageInventoryDeepSeek from '@deepseek-ai/dsh-plugin-package-inventory-deepseek' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' @@ -225,6 +225,25 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('DeepSeek Messages real API', () expect(third.assembler.finish.kind).toBe('stop') }) + it('continues persisted foreign history containing malformed tool arguments', async () => { + const ctx = await boot() + const callId = ToolCallId('historical_lookup') + const history = [ + user('Look up the secret value.'), + createAssistantMessage({ source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, content: [ + { type: 'tool-call', id: callId, name: 'lookup_value', arguments: '{"key":"the "secret""}' }, + ] }), + createToolResultMessage({ callId, content: [{ type: 'text', text: 'Invalid arguments: expected an object' }], isError: true }), + user('Do not retry the lookup. Reply with exactly HISTORY_RECOVERED_731.'), + ] + const saved = JSON.stringify(history) + const restored = JSON.parse(saved) as Message[] + const response = await assemble(ctx.llm.stream(options({ messages: restored, tools: [tool], reasoningEffort: ReasoningEffortId('off') }))) + expect(response.assembler.finish.kind).toBe('stop') + expect(response.message.content.filter(block => block.type === 'text').map(block => block.text).join('')).toContain('HISTORY_RECOVERED_731') + expect(JSON.stringify(restored)).toBe(saved) + }) + it('cancels an active stream without committing a successful response', async () => { const ctx = await boot() const controller = new AbortController() diff --git a/packages/llm/llm-deepseek/tests/messages/fixtures/history.ts b/packages/llm/llm-deepseek/tests/messages/fixtures/history.ts new file mode 100644 index 0000000000..4c5b78c6d1 --- /dev/null +++ b/packages/llm/llm-deepseek/tests/messages/fixtures/history.ts @@ -0,0 +1,15 @@ +/** Exercise Messages request conversion with recorded responses from another protocol. */ +import type { Context } from '@deepseek-ai/cordis' +import { resolveAdapterOptions } from '../../../src/config.ts' +import { serialize } from '../../../src/protocols/messages/serialize.ts' + +export const name = 'messages-history-snapshot' +export const inject = ['llm'] + +export function apply(ctx: Context): void { + const connection = resolveAdapterOptions({}) + ctx.on('llm/stream', (options, next) => { + serialize(options, connection, options.messages, new Map(), () => undefined) + return next() + }) +} diff --git a/packages/llm/llm-deepseek/tests/messages/serialize.spec.ts b/packages/llm/llm-deepseek/tests/messages/serialize.spec.ts index 168f912d7b..6e5d755202 100644 --- a/packages/llm/llm-deepseek/tests/messages/serialize.spec.ts +++ b/packages/llm/llm-deepseek/tests/messages/serialize.spec.ts @@ -149,8 +149,20 @@ describe('Messages request conversion', () => { expect(() => body(messages)).toThrow(/tool/) }) - it.each(['{', '[]'])('rejects invalid historical tool input %s', (arguments_) => { - expect(() => body([assistant([{ type: 'tool-call', id: ToolCallId('a'), name: 'read', arguments: arguments_ }]), result()])).toThrow() + it.each(['{', '', '[]', 'null', '42', 'true', '"text"', '{"description":"最快,但"某个说法"没有证据。"}'])('uses empty input for malformed or non-object historical tool arguments %s', (arguments_) => { + const message = assistant([{ type: 'tool-call', id: ToolCallId('a'), name: 'read', arguments: arguments_ }]) + const history = [user(), message, createToolResultMessage({ callId: ToolCallId('a'), content: [{ type: 'text', text: 'Invalid arguments' }], isError: true }), user('Continue')] + const saved = JSON.stringify(history) + const restored = JSON.parse(saved) as Message[] + expect(body(restored).messages).toEqual([ + { role: 'user', content: [{ type: 'text', text: 'hello' }] }, + { role: 'assistant', content: [{ type: 'tool_use', id: 'a', name: 'read', input: {} }] }, + { role: 'user', content: [ + { type: 'tool_result', tool_use_id: 'a', content: [{ type: 'text', text: 'Invalid arguments' }], is_error: true }, + { type: 'text', text: 'Continue' }, + ] }, + ]) + expect(JSON.stringify(restored)).toBe(saved) }) it('preserves own signed thinking, omits absent signatures and validates durable metadata', () => { @@ -214,11 +226,16 @@ describe('Messages request conversion', () => { expect(() => readReplay(damaged, MODEL, () => { throw failure })).toThrow(failure) }) - it('still rejects invalid tool JSON after discarding unusable replay metadata', () => { + it.each([1, 2])('uses empty historical tool input with replay version %s', (version) => { const message = createAssistantMessage({ content: [{ type: 'tool-call', id: ToolCallId('a'), name: 'read', arguments: '{' }], source: { - provider: 'deepseek-official', model: MODEL, replayState: { response: {}, blocks: [] }, + provider: 'deepseek-official', model: MODEL, replayState: { response: { kind: 'deepseek-messages', version, model: MODEL }, blocks: [{ type: 'tool-call' }] }, } }) - expect(() => body([message, result()])).toThrow(/historical tool input is invalid JSON/) + const saved = JSON.stringify(message) + const onDegrade = vi.fn() + const request = serialize(options(), connection, [message, result()], new Map(), () => undefined, onDegrade) + expect(request.messages[0]?.content).toEqual([{ type: 'tool_use', id: 'a', name: 'read', input: {} }]) + expect(onDegrade).toHaveBeenCalledTimes(version === 1 ? 0 : 1) + expect(JSON.stringify(message)).toBe(saved) }) }) diff --git a/snapshots/session/deepseek-messages-invalid-tool-history/cordis.snapshot.yml b/snapshots/session/deepseek-messages-invalid-tool-history/cordis.snapshot.yml new file mode 100644 index 0000000000..92bac46628 --- /dev/null +++ b/snapshots/session/deepseek-messages-invalid-tool-history/cordis.snapshot.yml @@ -0,0 +1,47 @@ +# Replay patch shared by the ordinary headless snapshot composition. The model +# script comes from the scenario's committed session JSONL. + +- id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + disabled: true + +- id: plugin-package-inventory-deepseek + disabled: true + +- id: session-title-llm + disabled: true + +- id: session-persistence-jsonl + name: '@deepseek-ai/dsh-session-persistence-jsonl' + config: + root: !!js dshHomePath('sessions') + compression: none + +- id: sandbox + name: '@deepseek-ai/dsh-sandbox-local' + config: + runnerCommand: + - bash + - -c + - while [ "$1" != "--" ]; do shift; done; shift; exec "$@" + - passthrough-runner + runnerFailureSignatures: + - 'passthrough-runner: profile rejected' + +- insert: + - id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' + config: + providers: + - id: deepseek-official + name: DeepSeek + models: + - id: deepseek-v4-flash + - id: deepseek-v4-pro + - id: deepseek-messages + name: DeepSeek Messages + models: + - id: deepseek-v4-flash + - id: deepseek-v4-pro + - id: messages-history-snapshot + name: '../../../packages/llm/llm-deepseek/tests/messages/fixtures/history.ts' diff --git a/snapshots/session/deepseek-messages-invalid-tool-history/cordis.yml b/snapshots/session/deepseek-messages-invalid-tool-history/cordis.yml new file mode 100644 index 0000000000..55b26d1e56 --- /dev/null +++ b/snapshots/session/deepseek-messages-invalid-tool-history/cordis.yml @@ -0,0 +1,3 @@ +- insert: + - id: messages-history-snapshot + name: '../../../packages/llm/llm-deepseek/tests/messages/fixtures/history.ts' diff --git a/snapshots/session/deepseek-messages-invalid-tool-history/session.v3.jsonl b/snapshots/session/deepseek-messages-invalid-tool-history/session.v3.jsonl new file mode 100644 index 0000000000..97d3f985d4 --- /dev/null +++ b/snapshots/session/deepseek-messages-invalid-tool-history/session.v3.jsonl @@ -0,0 +1,22 @@ +{"type":"session","version":3,"id":"{{session:1}}","createdAt":1783352050748,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0} +{"type":"permission/preset","data":{"preset":"danger-full-access"}} +{"type":"sandbox/mode","data":{"mode":"danger-full-access"}} +{"type":"approval/policy","data":{"policy":"never"}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use bash to print a quoted word. If the tool rejects the arguments, do not retry; reply DONE."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"}]}} +{"type":"turn/start","data":{"turn":1}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","data":{"turn":1,"step":1}} +{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{message:2}}"}},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Use bash to print a quoted word. If the tool rejects the arguments, do not retry; reply DONE."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"{{message:3}}"},"surfaceOp":"append"} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"session/title","data":{"title":"Use bash to print a","messageSeqs":[8],"source":{"kind":"fallback"}}} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"I will ask bash to print a quoted word."},{"type":"tool-call","id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","arguments":"{\"command\":\"echo \"QUOTED\"\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:4}}"},"usage":{"inputTokens":2877,"outputTokens":90,"cacheReadTokens":0,"totalTokens":2967},"stream":[{"type":"chunk","time":1788882845159,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1788882845159,"index":0,"dt":[],"texts":["I will ask bash to print a quoted word."]},{"type":"chunk","time":1788882845160,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1788882845160,"index":1,"dt":[],"id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","args":["{\"command\":\"echo \"QUOTED\"\"}"]},{"type":"chunk","time":1788882845160,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"I will ask bash to print a quoted word."}}},{"type":"chunk","time":1788882845160,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","arguments":"{\"command\":\"echo \"QUOTED\"\"}"}}},{"type":"chunk","time":1788882845160,"chunk":{"type":"usage","usage":{"inputTokens":2877,"outputTokens":90,"cacheReadTokens":0,"totalTokens":2967}}},{"type":"chunk","time":1788882845160,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","arguments":"{\"command\":\"echo \"QUOTED\"\"}"}} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_fkbBRJsUrGKd1pWVc4Gn8233"},"content":[{"type":"tool-result","toolCallId":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","content":[{"type":"text","text":"Error: invalid arguments: \"arguments\" must be an object"}],"isError":true}],"role":"user","id":"{{message:5}}"},"error":{"name":"ToolArgsError","code":"INVALID_ARGS"}},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":1}} +{"type":"step/start","data":{"turn":1,"step":2}} +{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The tool rejected the malformed arguments. I can continue with the requested final reply."},{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:6}}"},"usage":{"inputTokens":168,"outputTokens":25,"cacheReadTokens":2816,"totalTokens":3009},"stream":[{"type":"chunk","time":1788882845260,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1788882845260,"index":0,"dt":[],"texts":["The tool rejected the malformed arguments. I can continue with the requested final reply."]},{"type":"chunk","time":1788882845260,"chunk":{"type":"block-start","index":1,"blockType":"text"}},{"type":"text-chunks","time0":1788882845260,"index":1,"dt":[0],"texts":["D","ONE"]},{"type":"chunk","time":1788882845260,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The tool rejected the malformed arguments. I can continue with the requested final reply."}}},{"type":"chunk","time":1788882845260,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}},{"type":"chunk","time":1788882845260,"chunk":{"type":"usage","usage":{"inputTokens":168,"outputTokens":25,"cacheReadTokens":2816,"totalTokens":3009}}},{"type":"chunk","time":1788882845260,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":2}} +{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/snapshots/session/deepseek-messages-invalid-tool-history/snapshot.yml b/snapshots/session/deepseek-messages-invalid-tool-history/snapshot.yml new file mode 100644 index 0000000000..f039836709 --- /dev/null +++ b/snapshots/session/deepseek-messages-invalid-tool-history/snapshot.yml @@ -0,0 +1,10 @@ +version: 1 +scenario: deepseek-messages-invalid-tool-history +profile: headless +composition: deepseek-messages-invalid-tool-history +recording: authored +header: + class: deepseek-messages-invalid-tool-history + pin: true + systemPromptSource: text-turn + toolSchemasSource: text-turn From fa518ecd89feece78557720caf91f9b45fc80e32 Mon Sep 17 00:00:00 2001 From: yudshj Date: Wed, 16 Sep 2026 12:16:09 +0800 Subject: [PATCH 14/15] feat(desktop): enable standalone Office skills --- ...2026-09-15-bundled-office-skills.i18n.yaml | 6 + .../2026-09-15-bundled-office-skills.md | 31 +++ .../2026-09-15-bundled-office-skills.zh.md | 31 +++ apps/cli/tests/desktop-host.e2e.ts | 16 +- apps/desktop-host/package.json | 3 +- apps/desktop-host/src/index.ts | 4 +- apps/desktop-host/src/office.ts | 25 ++ apps/desktop-host/tests/office.spec.ts | 58 +++++ apps/desktop-host/tsconfig.json | 1 + apps/desktop/README.i18n.yaml | 4 +- apps/desktop/README.md | 2 + apps/desktop/README.zh.md | 2 + .../scripts/prepare-primary-runtime.ts | 19 +- apps/desktop/scripts/smoke-primary-runtime.py | 9 + apps/desktop/src/host-process.ts | 3 +- .../tests/primary-runtime-preparation.spec.ts | 26 +- docs/capability-seams.i18n.yaml | 4 +- docs/capability-seams.md | 4 +- docs/capability-seams.zh.md | 4 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 16 ++ docs/config-catalog.zh.md | 16 ++ docs/persistence-catalog.i18n.yaml | 4 +- docs/persistence-catalog.md | 6 +- docs/persistence-catalog.zh.md | 6 +- docs/persistence-schema.json | 2 +- package.json | 3 +- packages/skill/README.i18n.yaml | 4 +- packages/skill/README.md | 3 +- packages/skill/README.zh.md | 3 +- packages/skill/skill-office/README.i18n.yaml | 6 + packages/skill/skill-office/README.md | 100 ++++++++ packages/skill/skill-office/README.zh.md | 100 ++++++++ .../skill-office/assets/office-docx/SKILL.md | 46 ++++ .../skill-office/assets/office-pptx/SKILL.md | 70 ++++++ .../skill-office/assets/office-xlsx/SKILL.md | 56 +++++ .../assets/scripts/check_office.py | 228 ++++++++++++++++++ packages/skill/skill-office/package.json | 43 ++++ packages/skill/skill-office/src/index.ts | 70 ++++++ .../skill-office/tests/check_office_test.py | 228 ++++++++++++++++++ .../skill/skill-office/tests/checkers.spec.ts | 14 ++ .../tests/fixtures/encrypted.xlsx | Bin 0 -> 224 bytes .../skill-office/tests/skill-office.spec.ts | 124 ++++++++++ packages/skill/skill-office/tsconfig.json | 24 ++ pnpm-lock.yaml | 28 +++ scripts/check-workspace-constraints.spec.ts | 8 + scripts/check-workspace-constraints.ts | 1 + scripts/gen-doc-graphs.ts | 2 +- .../verify-package-readme-model-experience.ts | 1 + snapshots/session/headless.snapshot.ts | 3 + .../cordis.snapshot.yml | 22 ++ .../office-skills-no-renderer/cordis.yml | 14 ++ .../session.v3.jsonl | 27 +++ .../office-skills-no-renderer/snapshot.yml | 12 + .../system-prompt.expected.md | 1 + .../tool-schemas.expected.json | 1 + .../session/office-skills/cordis.snapshot.yml | 51 ++++ snapshots/session/office-skills/cordis.yml | 5 + .../session/office-skills/session.v3.jsonl | 25 ++ snapshots/session/office-skills/snapshot.yml | 12 + .../office-skills/system-prompt.expected.md | 1 + .../office-skills/tool-schemas.expected.json | 1 + tsconfig.base.json | 1 + tsconfig.host.json | 1 + 64 files changed, 1610 insertions(+), 35 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-09-15-bundled-office-skills.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-09-15-bundled-office-skills.md create mode 100644 .agents/notes/implemented/feature/2026-09-15-bundled-office-skills.zh.md create mode 100644 apps/desktop-host/src/office.ts create mode 100644 apps/desktop-host/tests/office.spec.ts create mode 100644 packages/skill/skill-office/README.i18n.yaml create mode 100644 packages/skill/skill-office/README.md create mode 100644 packages/skill/skill-office/README.zh.md create mode 100644 packages/skill/skill-office/assets/office-docx/SKILL.md create mode 100644 packages/skill/skill-office/assets/office-pptx/SKILL.md create mode 100644 packages/skill/skill-office/assets/office-xlsx/SKILL.md create mode 100644 packages/skill/skill-office/assets/scripts/check_office.py create mode 100644 packages/skill/skill-office/package.json create mode 100644 packages/skill/skill-office/src/index.ts create mode 100644 packages/skill/skill-office/tests/check_office_test.py create mode 100644 packages/skill/skill-office/tests/checkers.spec.ts create mode 100644 packages/skill/skill-office/tests/fixtures/encrypted.xlsx create mode 100644 packages/skill/skill-office/tests/skill-office.spec.ts create mode 100644 packages/skill/skill-office/tsconfig.json create mode 100644 snapshots/session/office-skills-no-renderer/cordis.snapshot.yml create mode 100644 snapshots/session/office-skills-no-renderer/cordis.yml create mode 100644 snapshots/session/office-skills-no-renderer/session.v3.jsonl create mode 100644 snapshots/session/office-skills-no-renderer/snapshot.yml create mode 120000 snapshots/session/office-skills-no-renderer/system-prompt.expected.md create mode 120000 snapshots/session/office-skills-no-renderer/tool-schemas.expected.json create mode 100644 snapshots/session/office-skills/cordis.snapshot.yml create mode 100644 snapshots/session/office-skills/cordis.yml create mode 100644 snapshots/session/office-skills/session.v3.jsonl create mode 100644 snapshots/session/office-skills/snapshot.yml create mode 120000 snapshots/session/office-skills/system-prompt.expected.md create mode 120000 snapshots/session/office-skills/tool-schemas.expected.json diff --git a/.agents/notes/implemented/feature/2026-09-15-bundled-office-skills.i18n.yaml b/.agents/notes/implemented/feature/2026-09-15-bundled-office-skills.i18n.yaml new file mode 100644 index 0000000000..3d46b8afa8 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-15-bundled-office-skills.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/feature/2026-09-15-bundled-office-skills.md +2026-09-15-bundled-office-skills.md: e041e52cbfc3d4d33870fa3c8faf5fac3c0b35a4 +2026-09-15-bundled-office-skills.zh.md: 3f36a0e6883f08df66c6781cbf8784a6a4c358b0 diff --git a/.agents/notes/implemented/feature/2026-09-15-bundled-office-skills.md b/.agents/notes/implemented/feature/2026-09-15-bundled-office-skills.md new file mode 100644 index 0000000000..e041e52cbf --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-15-bundled-office-skills.md @@ -0,0 +1,31 @@ +# Agent Note: Bundled Office skills with structural verification + +Status: implemented + +English | [中文](2026-09-15-bundled-office-skills.zh.md) + +## Problem + +Office tasks need format-specific editing guidance and dependable file checks. Requiring users to install interpreters, package managers, or rendering command-line tools interrupts ordinary document delivery. Structural heuristics can also reject valid merged tables, multi-section documents, or Chinese text without observing an actual layout defect. + +## Decision + +The [Office provider](../../../../packages/skill/skill-office/README.md) contributes three independently discoverable skills at the bundled rank. The default workflow uses `load_workspace_dependencies` and its Python executable; explicit user and AGENTS.md environment choices take precedence. A configurable absolute asset root lets Desktop expose Python-readable resources outside its application archive. Registration validates the required resources and YAML descriptions; loaded instructions exclude the metadata. Disposal removes every candidate. + +One standard-library checker validates ZIP/XML integrity and internal relationships, reports format-specific structure, and checks only explicit text or count assertions. DOCX table summaries count logical grid columns, including merged cells. Section geometry is reported rather than judged against the final section; font filenames do not establish glyph coverage. XLSX formula counts never imply recalculation. Text assertions follow section and note references and worksheet string indices, so retained headers, unused note definitions, comments, glossary entries, and unused strings cannot satisfy requested wording. + +Desktop mounts the skill provider and runtime query independently of document rendering. Word uses python-docx, PowerPoint creation and editing use python-pptx, and Excel uses openpyxl and pandas. The managed payload and the ordinary creation examples require neither a rendering engine nor a separate presentation authoring library. + +Office skills and the bundled-runtime query remain registered without a rendering service. Visual inspection depends on the active model accepting images and a rendering tool being available. Otherwise the skills complete structural and content checks and deliver with the unverified visual scope stated. `present` refers to the current workspace source file; it does not preserve a private copy. + +## Alternatives considered + +**Require a plan and a local renderer before every delivery.** Simple edits do not need a fixed planning artifact, and models without image input cannot judge rendered pages. Mandatory renderer installation would turn an optional quality signal into a dependency unrelated to many requests. + +**Judge layout using package structure and font-name guesses.** Merged cells, different section widths, font substitution, and application layout rules prevent those observations from establishing rendered correctness. The checker reports facts and leaves visual judgments to actual images. + +**Share one undifferentiated Office skill.** Format-specific discovery avoids loading spreadsheet formula guidance for a Word edit or presentation instructions for a cell update. The deterministic checker remains shared because all three formats use the same package relationship rules. + +## Consequences + +The provider supplies reusable instructions without selecting or installing a deployment runtime. The checker is portable wherever the supported Python standard library works, but it cannot establish Office rendering fidelity, advanced feature preservation, or formula results. Loader and disposal tests cover resource relocation, activation failures, and absent rendering services. A recorded Session pins the Office catalog and loaded instruction body. Checker tests cover structural failures and JSON diagnostics; native payload smoke executes the copied checker with the bundled interpreter. diff --git a/.agents/notes/implemented/feature/2026-09-15-bundled-office-skills.zh.md b/.agents/notes/implemented/feature/2026-09-15-bundled-office-skills.zh.md new file mode 100644 index 0000000000..3f36a0e688 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-15-bundled-office-skills.zh.md @@ -0,0 +1,31 @@ +# Agent Note: Bundled Office skills with structural verification + +Status: implemented + +[English](2026-09-15-bundled-office-skills.md) | 中文 + +## 问题 + +Office 任务需要针对文件格式的编辑指引和可靠的文件检查。要求用户安装解释器、包管理器或渲染命令行工具,会中断普通文档交付。结构启发式规则也可能在未观察到实际版式缺陷时,误拒有效的合并表格、多分节文档或中文文本。 + +## 决策 + +[Office 提供方](../../../../packages/skill/skill-office/README.zh.md)以内置优先级提供三个可独立发现的 skill(技能)。默认工作流使用 `load_workspace_dependencies` 及其 Python 可执行文件;用户和 AGENTS.md 明确指定的环境优先。可配置的绝对资源根目录让 Desktop 在应用归档之外暴露 Python 可读取的资源。注册时验证必需资源与 YAML 描述;加载后的指令不包含元数据。卸载时移除全部候选项。 + +一个仅依赖标准库的检查器验证 ZIP/XML 完整性与包内引用,报告各格式的结构,并只检查明确指定的文本或数量断言。DOCX 表格摘要计算逻辑网格列数,包含合并单元格。分节几何信息仅报告,不以最终分节判断全文;字体文件名不能证明字形覆盖范围。XLSX 公式数量不代表已执行重算。文本断言跟随分节与脚注/尾注引用及工作表字符串索引,因此保留的页眉、未使用的脚注/尾注定义、批注、词库条目和未使用的字符串不能满足措辞要求。 + +Desktop 独立于文档渲染挂载技能提供者和运行时查询。Word 使用 python-docx,PowerPoint 创建和编辑使用 python-pptx,Excel 使用 openpyxl 和 pandas。受管理产物和普通创建示例都不要求渲染引擎或单独的演示文稿创作库。 + +缺少渲染服务时,Office skills 与内置运行时查询仍保持注册。视觉检查取决于当前模型支持图片且有可用渲染工具。否则,skill 完成结构与内容检查,并在交付时说明未验证的视觉范围。`present` 引用工作区中的当前源文件,不保存私有副本。 + +## 考虑过的替代方案 + +**每次交付都强制要求计划与本地渲染器。** 简单编辑不需要固定计划文件,不支持图片的模型也无法判断渲染页面。强制安装渲染器会将可选质量信号变成与许多请求无关的依赖。 + +**通过包结构与字体名猜测判断版式。** 合并单元格、不同分节宽度、字体替换和应用排版规则,使这些观察不足以证明渲染正确。检查器报告事实,将视觉判断留给实际图像。 + +**共用一个不区分格式的 Office skill。** 按格式发现可避免在 Word 编辑时加载公式指引,或在单元格更新时加载演示文稿指令。确定性检查器仍然共享,因为三种格式采用相同的包引用规则。 + +## 影响 + +提供方提供可复用指令,不选择或安装部署运行时。检查器可在受支持的 Python 标准库可用之处运行,但不能证明 Office 渲染保真度、高级特性保留或公式结果。Loader 和卸载测试覆盖资源重定位、激活失败与渲染服务缺失。录制 Session 固定 Office 目录与加载后的指令正文。检查器测试覆盖结构错误与 JSON 诊断;原生产物冒烟检查通过内置解释器执行复制后的检查器。 diff --git a/apps/cli/tests/desktop-host.e2e.ts b/apps/cli/tests/desktop-host.e2e.ts index e2eb66184d..598af81606 100644 --- a/apps/cli/tests/desktop-host.e2e.ts +++ b/apps/cli/tests/desktop-host.e2e.ts @@ -1,9 +1,9 @@ /** Built Desktop Host lifecycle with Electron disconnecting before profile startup settles. */ import { fork } from 'node:child_process' -import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import { finished } from 'node:stream/promises' import { expect, it, onTestFinished } from 'vitest' @@ -11,7 +11,15 @@ import { expect, it, onTestFinished } from 'vitest' it.each([false, true])('settles startup after parent IPC disconnect (boot failure: %s)', async (fail) => { const root = mkdtempSync(join(tmpdir(), 'desktop-disconnect-')) const modules = join(root, 'node_modules', '@deepseek-ai') - for (const name of ['dsh-app-boot', 'dsh', 'dsh-home-paths', 'dsh-tools']) mkdirSync(join(modules, name), { recursive: true }) + 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']) + for (const name of Object.keys(manifest.dependencies)) { + const destination = join(root, 'node_modules', name) + mkdirSync(dirname(destination), { recursive: true }) + if (stubbed.has(name)) mkdirSync(destination) + else symlinkSync(realpathSync(join(hostDirectory, 'node_modules', name)), destination, 'junction') + } for (const [name, source] of [ ['dsh-home-paths', `export const resolveDshHome = () => ${JSON.stringify(root)}`], ['dsh-tools', 'export const defineTool = value => value'], @@ -35,7 +43,7 @@ it.each([false, true])('settles startup after parent IPC disconnect (boot failur } `) const entry = join(root, 'index.js') - copyFileSync(fileURLToPath(new URL('../../desktop-host/lib/index.js', import.meta.url)), entry) + copyFileSync(join(hostDirectory, 'lib', 'index.js'), entry) const child = fork(entry, [root, root], { execArgv: [], stdio: ['ignore', 'ignore', 'pipe', 'ipc'] }) let stderr = '' child.stderr!.setEncoding('utf8').on('data', (chunk: string) => { stderr += chunk }) diff --git a/apps/desktop-host/package.json b/apps/desktop-host/package.json index f0763ed795..b694f11e5a 100644 --- a/apps/desktop-host/package.json +++ b/apps/desktop-host/package.json @@ -16,6 +16,7 @@ "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-home-paths": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^" + "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-skill-office": "workspace:^" } } diff --git a/apps/desktop-host/src/index.ts b/apps/desktop-host/src/index.ts index 66d5f61a84..692c0b083a 100644 --- a/apps/desktop-host/src/index.ts +++ b/apps/desktop-host/src/index.ts @@ -6,7 +6,7 @@ import { runProfile } from '@deepseek-ai/dsh/profile-boot' import type {} from '@deepseek-ai/dsh-client-connection' import type {} from '@deepseek-ai/dsh-host-webserver' import { resolveDshHome } from '@deepseek-ai/dsh-home-paths' -import * as workspaceDependencies from './workspace-dependencies.ts' +import * as desktopOffice from './office.ts' async function main(): Promise { const runtimeDir = process.argv[2] as string @@ -32,7 +32,7 @@ async function main(): Promise { }) process.once('disconnect', () => { void stop() }) const { ctx } = await application - await ctx.plugin(workspaceDependencies, { + await ctx.plugin(desktopOffice, { source: process.argv[4] ?? join(runtimeDir, '..', 'runtime', 'primary-runtime'), root: join(resolveDshHome(), 'dsh-runtimes', 'dsh-primary-runtime'), }) diff --git a/apps/desktop-host/src/office.ts b/apps/desktop-host/src/office.ts new file mode 100644 index 0000000000..6f38e20587 --- /dev/null +++ b/apps/desktop-host/src/office.ts @@ -0,0 +1,25 @@ +/** Desktop Office skills and bundled authoring dependencies. */ + +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' + +/** Loader identity for the application-owned Office composition. */ +export const name = 'desktop-office' +/** Application-selected bundled payload and installation directories. */ +export interface Config { + /** Bundled payload directory. Missing sibling `office-skills` resources fail Host startup. */ + readonly source: string + readonly root: string +} + +/** + * Enable offline Office authoring and structural checks in the Desktop profile. + * @param ctx - Profile scope; child plugins declare their own service requirements. + * @param config - Bundled payload source and Harness-home installation root. + */ +export async function apply(ctx: Context, config: Config): Promise { + await ctx.plugin(workspaceDependencies, config) + await ctx.plugin(officeSkills, { assetRoot: join(dirname(config.source), 'office-skills') }) +} diff --git a/apps/desktop-host/tests/office.spec.ts b/apps/desktop-host/tests/office.spec.ts new file mode 100644 index 0000000000..f26b5fc6e0 --- /dev/null +++ b/apps/desktop-host/tests/office.spec.ts @@ -0,0 +1,58 @@ +import { cp, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import Include from '@deepseek-ai/cordis-plugin-include' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import SkillRegistry from '@deepseek-ai/dsh-skill' +import ToolRuntime from '@deepseek-ai/dsh-tools' +import { expect, it } from 'vitest' +import * as desktopOffice from '../src/office.ts' + +it('loads Desktop Office skills without a document renderer and removes them on disposal', async () => { + const root = await mkdtemp(join(tmpdir(), 'desktop-office-')) + const ctx = new Context() + try { + const assets = join(root, 'runtime', 'office-skills') + await cp(new URL('../../../packages/skill/skill-office/assets/', import.meta.url), assets, { recursive: true }) + ctx.baseUrl = pathToFileURL(root).href + '/' + await ctx.plugin(Loader) + ctx.loader.builtins.include = Include + expect('default' in desktopOffice).toBe(false) + expect(ctx.loader.unwrapExports(desktopOffice)).toBe(desktopOffice) + const modules = new Map([ + ['agents', AgentRegistry], ['systemPrompt', SystemPrompt], ['tools', ToolRuntime], + ['skills', SkillRegistry], ['office', desktopOffice], + ]) + ctx.loader.internal = { + version: 'v2', + async import(specifier: string) { + if (!modules.has(specifier)) throw new Error(`unexpected plugin ${specifier}`) + return modules.get(specifier) + }, + } as unknown as NonNullable + const config = join(root, 'cordis.yml') + await writeFile(config, [ + '- name: agents', '- name: systemPrompt', '- name: tools', '- name: skills', '- name: office', + ' config:', ` source: ${JSON.stringify(join(root, 'runtime', 'primary-runtime'))}`, + ` root: ${JSON.stringify(join(root, 'installed'))}`, '', + ].join('\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() + expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['office-docx', 'office-pptx', 'office-xlsx']) + expect((await ctx.skills.get('office-pptx'))?.resourceBase).toEqual({ kind: 'directory', path: join(assets, 'office-pptx') }) + expect(ctx.tools.schemas().map(tool => tool.name)).toEqual(['load_workspace_dependencies']) + const entry = [...ctx.loader.entries()].find(entry => entry.options.name === 'office') + expect(entry).toBeDefined() + await entry?.fiber?.dispose() + expect(await ctx.skills.list()).toEqual([]) + expect(ctx.tools.schemas()).toEqual([]) + } finally { + await ctx.fiber.dispose() + await rm(root, { recursive: true, force: true }) + } +}) diff --git a/apps/desktop-host/tsconfig.json b/apps/desktop-host/tsconfig.json index 871cf45821..7b6eaba077 100644 --- a/apps/desktop-host/tsconfig.json +++ b/apps/desktop-host/tsconfig.json @@ -8,6 +8,7 @@ "src" ], "references": [ + { "path": "../../packages/skill/skill-office" }, { "path": "../../packages/util/home-paths" }, { "path": "../../packages/core/tools" }, { diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 245274ee6f..0ce8b77c72 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: bf4f22be5aafa1323433d2eb396e9a5417dc3511 -README.zh.md: 058e6b223a4522c993243b463bc7cbd5796529aa +README.md: d19304ae123aefe57af649c5d6a02c4db867314e +README.zh.md: 3d5b84c23701c4915fb1983fd6363c1ce2dc3059 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index bf4f22be5a..d19304ae12 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -18,6 +18,8 @@ The current Windows Python payload contains unsigned native extensions. Smart Ap Desktop carries independent Python, Node.js and pnpm distributions. Python includes numpy, pandas, python-docx, python-pptx, openpyxl, Pillow, lxml and XlsxWriter with their complete dependencies. The `load_workspace_dependencies` tool installs this payload offline on first use under `$DSH_HOME/dsh-runtimes/dsh-primary-runtime` (normally `~/.dsh/dsh-runtimes/dsh-primary-runtime`) and returns absolute interpreter, pnpm script and library paths plus `pythonDistributions`, the bundled distribution names and versions. The version report excludes user-installed additions. Office tasks prefer these libraries unless user or workspace instructions select another environment. Execute the pnpm script with the returned Node executable. The returned Node library directory is reserved for bundled libraries, not pnpm's global installation directory. +Desktop registers `office-docx`, `office-pptx`, and `office-xlsx` by default. The skills use the bundled Python libraries for creation and focused edits, then reopen the files and run a shared structural checker before delivery. PowerPoint creation and editing use python-pptx. Skill resources are copied to `runtime/office-skills` outside ASAR so Python can read the checker. An available `render_document` tool can add visual inspection; its absence does not prevent authoring or delivery. See the [Office skill package](../../packages/skill/skill-office/README.md) for checks and limitations. + The payload follows the Desktop release. `runtime.json` records the Desktop version, target, component and Python distribution versions, and a digest of the selected target’s locked payload inputs and assembly format. Distribution names use PEP 503 normalization; duplicate normalized names and conflicting numpy/pandas component and distribution versions reject the manifest. Matching installations are reused; a dependency or archive change replaces the directory after a complete staged copy even when the Desktop version stays unchanged. Older manifests without a digest are replaced on their next installation. User-added Python packages remain only while the payload identity matches. A failed directory replacement retains the previous installation; Windows may refuse replacement while an interpreter is still running. 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. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 058e6b223a..3d5b84c237 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -18,6 +18,8 @@ macOS PNG 使用带留白的圆角底板,供传统 ICNS 打包使用,包含 Desktop 携带独立的 Python、Node.js 和 pnpm 分发包。Python 包含 numpy、pandas、python-docx、python-pptx、openpyxl、Pillow、lxml、XlsxWriter 及其完整依赖。`load_workspace_dependencies` 工具首次使用时,将该产物离线安装到 `$DSH_HOME/dsh-runtimes/dsh-primary-runtime`(通常为 `~/.dsh/dsh-runtimes/dsh-primary-runtime`),并返回解释器、pnpm 脚本和库目录的绝对路径,以及记录内置分发包名称与版本的 `pythonDistributions`。版本报告不包含用户自行安装的包。Office 任务默认使用这些库,用户或工作区指令指定其他环境时遵循其要求。pnpm 脚本通过返回的 Node 可执行文件运行。返回的 Node 库目录为随包交付的库预留,不是 pnpm 的全局安装目录。 +Desktop 默认注册 `office-docx`、`office-pptx` 和 `office-xlsx`。这些技能使用内置 Python 库创建文件和进行定点编辑,随后重新打开文件,并在交付前运行共享结构检查器。PowerPoint 的创建和编辑使用 python-pptx。技能资源复制到 ASAR 外的 `runtime/office-skills`,让 Python 可以读取检查器。可用的 `render_document` 工具可以补充视觉检查;缺少该工具不妨碍创作或交付。检查范围与限制见 [Office 技能包](../../packages/skill/skill-office/README.zh.md)。 + 该产物随 Desktop 版本发布。`runtime.json` 记录 Desktop 版本、目标平台、组件与 Python 分发包版本,以及所选目标的锁定产物输入与组装格式的摘要。分发包名称按 PEP 503 归一化;名称归一化后重复,或 numpy/pandas 的组件版本与分发包版本冲突时,清单会被拒绝。匹配的安装会被复用;依赖或压缩包变化后,即使 Desktop 版本不变,也会在完整暂存副本完成后替换目录。不含摘要的旧清单会在下次安装时被替换。用户自行添加的 Python 包仅在产物身份一致时保留。目录替换失败时保留之前的安装;解释器仍在运行时,Windows 可能拒绝替换。 Desktop 私有的 `runtime/bin` 目录仅添加到包安装进程,不进入 PTC 和 agent shell 从 Host 继承的 PATH。该工具不修改 PATH、环境变量或用户包管理器配置。pnpm 的全局包、命令入口和 store 保留自身默认值及用户设置,包括环境不支持全局安装时的原生错误。不提供独立依赖更新器。[第一方 Runtime 决策](../../.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.zh.md)记录这些选择。 diff --git a/apps/desktop/scripts/prepare-primary-runtime.ts b/apps/desktop/scripts/prepare-primary-runtime.ts index 88ba80befe..1c57d168ea 100644 --- a/apps/desktop/scripts/prepare-primary-runtime.ts +++ b/apps/desktop/scripts/prepare-primary-runtime.ts @@ -6,7 +6,7 @@ import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } f import { cp } from 'node:fs/promises' import { createRequire } from 'node:module' import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' +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' @@ -75,6 +75,17 @@ export async function unpackPrimaryRuntimeWheel(archive: string, destination: st }) } +/** + * 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. * @returns Resolves after dependency installation and native-target execution checks. @@ -131,6 +142,9 @@ export async function preparePrimaryRuntime(): Promise { } 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')) smokePrimaryRuntime(join(paths.runtime, 'primary-runtime')) } @@ -144,7 +158,8 @@ export function smokePrimaryRuntime(root: string): void { 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 } as const - execFileSync(entries.python, ['-I', '-B', join(import.meta.dirname, 'smoke-primary-runtime.py'), JSON.stringify(manifest.pythonPackages), manifest.components.python], 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) diff --git a/apps/desktop/scripts/smoke-primary-runtime.py b/apps/desktop/scripts/smoke-primary-runtime.py index fc0ddb4c19..232d67e108 100644 --- a/apps/desktop/scripts/smoke-primary-runtime.py +++ b/apps/desktop/scripts/smoke-primary-runtime.py @@ -4,6 +4,7 @@ import importlib.metadata import json from pathlib import Path import re +import subprocess import sys import tempfile @@ -83,6 +84,14 @@ def main(): finally: reopened_workbook.close() assert pandas.read_excel(root / "workbook.xlsx")["Value"].iloc[0] == 42 + for file, arguments in [ + ("document.docx", ["--contains", "Office 文档"]), + ("presentation.pptx", ["--contains", "Office 演示", "--count", "1"]), + ("workbook.xlsx", ["--contains", "Value", "--count", "1"]), + ]: + checked = subprocess.run([sys.executable, "-I", "-B", sys.argv[3], str(root / file), *arguments], + check=False, capture_output=True, text=True, timeout=30) + assert checked.returncode == 0 and json.loads(checked.stdout)["verdict"] == "pass", checked.stdout + checked.stderr print("Office runtime versions and document round trips passed.") diff --git a/apps/desktop/src/host-process.ts b/apps/desktop/src/host-process.ts index ac70ba15e9..6e0f1f805f 100644 --- a/apps/desktop/src/host-process.ts +++ b/apps/desktop/src/host-process.ts @@ -72,7 +72,8 @@ export class DesktopHostProcess { * @param inspectPort - Optional loopback inspector port for workspace development. * @param environment - Environment inherited by the Host and its plugin subprocesses. * @param onFailure - Receives the first unexpected child failure, including after readiness. - * @param primaryRuntime - Optional payload location for bundled script dependencies. + * @param primaryRuntime - Optional bundled dependency payload; when supplied, missing sibling + * `office-skills` resources fail Host startup. * @param profileResolution - Package resolution mode for the application-owned profile. */ constructor( diff --git a/apps/desktop/tests/primary-runtime-preparation.spec.ts b/apps/desktop/tests/primary-runtime-preparation.spec.ts index 9cf13bccd7..2e0f802af5 100644 --- a/apps/desktop/tests/primary-runtime-preparation.spec.ts +++ b/apps/desktop/tests/primary-runtime-preparation.spec.ts @@ -1,10 +1,10 @@ import { createHash } from 'node:crypto' -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +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, primaryRuntimePayloadDigest, smokePrimaryRuntime, unpackPrimaryRuntimeWheel } from '../scripts/prepare-primary-runtime.ts' +import { downloadPrimaryRuntimeAsset, prepareOfficeSkillAssets, primaryRuntimePayloadDigest, smokePrimaryRuntime, unpackPrimaryRuntimeWheel } from '../scripts/prepare-primary-runtime.ts' import lock from '../scripts/primary-runtime-lock.json' with { type: 'json' } const libraryWheel = Buffer.from('UEsDBAoAAAAAAASeLl0sYMPjDAAAAAwAAAAJAAAAc2FtcGxlLnB5c2FtcGxlID0gNDIKUEsBAh4DCgAAAAAABJ4uXSxgw+MMAAAADAAAAAkAAAAAAAAAAQAAAKSBAAAAAHNhbXBsZS5weVBLBQYAAAAAAQABADcAAAAzAAAAAAA=', 'base64') @@ -107,3 +107,25 @@ it('rejects library files requiring an unsupported installation scheme', async ( 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 }) } +}) diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index 5273440f8a..40a17bc67a 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.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/capability-seams.md -capability-seams.md: 5db5d11195355330b3c0bf3f58c928bd3533cf85 -capability-seams.zh.md: bfcd60916f615f173bc167623462841e25eca2a2 +capability-seams.md: b40adeafe7cec40dfbbe6a65788e5888097ef57f +capability-seams.zh.md: b33986f205c2e1352343c7c5ad3700efb027eb61 diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 5db5d11195..b40adeafe7 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -144,6 +144,7 @@ flowchart LR svc_skills["ctx.skills
    Skill provider registry"] pkg_skill_badge["skill-badge"] pkg_skill_filesystem["skill-filesystem"] + pkg_skill_office["skill-office"] svc_agents["ctx.agents
    Agent service"] pkg_acp["acp"] pkg_agent_default_model["agent-default-model"] @@ -344,6 +345,7 @@ flowchart LR pkg_skill --> svc_skills pkg_skill_badge --> svc_skills pkg_skill_filesystem --> svc_skills + pkg_skill_office --> svc_skills pkg_spill --> svc_spillStore pkg_spill_local --> svc_spillStore pkg_ssh --> svc_ssh @@ -570,7 +572,7 @@ flowchart LR | `ctx.commands` | `core` | [`commands`](../packages/interaction/commands) | - | - | - | Plugins register direct human commands without sending invocations to the model. | | `ctx.sessionProjections` | `core` | [`session-projection`](../packages/session/session-projection) | - | [`api-session-controller`](../packages/api/session-controller), [`tool-todo`](../packages/todo/tool-todo), [`session-title`](../packages/session/session-title) | - | Domains register state-driven fold units; the eager drive keeps per-session watermark states and the Session controller serves baselines and pushes changed values. | | `ctx.sessionProjectionCache` | `core` | [`session-projection-cache`](../packages/session/session-projection-cache) | - | [`api-session-controller`](../packages/api/session-controller), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference), [`subagent`](../packages/subagent/subagent) | - | Durably checkpoints projection unit states per session (throttled + turn/end/detach mandatory points) and serves the cold-read ladder: cache row + persistence tail replay, so listings never load full logs. | -| `ctx.skills` | `seam` | [`skill`](../packages/skill/skill) | [`skill-badge`](../packages/skill/skill-badge), [`skill-filesystem`](../packages/skill/skill-filesystem) | [`tool-skill`](../packages/skill/tool-skill) | - | Merges provider skill catalogs; tool-skill renders the session-prefix catalog and loads complete skill bodies. | +| `ctx.skills` | `seam` | [`skill`](../packages/skill/skill) | [`skill-badge`](../packages/skill/skill-badge), [`skill-filesystem`](../packages/skill/skill-filesystem), [`skill-office`](../packages/skill/skill-office) | [`tool-skill`](../packages/skill/tool-skill) | - | Merges provider skill catalogs; tool-skill renders the session-prefix catalog and loads complete skill bodies. | | `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/acp/acp), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | - | Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation. | | `ctx.agentDefaultModel` | `core` | [`agent-default-model`](../packages/core/agent-default-model) | - | [`api-session-controller`](../packages/api/session-controller), [`headless`](../packages/bundle/headless) | - | Layers the default ModelSelection through settings so direct and Host-backed Agent entry points share one state owner. | | `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`base`](../packages/bundle/base), [`sdk-minimal`](../packages/bundle/sdk-minimal) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index bfcd60916f..b33986f205 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -146,6 +146,7 @@ flowchart LR svc_skills["ctx.skills
    Skill provider registry"] pkg_skill_badge["skill-badge"] pkg_skill_filesystem["skill-filesystem"] + pkg_skill_office["skill-office"] svc_agents["ctx.agents
    Agent service"] pkg_acp["acp"] pkg_agent_default_model["agent-default-model"] @@ -346,6 +347,7 @@ flowchart LR pkg_skill --> svc_skills pkg_skill_badge --> svc_skills pkg_skill_filesystem --> svc_skills + pkg_skill_office --> svc_skills pkg_spill --> svc_spillStore pkg_spill_local --> svc_spillStore pkg_ssh --> svc_ssh @@ -572,7 +574,7 @@ flowchart LR | `ctx.commands` | `core` | [`commands`](../packages/interaction/commands) | - | - | - | 插件注册直接面向人的命令,而不会把调用发送给模型。 | | `ctx.sessionProjections` | `core` | [`session-projection`](../packages/session/session-projection) | - | [`api-session-controller`](../packages/api/session-controller), [`tool-todo`](../packages/todo/tool-todo), [`session-title`](../packages/session/session-title) | - | 各领域注册由状态驱动的折叠单元;主动驱动过程维护每个会话的水位状态,Session controller 提供 baseline 并推送发生变化的值。 | | `ctx.sessionProjectionCache` | `core` | [`session-projection-cache`](../packages/session/session-projection-cache) | - | [`api-session-controller`](../packages/api/session-controller), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference), [`subagent`](../packages/subagent/subagent) | - | 按会话持久保存投影单元状态的检查点(节流检查点,以及轮次/结束/分离时的必选检查点),并提供冷读取阶梯:缓存行加持久化尾部回放,因此列表读取永远不需要加载完整日志。 | -| `ctx.skills` | `seam` | [`skill`](../packages/skill/skill) | [`skill-badge`](../packages/skill/skill-badge), [`skill-filesystem`](../packages/skill/skill-filesystem) | [`tool-skill`](../packages/skill/tool-skill) | - | 合并提供方的 skill(技能)目录;tool-skill 渲染会话前缀目录,并加载完整的 skill 正文。 | +| `ctx.skills` | `seam` | [`skill`](../packages/skill/skill) | [`skill-badge`](../packages/skill/skill-badge), [`skill-filesystem`](../packages/skill/skill-filesystem), [`skill-office`](../packages/skill/skill-office) | [`tool-skill`](../packages/skill/tool-skill) | - | 合并提供方的 skill(技能)目录;tool-skill 渲染会话前缀目录,并加载完整的 skill 正文。 | | `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/acp/acp), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | - | 拥有实时 Agent 句柄、创建/恢复工厂 seam,以及进程本地的发起方传播。 | | `ctx.agentDefaultModel` | `core` | [`agent-default-model`](../packages/core/agent-default-model) | - | [`api-session-controller`](../packages/api/session-controller), [`headless`](../packages/bundle/headless) | - | 通过 settings 分层默认 `ModelSelection`,让直接入口与 Host 支撑的 Agent 入口共享同一个状态所有者。 | | `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`base`](../packages/bundle/base), [`sdk-minimal`](../packages/bundle/sdk-minimal) | - | 唯一的具体循环插件;扩展包依赖 dsh-agent 的事件和服务,而不依赖此包。 | diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index aaab47c994..eb95026f0d 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: 21172068c5be7c3e7515228a728517fe073bb328 -config-catalog.zh.md: 8ce16c5e502edb6e4571b50dc28711776f2912eb +config-catalog.md: 08c7559f2d83826a850b4eb5c18351bfd24e1707 +config-catalog.zh.md: f29345293fa50a38a631d73f9ba5094bc7fd47b3 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 21172068c5..08c7559f2d 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2402,6 +2402,22 @@ export interface Config { Source: [`packages/skill/skill-filesystem/src/index.ts:49`](../packages/skill/skill-filesystem/src/index.ts) + + +## `@deepseek-ai/dsh-skill-office` + +Requires: `skills` + +```ts config-catalog +/** Office skill resource location. */ +export interface Config { + /** Absolute assets directory containing the three skill folders and shared scripts; defaults to packaged assets. */ + assetRoot?: string +} +``` + +Source: [`packages/skill/skill-office/src/index.ts:15`](../packages/skill/skill-office/src/index.ts) + ## `@deepseek-ai/dsh-spill-local` diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 8ce16c5e50..f29345293f 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2404,6 +2404,22 @@ export interface Config { 来源:[`packages/skill/skill-filesystem/src/index.ts:49`](../packages/skill/skill-filesystem/src/index.ts) + + +## `@deepseek-ai/dsh-skill-office` + +依赖:`skills` + +```ts config-catalog +/** Office skill resource location. */ +export interface Config { + /** Absolute assets directory containing the three skill folders and shared scripts; defaults to packaged assets. */ + assetRoot?: string +} +``` + +来源:[`packages/skill/skill-office/src/index.ts:15`](../packages/skill/skill-office/src/index.ts) + ## `@deepseek-ai/dsh-spill-local` diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 197b13291c..a4cc54a5d9 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-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/persistence-catalog.md -persistence-catalog.md: 99c24562c178b35944e5616729076d015eb3f4c6 -persistence-catalog.zh.md: a2c9590a799a1bd63745afaaad989be051015ae0 +persistence-catalog.md: c01193930901e1b01def017ee5529b0e66efa5c1 +persistence-catalog.zh.md: fe11795b919e1f88cfc358896fbceb1388e22edc diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 99c24562c1..c011939309 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -1784,9 +1784,9 @@ SHA-256: `0ddab4577db37a3e0d9e3a91761735c8ef6f5629d526ab8ecaeeea19c097c8f8` | `kind` | required | `"plugin"` | | `plugin` | required | `string` | - + -### `event:agent/inbox/spliced.data.inserted[0].source[10]` +### `event:agent/inbox/spliced.data.inserted[0].source[11]` SHA-256: `6a4f72e2e179e17b922f2a9392c0e1c8f8c707f32494372454850a3eb184a6e7` @@ -5140,7 +5140,7 @@ One of: - [`packages/context/agent-instructions/src/state.ts#AgentInstructionSource`](#persistence-type-packagescontextagent-instructionssrcstatetsagentinstructionsource) - [`packages/llm/llm/src/message.ts#ToolMessageSource`](#persistence-type-packagesllmllmsrcmessagetstoolmessagesource) -- [`event:agent/inbox/spliced.data.inserted[0].source[10]`](#persistence-type-eventagentinboxspliceddatainserted0source10) +- [`event:agent/inbox/spliced.data.inserted[0].source[11]`](#persistence-type-eventagentinboxspliceddatainserted0source11) - [`event:agent/inbox/spliced.data.inserted[0].source[19]`](#persistence-type-eventagentinboxspliceddatainserted0source19) - [`packages/skill/tool-skill/src/index.ts#SkillCatalogSource`](#persistence-type-packagesskilltool-skillsrcindextsskillcatalogsource) - [`packages/skill/skill/src/index.ts#SkillInvocationSource`](#persistence-type-packagesskillskillsrcindextsskillinvocationsource) diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index a2c9590a79..fe11795b91 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -1786,9 +1786,9 @@ SHA-256: `0ddab4577db37a3e0d9e3a91761735c8ef6f5629d526ab8ecaeeea19c097c8f8` | `kind` | 必需 | `"plugin"` | | `plugin` | 必需 | `string` | - + -### `event:agent/inbox/spliced.data.inserted[0].source[10]` +### `event:agent/inbox/spliced.data.inserted[0].source[11]` SHA-256: `6a4f72e2e179e17b922f2a9392c0e1c8f8c707f32494372454850a3eb184a6e7` @@ -5142,7 +5142,7 @@ SHA-256: `4c965666d5b42116945bc356087cd5e1fd549e6dece560785ed60511059a8283` - [`packages/context/agent-instructions/src/state.ts#AgentInstructionSource`](#persistence-type-packagescontextagent-instructionssrcstatetsagentinstructionsource) - [`packages/llm/llm/src/message.ts#ToolMessageSource`](#persistence-type-packagesllmllmsrcmessagetstoolmessagesource) -- [`event:agent/inbox/spliced.data.inserted[0].source[10]`](#persistence-type-eventagentinboxspliceddatainserted0source10) +- [`event:agent/inbox/spliced.data.inserted[0].source[11]`](#persistence-type-eventagentinboxspliceddatainserted0source11) - [`event:agent/inbox/spliced.data.inserted[0].source[19]`](#persistence-type-eventagentinboxspliceddatainserted0source19) - [`packages/skill/tool-skill/src/index.ts#SkillCatalogSource`](#persistence-type-packagesskilltool-skillsrcindextsskillcatalogsource) - [`packages/skill/skill/src/index.ts#SkillInvocationSource`](#persistence-type-packagesskillskillsrcindextsskillinvocationsource) diff --git a/docs/persistence-schema.json b/docs/persistence-schema.json index a3f1565668..0ac5ce250b 100644 --- a/docs/persistence-schema.json +++ b/docs/persistence-schema.json @@ -37978,7 +37978,7 @@ ] }, "names": [ - "event:agent/inbox/spliced.data.inserted[0].source[10]" + "event:agent/inbox/spliced.data.inserted[0].source[11]" ], "sources": [ "packages/api/session-controller/src/types.ts:380" diff --git a/package.json b/package.json index bb0c2ce254..7eb8fb2782 100644 --- a/package.json +++ b/package.json @@ -239,6 +239,7 @@ "tsx": "^4.22.4", "typescript": "^6.0.3", "vite-tsconfig-paths": "^6.1.1", - "vitest": "^4.1.8" + "vitest": "^4.1.8", + "@deepseek-ai/dsh-skill-office": "workspace:^" } } diff --git a/packages/skill/README.i18n.yaml b/packages/skill/README.i18n.yaml index e5567f85a6..b147ecb55a 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: 963b8195b2240f22cb2df16ffccbb91365e62566 -README.zh.md: f11bd1be5ae005f23592ec479ae635ca28eebeeb +README.md: 9546dfbfb2081480a1af8078d65481d0fc080ae3 +README.zh.md: eb1231b6fc7e110afdcc22fb25db3dd55b830092 diff --git a/packages/skill/README.md b/packages/skill/README.md index 963b8195b2..9546dfbfb2 100644 --- a/packages/skill/README.md +++ b/packages/skill/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -The skill family lets agents and users discover and load reusable task instructions only when needed. Use `skill/` to combine catalogs and expose one instruction set per name; choose `skill-filesystem` for project, custom, or user-directory discovery, and `skill-badge` for the optional official badge. Add `tool-skill` when models should receive a sorted, durable session catalog, load full instructions through the `skill` tool, or accept direct `/name` invocation. Different sources produce the same model-visible format, and model access requires at least one source. +The skill family lets agents and users discover and load reusable task instructions only when needed. Use `skill/` to combine catalogs and expose one instruction set per name; choose `skill-filesystem` for project, custom, or user-directory discovery, `skill-badge` for the optional official badge, and `skill-office` for Word, PowerPoint, and Excel workflows. Add `tool-skill` when models should receive a sorted, durable session catalog, load full instructions through the `skill` tool, or accept direct `/name` invocation. Different sources produce the same model-visible format, and model access requires at least one source. ## Table of Contents @@ -27,6 +27,7 @@ The skill family lets agents and users discover and load reusable task instructi | [`skill/`](skill/README.md) | Registry that merges skill catalogs from any provider and resolves the winning skill for a name | `ctx.skills` | | [`skill-filesystem/`](skill-filesystem/README.md) | Discovers skills from project, custom, and user directories and watches them for changes | registers on `ctx.skills` | | [`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` | ----- diff --git a/packages/skill/README.zh.md b/packages/skill/README.zh.md index f11bd1be5a..eb1231b6fc 100644 --- a/packages/skill/README.zh.md +++ b/packages/skill/README.zh.md @@ -9,7 +9,7 @@ kind: "package-group" ## 概述 -skill 家族让 agent 和用户仅在需要时发现并加载可复用的任务指令。使用 `skill/` 合并目录并为每个名称提供一组指令;需要从项目、自定义或用户目录发现 skill 时选择 `skill-filesystem`,需要可选的官方徽章时选择 `skill-badge`。需要让模型获得排序且持久的会话目录、通过 `skill` 工具加载完整指令,或接受 `/name` 直接调用时,请添加 `tool-skill`。不同来源生成相同的模型可见格式,启用模型访问前必须配置至少一个来源。 +skill 家族让 agent 和用户仅在需要时发现并加载可复用的任务指令。使用 `skill/` 合并目录并为每个名称提供一组指令;需要从项目、自定义或用户目录发现 skill 时选择 `skill-filesystem`,需要可选的官方徽章时选择 `skill-badge`,需要 Word、PowerPoint 和 Excel 工作流时选择 `skill-office`。需要让模型获得排序且持久的会话目录、通过 `skill` 工具加载完整指令,或接受 `/name` 直接调用时,请添加 `tool-skill`。不同来源生成相同的模型可见格式,启用模型访问前必须配置至少一个来源。 ## 目录 @@ -27,6 +27,7 @@ skill 家族让 agent 和用户仅在需要时发现并加载可复用的任务 | [`skill/`](skill/README.zh.md) | 合并任意提供方的 skill 目录、并按名称解析出胜出 skill 的注册表 | `ctx.skills` | | [`skill-filesystem/`](skill-filesystem/README.zh.md) | 从项目、自定义与用户目录发现 skill,并监视其变更 | 注册到 `ctx.skills` | | [`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` | ----- diff --git a/packages/skill/skill-office/README.i18n.yaml b/packages/skill/skill-office/README.i18n.yaml new file mode 100644 index 0000000000..1bf89bcdc8 --- /dev/null +++ b/packages/skill/skill-office/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/skill-office/README.md +README.md: 7ed8f0c0d839db3c62718f5ce97a55875bd65ddc +README.zh.md: 6b6e84bbd7db1a08a4c19c073759523b6cb4d1fd diff --git a/packages/skill/skill-office/README.md b/packages/skill/skill-office/README.md new file mode 100644 index 0000000000..7ed8f0c0d8 --- /dev/null +++ b/packages/skill/skill-office/README.md @@ -0,0 +1,100 @@ +--- +description: "Bundled Word, PowerPoint, and Excel instructions for deployments providing Office file authoring and structural checks." +kind: "package-reference" +--- + +# @deepseek-ai/dsh-skill-office + +English | [中文](README.zh.md) + +## Summary + +Agents can load Word, PowerPoint, and Excel workflows that use the bundled Python environment by default and respect explicit user or AGENTS.md environment choices. The skills cover creation, focused edits, structural checks, and file delivery. Visual inspection is conditional on image-capable models and an available rendering tool; ordinary document delivery does not require installing a renderer. + +## 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 this provider beside the skill registry and `dsh-tool-skill` to expose `office-docx`, `office-pptx`, and `office-xlsx` in the session catalog. The provider supplies instructions and scripts; the deployment supplies its interpreters, authoring libraries, execution tools, and file delivery tool. + +### Minimal configuration + +```yaml +- name: '@deepseek-ai/dsh-skill-office' +``` + +| Field | Default | Meaning | +|---|---|---| +| `assetRoot` | Packaged `assets/` | Absolute resource directory containing the three skill folders and shared `scripts/`; deployments can place it outside an application archive. | + +Relative paths, missing resources, and skill files without a YAML frontmatter description reject activation. Disposing the plugin removes its candidates. Project and user skill precedence remains owned by the skill registry. + +### Structural checks + +The shared Python checker reads DOCX, PPTX, or XLSX without modifying the source. It validates ZIP/XML and internal package relationships, reports document structure, and optionally checks required text or slide/sheet count. DOCX text assertions cover the main body, section-referenced headers and footers, and body-referenced footnotes and endnotes; comments, glossary text, and unreferenced parts or notes do not satisfy them. It uses only the Python standard library. Invalid packages, encrypted ZIP members, and report-file write failures produce a JSON failure report on stdout. A passing report does not establish appearance, feature preservation, or calculated formula results. + +----- + + +## Understand the implementation + +
    +Implementation internals — click to expand + +The provider registers three bundled candidates and reads their descriptions from the shipped YAML frontmatter at activation. Loaded instruction bodies exclude that metadata. Each loaded skill exposes its own filesystem directory, so the execution tool can resolve the shared checker without relying on the task working directory. Configurable external resources support carriers whose application archive is not readable by Python. + +| File | Responsibility | +|---|---| +| [`src/index.ts`](src/index.ts) | Provider registration and configured resource paths. | +| [`assets/`](assets/) | Three workflows and the read-only OOXML checker. | +| — | No runtime invariant companion is published: the provider owns immutable candidates, and the skill registry owns registration lifecycle and precedence. | + +
    + +----- + + +## Further Exploration + +- [Skill registry](../skill/README.md) — discovery and precedence. +- [Skill tool](../tool-skill/README.md) — model-visible catalogs and bodies. +- [File delivery](../../fs/tool-present/README.md) — current source-path delivery. + +----- + + +## Model Experience + +Indirectly, through `dsh-tool-skill`, which renders the catalog entries and selected instruction body. + +#### KV Cache effect + +Mounting the provider adds three catalog entries; loading a skill adds its body at the existing skill-tool insertion point. The provider does not add separate prompt sections. + +## Known Limitations and Deferred Work + + + +- The provider does not install Python, authoring libraries, or a rendering engine. The checker requires Python 3.9 or later. +- Structural checks do not judge pagination, clipping, fonts, chart appearance, or Excel recalculation. +- The checker accepts DOCX, PPTX, and XLSX only; legacy, encrypted, and macro-enabled formats require an appropriate separate workflow. + + +### Dev Note + +
    +Working context for maintainers — click to expand + +None. + +
    diff --git a/packages/skill/skill-office/README.zh.md b/packages/skill/skill-office/README.zh.md new file mode 100644 index 0000000000..6b6e84bbd7 --- /dev/null +++ b/packages/skill/skill-office/README.zh.md @@ -0,0 +1,100 @@ +--- +description: "随包附带的 Word、PowerPoint 和 Excel 指令,供需要 Office 文件编写与结构检查能力的部署使用。" +kind: "package-reference" +--- + +# @deepseek-ai/dsh-skill-office + +[English](README.md) | 中文 + +## 概述 + +Agent(智能体)可以加载 Word、PowerPoint 和 Excel 工作流,默认使用内置 Python 环境,并遵循用户或 AGENTS.md 明确指定的环境。这些 skill(技能)涵盖创建、局部编辑、结构检查和文件交付。视觉检查以模型支持图片且有可用渲染工具为前提;普通文档交付不要求安装渲染器。 + +## 目录 + +- [使用本包](#use-this-package) +- [了解实现](#understand-the-implementation) +- [延伸阅读](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与暂缓事项](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用本包 + +将本提供方与 skill 注册表及 `dsh-tool-skill` 一同挂载,即可在会话目录中提供 `office-docx`、`office-pptx` 和 `office-xlsx`。提供方携带指令和脚本;部署提供解释器、编写库、执行工具与文件交付工具。 + +### 最小配置 + +```yaml +- name: '@deepseek-ai/dsh-skill-office' +``` + +| 字段 | 默认值 | 含义 | +|---|---|---| +| `assetRoot` | 包内的 `assets/` | 包含三个 skill 文件夹和共享 `scripts/` 的绝对资源目录;部署可将其放在应用归档之外。 | + +相对路径、资源缺失或 skill 文件的 YAML frontmatter 缺少描述都会导致激活失败。卸载插件会移除其候选项。项目和用户 skill 的优先级仍由 skill 注册表负责。 + +### 结构检查 + +共享 Python 检查器读取 DOCX、PPTX 或 XLSX,不修改源文件。它验证 ZIP/XML 和包内引用关系,报告文档结构,并可检查必需文本或幻灯片/工作表数量。DOCX 文本断言覆盖正文、分节引用的页眉和页脚,以及正文引用的脚注和尾注;批注、词库文本与未引用的部件或脚注/尾注不能满足断言。它只使用 Python 标准库。无效包、加密 ZIP 成员和报告文件写入失败都会在标准输出中产生 JSON 失败报告。检查通过不代表外观、特性保留或公式计算结果已得到验证。 + +----- + + +## 了解实现 + +
    +实现细节——点击展开 + +提供方注册三个内置候选项,并在激活时从随包 YAML frontmatter 读取描述。加载后的指令正文不包含这些元数据。加载后的每个 skill 暴露自己的文件系统目录,因此执行工具可以定位共享检查器,而不依赖任务工作目录。可配置的外部资源支持 Python 无法读取应用归档的分发方式。 + +| 文件 | 职责 | +|---|---| +| [`src/index.ts`](src/index.ts) | 提供方注册与配置资源路径。 | +| [`assets/`](assets/) | 三种工作流和只读 OOXML 检查器。 | +| — | 不发布运行时不变量伴随入口:提供方拥有不可变候选项,skill 注册表负责注册生命周期和优先级。 | + +
    + +----- + + +## 延伸阅读 + +- [skill 注册表](../skill/README.zh.md)——发现与优先级。 +- [skill 工具](../tool-skill/README.zh.md)——模型可见目录与正文。 +- [文件交付](../../fs/tool-present/README.zh.md)——当前源路径交付。 + +----- + + +## 模型体验 + +通过 `dsh-tool-skill` 间接呈现,由其渲染目录项与选中的指令正文。 + +#### KV 缓存影响 + +挂载提供方会增加三个目录项;加载 skill 时,其正文进入既有 skill 工具的插入位置。提供方不另增提示词分区。 + +## 已知限制与暂缓事项 + + + +- 提供方不安装 Python、编写库或渲染引擎。检查器要求 Python 3.9 或更高版本。 +- 结构检查不判断分页、裁切、字体、图表外观或 Excel 重算。 +- 检查器只接受 DOCX、PPTX 和 XLSX;传统格式、加密文件和启用宏的格式需要合适的其他工作流。 + + +### 开发备注 + +
    +维护者的工作上下文——点击展开 + +无。 + +
    diff --git a/packages/skill/skill-office/assets/office-docx/SKILL.md b/packages/skill/skill-office/assets/office-docx/SKILL.md new file mode 100644 index 0000000000..b9edf3d8b8 --- /dev/null +++ b/packages/skill/skill-office/assets/office-docx/SKILL.md @@ -0,0 +1,46 @@ +--- +name: office-docx +description: Create, read, edit, and check Word documents (.docx), including reports, letters, and formatted tables. Use when a DOCX file is an input or requested deliverable. +--- + +# Word documents + +Use `python-docx` for DOCX creation and ordinary edits. Follow an explicit user or applicable AGENTS.md requirement for a project environment or another library. Otherwise call `load_workspace_dependencies` and execute the returned Python path with its bundled libraries. Do not install packages or discover a system Python for the default workflow. If the tool is unavailable, use an already configured environment and report a missing dependency only when it prevents the requested operation. + +Keep source scripts, intermediate files, and final documents in the task workspace; the runtime and this skill directory are read-only resources. Use the user's requested language and preserve an existing document's design unless a redesign is requested. + +## Create and edit + +For existing files, inspect paragraphs, runs, tables, sections, headers, and footers before changing the affected content. Save to a new file unless the user requests an in-place edit. Replacing a paragraph's `.text` destroys its run formatting; change the relevant runs when formatting must survive. Reconstructing the whole document can lose features outside python-docx's supported editing API. + +Use paragraph styles for headings and body text. Size tables for the section that contains them, and account for merged cells and nested tables. Chinese, Japanese, and Korean text may need an explicit `w:eastAsia` font assignment in addition to `run.font.name`; font names alone do not establish glyph availability or rendered appearance. + +For a new document, use the selected Python executable: + +```python +from docx import Document + +document = Document() +document.add_heading("Project report", level=0) +document.add_paragraph("Summary", style="Heading 1") +document.add_paragraph("The requested findings go here.") +document.save("report.docx") +``` + +python-docx does not paginate or render documents. Do not represent ordinary replacement, colored text, or comments as tracked changes. When real revisions or unsupported OOXML features matter, preserve their package parts and verify the requested operation rather than silently discarding them. + +## Check and deliver + +Run the shared checker with the selected Python executable; `` is this loaded skill's resource base: + +```text + /../scripts/check_office.py --out +``` + +It checks ZIP/XML integrity and internal relationships, and reports paragraphs, logical table dimensions, and sections. Optional `--contains TEXT` arguments assert required text. A successful structural check does not verify pagination, clipping, fonts, or visual appearance. Compare the summary and reopened document with the user's request, including unchanged content that matters to an edit. + +If `render_document` is available and visual inspection is useful, call it on the final DOCX without `pages` to prepare page 1 and learn `pageCount`. It checks the current main model's actual image capability; do not choose a second model. On `status: "skipped"`, complete structural and content checks and deliver the document, briefly stating that visual layout was not inspected. On a ready result, call `read_image` on `pages[].imagePath` and request remaining pages in small batches. Check page breaks, clipped text, headings, table widths, and consistency with the requested format or source design. Fix the source and render affected pages again. + +Use the available rendering tool for this check. Review `warnings` such as missing fonts. LibreOffice pagination can differ from Microsoft Word. If rendering is unavailable or fails, preserve the usable document and report the inspection limit; do not require the user to install a renderer. + +Call `present({"files":[{"path":"report.docx"}]})` with the actual final DOCX path. It exposes the current source file without copying or preserving its bytes, so keep that file in place and do not present temporary QA reports unless requested. If `present` is unavailable, provide the final workspace path using the session's supported file delivery method. diff --git a/packages/skill/skill-office/assets/office-pptx/SKILL.md b/packages/skill/skill-office/assets/office-pptx/SKILL.md new file mode 100644 index 0000000000..161fcfb7fa --- /dev/null +++ b/packages/skill/skill-office/assets/office-pptx/SKILL.md @@ -0,0 +1,70 @@ +--- +name: office-pptx +description: Create, read, edit, and check PowerPoint presentations (.pptx), including slide text, tables, images, and charts. Use when a PPTX file is an input or requested deliverable. +--- + +# PowerPoint presentations + +Follow an explicit user or applicable AGENTS.md requirement for an environment or library. Otherwise call `load_workspace_dependencies` and use its Python executable and bundled presentation libraries. Do not install npm or pip packages or locate a system interpreter for the default workflow. If the tool is unavailable, use an already configured environment and report a missing dependency only when it prevents the requested operation. + +Keep scripts and output files in the task workspace. The runtime and skill directory contain shared read-only resources. Match the requested slide language and the supplied presentation's design when editing it. + +## Create and edit + +Use `python-pptx` to inspect or modify an existing presentation. Inspect slide layouts, text runs, images, tables, and charts before editing. Change only the requested content, preserve mixed text formatting, and save to a new file unless the user requests an in-place edit. Rebuilding slides can discard unsupported animation, SmartArt, or other extension content. + +For a new deck, use `python-pptx` with editable text, tables, and charts. Use local image assets rather than network-dependent image URLs. Set slide dimensions, text sizes, and chart data explicitly. + +A minimal editable deck, run with the selected Python executable: + +```python +from pptx import Presentation +from pptx.chart.data import CategoryChartData +from pptx.enum.chart import XL_CHART_TYPE +from pptx.util import Inches, Pt + +presentation = Presentation() +presentation.slide_width = Inches(13.333) +presentation.slide_height = Inches(7.5) +slide = presentation.slides.add_slide(presentation.slide_layouts[6]) +title = slide.shapes.add_textbox(Inches(0.6), Inches(0.4), Inches(12), Inches(0.8)) +run = title.text_frame.paragraphs[0].add_run() +run.text = "Quarterly report" +run.font.size = Pt(30) +data = CategoryChartData() +data.categories = ["Q1", "Q2"] +data.add_series("Revenue", [12, 18]) +slide.shapes.add_chart(XL_CHART_TYPE.COLUMN_CLUSTERED, + Inches(0.8), Inches(1.6), Inches(11.5), Inches(4.8), data) +presentation.save("report.pptx") +``` + +Use `Inches` or `Cm` for positions and sizes and `Pt` for font sizes. To edit a generated title while retaining its other slides and charts: + +```python +from pptx import Presentation + +presentation = Presentation("report.pptx") +for shape in presentation.slides[0].shapes: + if shape.has_text_frame and shape.text == "Quarterly report": + shape.text_frame.paragraphs[0].runs[0].text = "Quarterly results" +presentation.save("report-edited.pptx") +``` + +Check that text and images fit the slide dimensions, titles form a useful sequence, and chart labels agree with source values. A native chart's embedded workbook is part of the deliverable and must contain the intended data. Library support for writing PPTX is not a rendering engine or a guarantee that every PowerPoint feature survives editing. + +## Check and deliver + +Run the shared checker with the selected Python executable; `` is this loaded skill's resource base: + +```text + /../scripts/check_office.py --out +``` + +It checks ZIP/XML integrity and internal relationships and reports slide count and extracted text. Use repeated `--contains TEXT` arguments for required slide text and `--count N` for a requested slide count; `--contains` excludes chart text and speaker notes. Reopen the file to check the requested edits, chart data, and notes. Structural success does not establish text fit, alignment, readable contrast, or rendering fidelity. + +If `render_document` is available and visual inspection is useful, call it on the final PPTX, initially omitting `pages` to prepare page 1 and obtain `pageCount`. The tool checks the current main model's actual image capability; do not choose a second model. If `status` is `skipped`, complete structural and content checks and deliver the PPTX, briefly stating that visual layout was not inspected. If ready, call `read_image` on its returned `pages[].imagePath`, then inspect remaining slides in small batches such as `pages: [2, 3, 4]`. Check clipping, alignment, contrast, chart labels, and consistency with the requested design or supplied reference; fix the source and render the affected pages again. + +Use the available rendering tool for this check. Inspect `warnings`, especially missing fonts. LibreOffice previews do not certify pixel-identical PowerPoint or Keynote output, animation, or media playback. If the rendering tool is unavailable or fails, retain the usable source and report the inspection limit; do not require the user to install another tool. + +Call `present({"files":[{"path":"report-edited.pptx"}]})` with the actual final PPTX path. It exposes the current source file without copying or preserving its bytes, so keep that file in place and leave intermediate images and reports out of delivery unless requested. If `present` is unavailable, use the session's supported file delivery method. diff --git a/packages/skill/skill-office/assets/office-xlsx/SKILL.md b/packages/skill/skill-office/assets/office-xlsx/SKILL.md new file mode 100644 index 0000000000..f4d6180afc --- /dev/null +++ b/packages/skill/skill-office/assets/office-xlsx/SKILL.md @@ -0,0 +1,56 @@ +--- +name: office-xlsx +description: Read, create, and modify Excel workbooks (.xlsx), including cell values, formulas, formatting, and pandas analysis. Use when an Excel workbook is an input or deliverable. +--- + +# Excel workbooks + +Follow an explicit user or applicable AGENTS.md requirement for an environment or library. Otherwise call `load_workspace_dependencies` and run its Python executable with bundled `openpyxl` and `pandas`. Do not install packages or search for a system Python for the default workflow. If the tool is unavailable, use an already configured environment and report a missing dependency only when it prevents the requested operation. + +Keep scripts, working files, and outputs in the task workspace. The runtime and skill directory are shared read-only resources. Save to a new workbook unless the user requests an in-place edit. + +## Read and modify + +Use `openpyxl` for existing XLSX workbooks and targeted cell or style changes. Load with `data_only=False` when formulas must survive. Inspect sheet names, the affected cell types, formulas, styles, merged ranges, and referenced ranges before editing. Check the saved file by reopening it, including unchanged content that the request requires preserving. + +Use pandas for data analysis and transformations. A DataFrame is not the workbook: exporting it over an existing file can lose sheets, formulas, charts, and formatting. Write analysis results back to the intended ranges with openpyxl. `XlsxWriter` can create new workbooks, but cannot read or modify existing ones. + +For a new workbook, the selected Python environment can write editable values, styles, and formulas directly: + +```python +from openpyxl import Workbook +from openpyxl.styles import Font + +workbook = Workbook() +sheet = workbook.active +sheet.title = "Revenue" +for row in [("Quarter", "Revenue"), ("Q1", 12), ("Q2", 18), ("Total", "=SUM(B2:B3)")]: + sheet.append(row) +for cell in sheet[1]: + cell.font = Font(bold=True) +sheet.column_dimensions["A"].width = 18 +sheet.column_dimensions["B"].width = 18 +workbook.save("report.xlsx") +``` + +Preserve numbers, dates, booleans, and identifiers as the intended cell types; formatting is not a type conversion. For modifications, load the existing file with `openpyxl.load_workbook("input.xlsx", data_only=False)`, change the requested ranges, and save a separate result. Avoid a DataFrame round trip when workbook features must survive. + +Writing a formula does not calculate its result. openpyxl and XlsxWriter do not evaluate Excel formulas; `data_only=True` returns stored cached values that may be absent or stale. Verify formulas and inputs separately, and state when current results require recalculation in a spreadsheet application. Do not replace requested formulas with constants or report cached values as newly calculated results. + +Do not rename `.xls`, `.xlsb`, encrypted files, or macro-enabled files to `.xlsx`. They need an appropriate supported operation. `keep_vba=True` can preserve VBA package content in an XLSM workflow, but does not execute or edit macros and does not guarantee every advanced workbook feature survives. Preserve the original and verify such requirements explicitly. + +## Check and deliver + +Run the shared checker with the selected Python executable; `` is this loaded skill's resource base: + +```text + /../scripts/check_office.py --out +``` + +It checks ZIP/XML integrity and internal relationships, and reports sheet names, populated-cell counts, and formula counts. Repeated `--contains TEXT` arguments check string cells and sheet names, and `--count N` checks sheet count. `--contains` excludes numeric cells and does not validate formula results; verify those separately with openpyxl. The checker does not calculate formulas or judge workbook appearance. Compare relevant values, types, formulas, styles, and totals with the task's source data. + +If `render_document` is available and visual inspection is useful, call it on the final workbook without `pages` to prepare page 1 and learn `pageCount`. It checks the current main model's actual image capability; do not choose a second model. On `status: "skipped"`, complete structural and data checks and deliver the workbook, briefly stating that visual layout was not inspected. Otherwise call `read_image` on `pages[].imagePath` and request remaining pages in small batches. Check column widths, number formats, clipping, charts, and print areas; fix the workbook and review the affected pages again. + +Rendered pages follow spreadsheet print settings, so a page is not necessarily a worksheet. Set appropriate print areas and scaling when a readable printed layout is requested. Review `warnings` such as missing fonts. LibreOffice preview conversion neither updates the original workbook's cached formulas nor certifies native Excel calculation or appearance. If rendering is unavailable or fails, preserve the usable workbook and report the inspection limit; ordinary cell edits do not require installing an external renderer. + +Call `present({"files":[{"path":"report.xlsx"}]})` with the actual final workbook path. It exposes the current source file without copying or preserving its bytes, so keep that file in place and omit intermediate scripts and reports unless requested. If `present` is unavailable, use the session's supported file delivery method. diff --git a/packages/skill/skill-office/assets/scripts/check_office.py b/packages/skill/skill-office/assets/scripts/check_office.py new file mode 100644 index 0000000000..faa5bb4596 --- /dev/null +++ b/packages/skill/skill-office/assets/scripts/check_office.py @@ -0,0 +1,228 @@ +#!/usr/bin/env python3 +"""Read-only OOXML checks and structural summaries; no rendering or formula evaluation. + +Run with INPUT.docx, INPUT.pptx, or INPUT.xlsx. Optional --contains assertions +check extracted text; DOCX excludes comments, glossary text, and unreferenced parts or notes. +--count checks slides or sheets. JSON escapes non-ASCII +characters and goes to stdout and optionally --out. Exit 0 means the requested +structural checks passed, 1 means a document, assertion, or report write failed, and 2 means +invalid command-line arguments. +""" + +from __future__ import annotations + +import argparse +import json +import posixpath +import sys +import zipfile +from pathlib import Path +from urllib.parse import unquote, urlsplit +from xml.etree import ElementTree as ET + +W = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}" +A = "{http://schemas.openxmlformats.org/drawingml/2006/main}" +P = "{http://schemas.openxmlformats.org/presentationml/2006/main}" +S = "{http://schemas.openxmlformats.org/spreadsheetml/2006/main}" +R = "{http://schemas.openxmlformats.org/officeDocument/2006/relationships}" +MAIN_PARTS = { + ".docx": ("word/document.xml", "wordprocessingml.document.main+xml"), + ".pptx": ("ppt/presentation.xml", "presentationml.presentation.main+xml"), + ".xlsx": ("xl/workbook.xml", "spreadsheetml.sheet.main+xml"), +} + + +def relationship_target(part: str, target: str) -> str: + """Resolve a package relationship without fetching external resources.""" + path = unquote(urlsplit(target).path) + return posixpath.normpath(path.lstrip("/") if path.startswith("/") else posixpath.join(posixpath.dirname(part), path)) + + +def relationships(part: str, xml: dict[str, ET.Element], types: set[str] | None = None) -> dict[str, str]: + path = posixpath.join(posixpath.dirname(part), "_rels", posixpath.basename(part) + ".rels") + root = xml.get(path) + if root is None: + return {} + return { + rel.attrib["Id"]: relationship_target(part, rel.attrib["Target"]) + for rel in root if rel.get("TargetMode") != "External" and (types is None or rel.get("Type") in types) + } + + +def related_xml(part: str, reference: str, links: dict[str, str], xml: dict[str, ET.Element]) -> ET.Element: + """Read a related XML part with diagnostics naming its source and reference.""" + if reference not in links: + raise ValueError(f"{part} references missing relationship: {reference}") + target = links[reference] + if target not in xml: + raise ValueError(f"{part} relationship {reference} targets a non-XML member: {target}") + return xml[target] + + +def inspect_docx(xml: dict[str, ET.Element]) -> tuple[dict, str]: + part = "word/document.xml" + root = xml[part] + body = root.find(f"{W}body") + if body is None: + raise ValueError("word/document.xml has no document body") + tables = [] + for table in body.iter(f"{W}tbl"): + grid = table.findall(f"{W}tblGrid/{W}gridCol") + rows = table.findall(f"{W}tr") + # Merged cells span logical grid columns; counting physical cells loses them. + columns = len(grid) if grid else max((sum( + int(cell.find(f"{W}tcPr/{W}gridSpan").get(f"{W}val", "1")) + if cell.find(f"{W}tcPr/{W}gridSpan") is not None else 1 + for cell in row.findall(f"{W}tc") + ) for row in rows), default=0) + tables.append({"rows": len(rows), "columns": columns}) + sections = [] + for section in body.iter(f"{W}sectPr"): + size = section.find(f"{W}pgSz") + margins = section.find(f"{W}pgMar") + sections.append({ + "page_twips": {} if size is None else {key.removeprefix(W): value for key, value in size.attrib.items()}, + "margins_twips": {} if margins is None else {key.removeprefix(W): value for key, value in margins.attrib.items()}, + }) + text_parts = [body] + for kind in ("header", "footer"): + links = relationships(part, xml, {f"{R[1:-1]}/{kind}"}) + for section in body.iter(f"{W}sectPr"): + for reference in section.findall(f"{W}{kind}Reference"): + text_parts.append(related_xml(part, reference.attrib[f"{R}id"], links, xml)) + for kind in ("footnote", "endnote"): + references = {node.attrib[f"{W}id"] for node in body.iter(f"{W}{kind}Reference")} + if not references: + continue + links = relationships(part, xml, {f"{R[1:-1]}/{kind}s"}) + for reference in links: + tree = related_xml(part, reference, links, xml) + text_parts.extend(note for note in tree.findall(f"{W}{kind}") if note.get(f"{W}id") in references) + text = "\n".join("".join(node.text or "" for node in paragraph.iter(f"{W}t")) for tree in text_parts + for paragraph in tree.iter(f"{W}p")) + return {"paragraphs": len(list(body.iter(f"{W}p"))), "tables": tables, "sections": sections}, text + + +def inspect_pptx(xml: dict[str, ET.Element]) -> tuple[dict, str]: + part = "ppt/presentation.xml" + links = relationships(part, xml) + slides = xml[part].findall(f"{P}sldIdLst/{P}sldId") + texts = [] + for slide in slides: + reference = slide.attrib[f"{R}id"] + tree = related_xml(part, reference, links, xml) + texts.append("\n".join("".join(node.text or "" for node in paragraph.iter(f"{A}t")) + for paragraph in tree.iter(f"{A}p"))) + return {"slides": len(slides)}, "\n".join(texts) + + +def inspect_xlsx(xml: dict[str, ET.Element]) -> tuple[dict, str]: + part = "xl/workbook.xml" + links = relationships(part, xml) + sheets = [] + texts = [] + shared = xml.get("xl/sharedStrings.xml") + shared_strings = [] if shared is None else [ + "".join(node.text or "" for node in item.iter(f"{S}t")) for item in shared.iter(f"{S}si") + ] + for sheet in xml[part].findall(f"{S}sheets/{S}sheet"): + reference = sheet.attrib[f"{R}id"] + tree = related_xml(part, reference, links, xml) + cells = list(tree.iter(f"{S}c")) + formulas = sum(cell.find(f"{S}f") is not None for cell in cells) + sheets.append({"name": sheet.attrib["name"], "cells": len(cells), "formulas": formulas}) + for cell in cells: + if cell.get("t") == "s": + value = cell.findtext(f"{S}v", "") + location = f"{links[reference]} cell {cell.get('r', '(no reference)')}" + try: + index = int(value) + except ValueError as error: + raise ValueError(f"{location}: invalid shared string index {value!r}") from error + if not 0 <= index < len(shared_strings): + raise ValueError(f"{location}: shared string index out of range: {index}") + texts.append(shared_strings[index]) + texts.extend("".join(node.text or "" for node in cell.iter(f"{S}t")) for cell in cells) + texts.extend(cell.findtext(f"{S}v", "") for cell in cells if cell.get("t") == "str") + texts.append(sheet.attrib["name"]) + return {"sheets": sheets, "formulas_evaluated": False}, "\n".join(texts) + + +def inspect(path: Path) -> tuple[dict, str]: + """Validate package members and relationships before inspecting the main part.""" + main, content_type = MAIN_PARTS[path.suffix.lower()] + with zipfile.ZipFile(path) as archive: + members = archive.namelist() + if len(members) != len(set(members)): + raise ValueError("ZIP contains duplicate member names") + corrupt = archive.testzip() + if corrupt is not None: + raise ValueError(f"ZIP member failed its CRC check: {corrupt}") + xml = {name: ET.fromstring(archive.read(name)) for name in members + if name.endswith((".xml", ".rels"))} + if main not in xml: + raise ValueError(f"missing main part: {main}") + types = xml.get("[Content_Types].xml") + if types is None or not any(node.get("PartName") == "/" + main + and node.get("ContentType", "").endswith(content_type) for node in types): + raise ValueError(f"[Content_Types].xml does not declare {main} as {path.suffix.lower()}") + for name, tree in xml.items(): + if not name.endswith(".rels"): + continue + source = "" if name == "_rels/.rels" else posixpath.join(posixpath.dirname(posixpath.dirname(name)), posixpath.basename(name)[:-5]) + for rel in tree: + if rel.get("TargetMode") == "External": + continue + target = relationship_target(source, rel.attrib["Target"]) + if target not in members: + raise ValueError(f"{name} references missing package member: {target}") + if path.suffix.lower() == ".docx": + return inspect_docx(xml) + if path.suffix.lower() == ".pptx": + return inspect_pptx(xml) + return inspect_xlsx(xml) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("input", type=Path) + parser.add_argument("--out", type=Path) + parser.add_argument("--contains", action="append", default=[], metavar="TEXT") + parser.add_argument("--count", type=int, help="expected slide or sheet count") + args = parser.parse_args() + if args.input.suffix.lower() not in MAIN_PARTS: + parser.error("input must be .docx, .pptx, or .xlsx; converting the filename does not convert its contents") + if args.count is not None and (args.count < 0 or args.input.suffix.lower() == ".docx"): + parser.error("--count must be non-negative and applies only to slides or sheets") + if args.out is not None and args.out.resolve() == args.input.resolve(): + parser.error("--out must differ from the input document") + checks = [] + summary = {} + try: + summary, text = inspect(args.input) + checks.append({"id": "package", "status": "pass"}) + for required in args.contains: + checks.append({"id": "contains", "status": "pass" if required in text else "fail", "text": required}) + if args.count is not None: + actual = summary.get("slides", len(summary.get("sheets", []))) + checks.append({"id": "count", "status": "pass" if actual == args.count else "fail", "expected": args.count, "actual": actual}) + except (OSError, ValueError, KeyError, RuntimeError, ET.ParseError, zipfile.BadZipFile) as error: + checks.append({"id": "package", "status": "fail", "detail": str(error)}) + failed = any(check["status"] == "fail" for check in checks) + report = {"format": args.input.suffix.lower()[1:], "verdict": "fail" if failed else "pass", "checks": checks, "summary": summary} + output = json.dumps(report, ensure_ascii=True, indent=2) + "\n" + if args.out is not None: + try: + args.out.parent.mkdir(parents=True, exist_ok=True) + args.out.write_text(output, encoding="utf-8") + except OSError as error: + failed = True + report["verdict"] = "fail" + checks.append({"id": "output", "status": "fail", "detail": str(error)}) + output = json.dumps(report, ensure_ascii=True, indent=2) + "\n" + sys.stdout.write(output) + return 1 if failed else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/packages/skill/skill-office/package.json b/packages/skill/skill-office/package.json new file mode 100644 index 0000000000..ba80793802 --- /dev/null +++ b/packages/skill/skill-office/package.json @@ -0,0 +1,43 @@ +{ + "name": "@deepseek-ai/dsh-skill-office", + "description": "Bundled Word, PowerPoint, and Excel workflows and structural checks", + "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/skill-office" + }, + "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", + "assets", + "lib/types/**/*.d.ts" + ], + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/dsh-skill": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/dsh-skill": "workspace:^", + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/cordis-plugin-include": "workspace:^" + }, + "dependencies": { + "@deepseek-ai/schemastery": "workspace:^", + "yaml": "^2.4.2" + } +} diff --git a/packages/skill/skill-office/src/index.ts b/packages/skill/skill-office/src/index.ts new file mode 100644 index 0000000000..b1dcd2894c --- /dev/null +++ b/packages/skill/skill-office/src/index.ts @@ -0,0 +1,70 @@ +/** Bundled Office workflows and filesystem resources for document authoring and checks. */ + +import { readFileSync, statSync } from 'node:fs' +import { readFile } from 'node:fs/promises' +import { isAbsolute, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' +import { BUNDLED_SKILL_RANK, type SkillCandidate, type SkillProvider } from '@deepseek-ai/dsh-skill' +import { parse as parseYaml } from 'yaml' + +const SKILL_NAMES = ['office-docx', 'office-pptx', 'office-xlsx'] as const + +/** Office skill resource location. */ +export interface Config { + /** Absolute assets directory containing the three skill folders and shared scripts; defaults to packaged assets. */ + assetRoot?: string +} + +/** Validated resource configuration. */ +export const Config: z = z.object({ assetRoot: z.string().min(1) }) + +/** Cordis plugin identity. */ +export const name = 'skill-office' +/** Registry used by the bundled provider. */ +export const inject = ['skills'] + +function parseSkill(raw: string, path: string): { description: string; content: string } { + const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/u.exec(raw) + if (frontmatter?.[1] === undefined) throw new Error(`skill-office: ${path} has no YAML frontmatter`) + const metadata: unknown = parseYaml(frontmatter[1]) + const description = typeof metadata === 'object' && metadata !== null && 'description' in metadata + ? metadata.description : undefined + if (typeof description !== 'string' || description.length === 0) throw new Error(`skill-office: ${path} has no description`) + return { description, content: raw.slice(frontmatter[0].length).trim() } +} + +/** + * Register Office skills with resources readable by the script interpreter. + * @param ctx - Context carrying the skill registry. + * @param config - Optional external assets directory for packaged applications. + */ +export function apply(ctx: Context, config: Config = {}): void { + const assetRoot = config.assetRoot ?? fileURLToPath(new URL('../assets/', import.meta.url)) + if (!isAbsolute(assetRoot)) throw new Error('skill-office: assetRoot must be an absolute directory') + if (!statSync(join(assetRoot, 'scripts', 'check_office.py')).isFile()) { + throw new Error('skill-office: assets must contain scripts/check_office.py') + } + const candidates: SkillCandidate[] = SKILL_NAMES.map((skillName) => { + const directory = join(assetRoot, skillName) + const path = join(directory, 'SKILL.md') + const { description } = parseSkill(readFileSync(path, 'utf8'), path) + return { + name: skillName, description, + invocation: { modelInvocable: true, userInvocable: true }, + provider: 'dsh-office', source: 'bundled', rank: BUNDLED_SKILL_RANK, + resourceBase: { kind: 'directory', path: directory }, locator: path, + } + }) + const provider: SkillProvider = { + name: 'dsh-office', + list: () => Promise.resolve(candidates), + async get(candidate, options) { + const { rank: _rank, locator, ...summary } = candidate + const raw = await readFile(locator as string, { encoding: 'utf8', signal: options.signal }) + return { ...summary, content: parseSkill(raw, locator as string).content } + }, + } + ctx.skills.registerProvider(() => provider) +} diff --git a/packages/skill/skill-office/tests/check_office_test.py b/packages/skill/skill-office/tests/check_office_test.py new file mode 100644 index 0000000000..ccb530de0d --- /dev/null +++ b/packages/skill/skill-office/tests/check_office_test.py @@ -0,0 +1,228 @@ +"""Exercise the distributed checker without requiring external Office libraries.""" + +import json +import subprocess +import sys +import tempfile +import unittest +import zipfile +from pathlib import Path + +CHECKER = Path(__file__).resolve().parents[1] / "assets/scripts/check_office.py" +W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main" +P = "http://schemas.openxmlformats.org/presentationml/2006/main" +A = "http://schemas.openxmlformats.org/drawingml/2006/main" +S = "http://schemas.openxmlformats.org/spreadsheetml/2006/main" +R = "http://schemas.openxmlformats.org/officeDocument/2006/relationships" +PKG = "http://schemas.openxmlformats.org/package/2006/relationships" + + +class OfficeCheckTest(unittest.TestCase): + def setUp(self): + self.directory = tempfile.TemporaryDirectory(prefix="dsh-office-check-") + self.addCleanup(self.directory.cleanup) + self.root = Path(self.directory.name) + + def package(self, suffix, parts): + primary, mime = { + "docx": ("word/document.xml", "wordprocessingml.document.main+xml"), + "pptx": ("ppt/presentation.xml", "presentationml.presentation.main+xml"), + "xlsx": ("xl/workbook.xml", "spreadsheetml.sheet.main+xml"), + }[suffix] + path = self.root / ("中文 document." + suffix) + with zipfile.ZipFile(path, "w") as archive: + archive.writestr("[Content_Types].xml", '' + f'') + for name, content in parts.items(): + archive.writestr(name, content) + return path + + def run_check(self, path, *args): + before = path.read_bytes() + output = self.root / "checks.json" + result = subprocess.run([sys.executable, str(CHECKER), str(path), "--out", str(output), *args], + capture_output=True, text=True, timeout=10) + self.assertEqual(path.read_bytes(), before) + self.assertEqual(result.stderr, "", result.stderr) + report = json.loads(result.stdout) + self.assertEqual(report, json.loads(output.read_text(encoding="utf-8"))) + return result.returncode, report + + def test_docx_merged_cells_distinct_sections_and_cjk_are_not_layout_failures(self): + path = self.package("docx", {"word/document.xml": f''' + 中文报告 + 合并标题 + + '''}) + code, report = self.run_check(path, "--contains", "中文报告") + self.assertEqual(code, 0) + self.assertEqual(report["summary"]["tables"], [{"rows": 1, "columns": 2}]) + self.assertEqual(len(report["summary"]["sections"]), 2) + code, report = self.run_check(path, "--contains", "Missing requested title") + self.assertEqual(code, 1) + self.assertEqual(report["checks"][-1]["status"], "fail") + + def test_pptx_checks_slide_count_and_relationships(self): + parts = { + "ppt/presentation.xml": f'', + "ppt/_rels/presentation.xml.rels": f'', + "ppt/slides/slide1.xml": f'季度总结', + } + path = self.package("pptx", parts) + self.assertEqual(self.run_check(path, "--contains", "季度总结", "--count", "1")[0], 0) + self.assertEqual(self.run_check(path, "--count", "2")[0], 1) + parts["ppt/_rels/presentation.xml.rels"] = f'' + code, report = self.run_check(self.package("pptx", parts)) + self.assertEqual(code, 1) + self.assertIn("ppt/presentation.xml", report["checks"][0]["detail"]) + self.assertIn("r1", report["checks"][0]["detail"]) + parts["ppt/_rels/presentation.xml.rels"] = f'' + del parts["ppt/slides/slide1.xml"] + path = self.package("pptx", parts) + code, report = self.run_check(path) + self.assertEqual(code, 1) + self.assertIn("missing package member", report["checks"][0]["detail"]) + + def test_docx_content_follows_section_and_note_references(self): + parts = { + "word/document.xml": f''' + Main text + + ''', + "word/_rels/document.xml.rels": f''' + + + + + + + + + ''', + "word/custom/header.xml": f'Linked header', + "word/custom/footer.xml": f'Linked footer', + "word/custom/unused-header.xml": f'Retained header only', + "word/custom/unused-footer.xml": f'Retained footer only', + "word/custom/notes.xml": f''' + Linked note + Unused note only + ''', + "word/custom/endnotes.xml": f''' + Linked endnote + Unused endnote only + ''', + "word/comments.xml": f'Comment only', + "word/glossary/document.xml": f'Building block only', + "word/header9.xml": f'Unused header only', + } + path = self.package("docx", parts) + self.assertEqual(self.run_check(path, "--contains", "Main text", "--contains", "Linked header", "--contains", "Linked footer", + "--contains", "Linked note", "--contains", "Linked endnote")[0], 0) + for text in ["Comment only", "Building block only", "Unused header only", "Retained header only", "Retained footer only", + "Unused note only", "Unused endnote only"]: + with self.subTest(text=text): + code, report = self.run_check(path, "--contains", text) + self.assertEqual(code, 1) + self.assertEqual(report["checks"][-1]["status"], "fail") + + def test_relationships_to_non_xml_members_report_source_reference_and_target(self): + for suffix, main, references in [ + ("pptx", "ppt/presentation.xml", f''), + ("xlsx", "xl/workbook.xml", f''), + ]: + with self.subTest(format=suffix): + folder, filename = main.split("/") + target = f"{folder}/media/image1.png" + path = self.package(suffix, { + main: references, + f"{folder}/_rels/{filename}.rels": f'', + target: b"image bytes", + }) + code, report = self.run_check(path) + self.assertEqual(code, 1) + detail = report["checks"][0]["detail"] + for expected in [main, "r1", target]: + self.assertIn(expected, detail) + + def test_xlsx_counts_formulas_without_claiming_recalculation(self): + path = self.package("xlsx", { + "xl/workbook.xml": f'', + "xl/_rels/workbook.xml.rels": f'', + "xl/worksheets/sheet1.xml": f'收入4B1*2', + }) + code, report = self.run_check(path, "--contains", "收入", "--count", "1") + self.assertEqual(code, 0) + self.assertEqual(report["summary"], {"sheets": [{"name": "Data", "cells": 3, "formulas": 1}], "formulas_evaluated": False}) + + def test_xlsx_checks_only_cell_referenced_shared_strings(self): + parts = { + "xl/workbook.xml": f'', + "xl/_rels/workbook.xml.rels": f'', + "xl/sharedStrings.xml": f'Deleted value实际内容', + "xl/worksheets/sheet1.xml": f'1Inline value', + } + path = self.package("xlsx", parts) + self.assertEqual(self.run_check(path, "--contains", "实际内容", "--contains", "Inline value")[0], 0) + code, report = self.run_check(path, "--contains", "Deleted value") + self.assertEqual(code, 1) + self.assertEqual(report["checks"][-1]["status"], "fail") + for index in ["-1", "2", "invalid", ""]: + with self.subTest(index=index): + parts["xl/worksheets/sheet1.xml"] = f'{index}' + code, report = self.run_check(self.package("xlsx", parts)) + self.assertEqual(code, 1) + self.assertEqual(report["checks"][0]["status"], "fail") + self.assertIn("xl/worksheets/sheet1.xml", report["checks"][0]["detail"]) + self.assertIn("A1", report["checks"][0]["detail"]) + parts["xl/_rels/workbook.xml.rels"] = f'' + code, report = self.run_check(self.package("xlsx", parts)) + self.assertEqual(code, 1) + self.assertIn("xl/workbook.xml", report["checks"][0]["detail"]) + self.assertIn("r1", report["checks"][0]["detail"]) + + def test_xlsx_empty_or_missing_string_values_remain_json_reports(self): + for cell_type, expected in [("s", 1), ("str", 0)]: + for value in ["", ""]: + with self.subTest(cell_type=cell_type, value=value): + path = self.package("xlsx", { + "xl/workbook.xml": f'', + "xl/_rels/workbook.xml.rels": f'', + "xl/worksheets/sheet1.xml": f'{value}', + }) + code, report = self.run_check(path) + self.assertEqual(code, expected) + self.assertEqual(report["verdict"], "fail" if expected else "pass") + if cell_type == "s": + self.assertIn("A1: invalid shared string index ''", report["checks"][0]["detail"]) + + def test_output_directory_failure_is_json_and_preserves_the_document(self): + path = self.package("docx", {"word/document.xml": f''}) + before = path.read_bytes() + result = subprocess.run([sys.executable, str(CHECKER), str(path), "--out", str(self.root)], capture_output=True, text=True, timeout=10) + self.assertEqual(result.returncode, 1) + self.assertEqual(result.stderr, "") + report = json.loads(result.stdout) + self.assertEqual(report["verdict"], "fail") + self.assertEqual(report["checks"][-1]["id"], "output") + self.assertEqual(path.read_bytes(), before) + + def test_encrypted_package_returns_json_failure(self): + path = Path(__file__).parent / "fixtures/encrypted.xlsx" + code, report = self.run_check(path) + self.assertEqual(code, 1) + self.assertIn("encrypted", report["checks"][0]["detail"]) + + def test_invalid_zip_and_xml_fail_and_output_cannot_overwrite_input(self): + path = self.root / "broken.docx" + path.write_bytes(b"not an Office archive") + self.assertEqual(self.run_check(path)[0], 1) + path = self.package("docx", {"word/document.xml": ""}) + self.assertEqual(self.run_check(path)[0], 1) + before = path.read_bytes() + result = subprocess.run([sys.executable, str(CHECKER), str(path), "--out", str(path)], capture_output=True, timeout=10) + self.assertEqual(result.returncode, 2) + self.assertEqual(path.read_bytes(), before) + + +if __name__ == "__main__": + unittest.main() diff --git a/packages/skill/skill-office/tests/checkers.spec.ts b/packages/skill/skill-office/tests/checkers.spec.ts new file mode 100644 index 0000000000..a2a62768c4 --- /dev/null +++ b/packages/skill/skill-office/tests/checkers.spec.ts @@ -0,0 +1,14 @@ +import { spawnSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import { expect, it } from 'vitest' + +it.each(['utf-8', 'cp1252'])('executes the shipped OOXML checker against valid, edited, and broken documents with %s stdout', (encoding) => { + const python = process.env.DSH_OFFICE_TEST_PYTHON ?? (process.platform === 'win32' ? 'python' : 'python3') + const result = spawnSync(python, [fileURLToPath(new URL('./check_office_test.py', import.meta.url))], { + env: { ...process.env, PYTHONIOENCODING: encoding }, + encoding: 'utf8', timeout: 30_000, + }) + expect(result.error).toBeUndefined() + expect(result.signal, result.stderr + result.stdout).toBeNull() + expect(result.status, result.stderr + result.stdout).toBe(0) +}) diff --git a/packages/skill/skill-office/tests/fixtures/encrypted.xlsx b/packages/skill/skill-office/tests/fixtures/encrypted.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..f2bbc2e6634fc19d9dcca461f16f55eb6bee16f4 GIT binary patch literal 224 zcmWIWW@h1H;ACK6NZYI*dt64`Mg+*_0AgVV8HQ-*{JfIXyps5k%7WD5SiOqeoX`+X z2Id2yE3-hjw1S&~k>x8R0|N_4+w6+9i@X{(wPhOnJo#z5VRu)O<;nnWb`E4?1H2iT s { + it('loads each packaged body and removes all candidates on disposal', async () => { + const ctx = new Context() + try { + await ctx.plugin(SkillRegistry) + const fiber = await ctx.plugin(SkillOffice) + const catalog = await ctx.skills.list() + expect(catalog.map(skill => skill.name)).toEqual(names) + for (const skill of catalog) { + expect(skill.description.length).toBeLessThanOrEqual(500) + expect(skill).toMatchObject({ source: 'bundled', provider: 'dsh-office', invocation: { modelInvocable: true, userInvocable: true } }) + const loaded = await ctx.skills.get(skill.name) + expect(loaded?.resourceBase).toEqual({ kind: 'directory', path: join(assets, skill.name) }) + const raw = await readFile(join(assets, skill.name, 'SKILL.md'), 'utf8') + expect(loaded?.content).toBe(raw.slice(raw.indexOf('\n---\n') + 5).trim()) + } + await fiber.dispose() + expect(await ctx.skills.list()).toEqual([]) + } finally { + await ctx.fiber.dispose() + } + }) + + it('loads relocated resources through a real cordis.yml composition', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-office-skills-')) + const ctx = new Context() + try { + const external = join(root, '中文 assets') + await cp(assets, external, { recursive: true }) + const configPath = join(root, 'cordis.yml') + await writeFile(configPath, [ + "- name: '@deepseek-ai/dsh-skill'", + "- name: '@deepseek-ai/dsh-skill-office'", + ' config:', + ` assetRoot: ${JSON.stringify(external)}`, + '', + ].join('\n')) + ctx.baseUrl = pathToFileURL(root).href + '/' + await ctx.plugin(Loader) + ctx.loader.builtins.include = Include + const modules = new Map([ + ['@deepseek-ai/dsh-skill', SkillRegistry], + ['@deepseek-ai/dsh-skill-office', SkillOffice], + ]) + ctx.loader.internal = { + version: 'v2', + async import(specifier: string) { + if (!modules.has(specifier)) throw new Error(`Unexpected Loader import: ${specifier}`) + return modules.get(specifier) + }, + } as unknown as NonNullable + await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(configPath).href } }) + await ctx.loader.await() + expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(names) + const loaded = await ctx.skills.get('office-xlsx') + expect(loaded?.resourceBase).toEqual({ kind: 'directory', path: join(external, 'office-xlsx') }) + const raw = await readFile(join(external, 'office-xlsx', 'SKILL.md'), 'utf8') + expect(loaded?.content).toBe(raw.slice(raw.indexOf('\n---\n') + 5).trim()) + } finally { + await ctx.fiber.dispose() + await rm(root, { recursive: true, force: true }) + } + }) + + it.each([ + ['description: "Quoted: description"', '\r\n', 'Quoted: description'], + ['description: >-\n Folded\n description', '\n', 'Folded description'], + ])('parses frontmatter %s and leaves body metadata-like lines intact', async (header, newline, description) => { + const root = await mkdtemp(join(tmpdir(), 'dsh-office-metadata-')) + const ctx = new Context() + try { + await cp(assets, root, { recursive: true }) + const body = '# Office instructions\n\ndescription: instruction text' + await writeFile(join(root, 'office-docx', 'SKILL.md'), `---\n${header}\n---\n\n${body}\n`.replaceAll('\n', newline)) + await ctx.plugin(SkillRegistry) + await ctx.plugin(SkillOffice, { assetRoot: root }) + const skill = await ctx.skills.get('office-docx') + expect(skill?.description).toBe(description) + expect(skill?.content).toBe(body.replaceAll('\n', newline)) + } finally { + await ctx.fiber.dispose() + await rm(root, { recursive: true, force: true }) + } + }) + + it('rejects relative resource paths and incomplete asset trees before registration', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-office-invalid-')) + const ctx = new Context() + try { + await ctx.plugin(SkillRegistry) + expect(() => { SkillOffice.apply(ctx, { assetRoot: 'assets' }) }).toThrow('absolute directory') + await cp(assets, root, { recursive: true }) + await rm(join(root, 'scripts', 'check_office.py')) + await cp(join(assets, 'office-docx'), join(root, 'scripts', 'check_office.py'), { recursive: true }) + expect(() => { SkillOffice.apply(ctx, { assetRoot: root }) }).toThrow('scripts/check_office.py') + await rm(join(root, 'scripts', 'check_office.py'), { recursive: true }) + await cp(join(assets, 'scripts', 'check_office.py'), join(root, 'scripts', 'check_office.py')) + await writeFile(join(root, 'office-docx', 'SKILL.md'), '# Missing frontmatter\ndescription: body text\n') + expect(() => { SkillOffice.apply(ctx, { assetRoot: root }) }).toThrow('has no YAML frontmatter') + for (const header of ['', 'null', 'scalar', 'name: office-docx', 'description: 3', 'description: ""']) { + await writeFile(join(root, 'office-docx', 'SKILL.md'), `---\n${header}\n---\n# Instructions\n`) + expect(() => { SkillOffice.apply(ctx, { assetRoot: root }) }).toThrow('has no description') + } + expect(await ctx.skills.list()).toEqual([]) + } finally { + await ctx.fiber.dispose() + await rm(root, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/skill/skill-office/tsconfig.json b/packages/skill/skill-office/tsconfig.json new file mode 100644 index 0000000000..60eff3dae3 --- /dev/null +++ b/packages/skill/skill-office/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../skill" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 361805bbcc..c897aaa249 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -48,6 +48,9 @@ importers: '@deepseek-ai/dsh-llm-replay': specifier: workspace:^ version: link:packages/test-support/llm-replay + '@deepseek-ai/dsh-skill-office': + specifier: workspace:^ + version: link:packages/skill/skill-office '@deepseek-ai/dsh-tool-session-query': specifier: workspace:^ version: link:packages/session-query/tool-session-query @@ -608,6 +611,9 @@ importers: '@deepseek-ai/dsh-host-webserver': specifier: workspace:^ version: link:../../packages/host/webserver + '@deepseek-ai/dsh-skill-office': + specifier: workspace:^ + version: link:../../packages/skill/skill-office '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../packages/core/tools @@ -10006,6 +10012,28 @@ importers: specifier: workspace:^ version: link:../skill + packages/skill/skill-office: + dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + yaml: + specifier: ^2.4.2 + version: 2.9.0 + 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-skill': + specifier: workspace:^ + version: link:../skill + packages/skill/tool-skill: dependencies: '@deepseek-ai/schemastery': diff --git a/scripts/check-workspace-constraints.spec.ts b/scripts/check-workspace-constraints.spec.ts index c536240a6c..c824e41b3d 100644 --- a/scripts/check-workspace-constraints.spec.ts +++ b/scripts/check-workspace-constraints.spec.ts @@ -198,3 +198,11 @@ it('requires the shared Web injection entry in the published payload', () => { ...manifest, files: ['lib/index.js', 'lib/**/*.css', 'lib/types/**/*.d.ts'], } })).toEqual([expect.stringContaining('package.json files must be')]) }) + +it('requires Office skill bodies and helpers in the published payload', () => { + const manifest = JSON.parse(readFileSync(new URL('../packages/skill/skill-office/package.json', import.meta.url), 'utf8')) as WorkspaceManifest['manifest'] + expect(checkWorkspaceManifest({ dir: 'packages/skill/skill-office', manifest })).toEqual([]) + expect(checkWorkspaceManifest({ dir: 'packages/skill/skill-office', manifest: { + ...manifest, files: ['lib/index.js', 'lib/types/**/*.d.ts'], + } })).toEqual([expect.stringContaining('package.json files must be')]) +}) diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index 7023c0c8f1..7e9dea5287 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -180,6 +180,7 @@ const packageFileExtras: Readonly> = { // also shares its generated FFI code through a hashed runtime chunk. '@deepseek-ai/dsh-sandbox-windows-acl': ['lib/runner.js', 'lib/types-*.js'], '@deepseek-ai/dsh-skill-badge': ['assets'], + '@deepseek-ai/dsh-skill-office': ['assets'], '@deepseek-ai/dsh-subprocess': ['lib/control.js'], // SSH launches a private helper and shares wire definitions and TLS setup // between that helper and the connection owner. diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 9aa044a0bb..6c03622bba 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -485,7 +485,7 @@ const SERVICE_ROLES: ServiceRole[] = [ pkg: 'skill', title: 'Skill provider registry', mode: 'seam', - implementations: ['skill-badge', 'skill-filesystem'], + implementations: ['skill-badge', 'skill-filesystem', 'skill-office'], consumers: ['tool-skill'], note: 'Merges provider skill catalogs; tool-skill renders the session-prefix catalog and loads complete skill bodies.', }, diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 0804913cc7..83104cb7fb 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -175,6 +175,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/identity/anonymous-user-id': { kind: 'none', reason: 'The shared identifier reaches DeepSeek only as model-hidden HTTP metadata; it registers nothing model-facing.' }, 'packages/skill/skill': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-skill.' }, 'packages/skill/skill-badge': { kind: 'indirect', reason: 'The bundled provider delegates model rendering to dsh-tool-skill.' }, + 'packages/skill/skill-office': { kind: 'indirect', reason: 'The bundled Office provider delegates model rendering to dsh-tool-skill.' }, 'packages/skill/skill-filesystem': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-skill.' }, 'packages/spill/spill': { kind: 'indirect', reason: 'The storage seam delegates model rendering to spill consumers.' }, 'packages/spill/spill-local': { kind: 'indirect', reason: 'The storage backend delegates model rendering to spill consumers.' }, diff --git a/snapshots/session/headless.snapshot.ts b/snapshots/session/headless.snapshot.ts index f072ebc1fa..840760c70d 100644 --- a/snapshots/session/headless.snapshot.ts +++ b/snapshots/session/headless.snapshot.ts @@ -459,6 +459,9 @@ async function seedWorkspace(scenario: HeadlessScenario, cwd: string): Promise Promise> = { + async 'office-skills'(cwd) { + await cp(join(repoRoot, 'packages/skill/skill-office/assets'), join(cwd, 'office-skills'), { recursive: true }) + }, async 'editing-cordis-skill'(cwd) { const target = join(cwd, '.dsh', 'skills', 'editing-cordis-compositions', 'SKILL.md') await mkdir(dirname(target), { recursive: true }) diff --git a/snapshots/session/office-skills-no-renderer/cordis.snapshot.yml b/snapshots/session/office-skills-no-renderer/cordis.snapshot.yml new file mode 100644 index 0000000000..654dc32dde --- /dev/null +++ b/snapshots/session/office-skills-no-renderer/cordis.snapshot.yml @@ -0,0 +1,22 @@ +- id: llm-deepseek + disabled: true + +- insert: + - id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' + config: + providers: + - id: deepseek-official + name: DeepSeek + models: + - id: deepseek-v4-flash + contextWindow: 1000000 + defaultMaxTokens: 8192 + reasoningEfforts: [off, low, high, max] + defaultReasoningEffort: high + +- insert: + - id: office-skills + name: '@deepseek-ai/dsh-skill-office' + config: + assetRoot: !!js process.cwd() + '/office-skills' diff --git a/snapshots/session/office-skills-no-renderer/cordis.yml b/snapshots/session/office-skills-no-renderer/cordis.yml new file mode 100644 index 0000000000..4c963015fe --- /dev/null +++ b/snapshots/session/office-skills-no-renderer/cordis.yml @@ -0,0 +1,14 @@ +- insert: + - id: office-skills + name: '@deepseek-ai/dsh-skill-office' + config: + assetRoot: !!js process.cwd() + '/office-skills' + +- id: llm-deepseek + config: + protocol: chat-completions + thinking: enabled + reasoningEffort: high + maxTokens: 8192 + models: + - id: deepseek-v4-flash diff --git a/snapshots/session/office-skills-no-renderer/session.v3.jsonl b/snapshots/session/office-skills-no-renderer/session.v3.jsonl new file mode 100644 index 0000000000..1532e20085 --- /dev/null +++ b/snapshots/session/office-skills-no-renderer/session.v3.jsonl @@ -0,0 +1,27 @@ +{"type":"session","version":3,"id":"{{session:1}}","createdAt":1789532000606,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0} +{"type":"permission/preset","data":{"preset":"danger-full-access"}} +{"type":"sandbox/mode","data":{"mode":"danger-full-access"}} +{"type":"approval/policy","data":{"policy":"never"}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"请先加载 office-docx、office-pptx 和 office-xlsx 三个 skill,仅根据 skill 指令及当前工具清单确认工作流:在已配置好打包 Python 和 Office 库的 Desktop 环境中,缺少渲染工具是否阻止交付这三种文件?PPTX 新建和编辑分别用什么库?交付前需要哪些结构和内容检查?能否声称做了视觉检查或 Excel 公式重算?只回答工作流,不检查宿主环境,不运行命令,不创建文件,不安装依赖。"}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"}]}} +{"type":"turn/start","data":{"turn":1}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","data":{"turn":1,"step":1}} +{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{message:2}}"}},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"请先加载 office-docx、office-pptx 和 office-xlsx 三个 skill,仅根据 skill 指令及当前工具清单确认工作流:在已配置好打包 Python 和 Office 库的 Desktop 环境中,缺少渲染工具是否阻止交付这三种文件?PPTX 新建和编辑分别用什么库?交付前需要哪些结构和内容检查?能否声称做了视觉检查或 Excel 公式重算?只回答工作流,不检查宿主环境,不运行命令,不创建文件,不安装依赖。"}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"{{message:3}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"\nA skill is a reusable set of task-specific instructions. The following skills are available in this session:\n\n\n- `office-docx`: Create, read, edit, and check Word documents (.docx), including reports, letters, and formatted tables. Use when a DOCX file is an input or requested deliverable.\n- `office-pptx`: Create, read, edit, and check PowerPoint presentations (.pptx), including slide text, tables, images, and charts. Use when a PPTX file is an input or requested deliverable.\n- `office-xlsx`: Read, create, and modify Excel workbooks (.xlsx), including cell values, formulas, formatting, and pandas analysis. Use when an Excel workbook is an input or deliverable.\n\n\nIf the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.\nA user may also invoke a skill directly; its block then appears in this conversation. Follow it, and do not call the `skill` tool again for that skill.\n"}],"source":{"kind":"skill-catalog","form":"catalog","entries":[{"name":"office-docx","description":"Create, read, edit, and check Word documents (.docx), including reports, letters, and formatted tables. Use when a DOCX file is an input or requested deliverable."},{"name":"office-pptx","description":"Create, read, edit, and check PowerPoint presentations (.pptx), including slide text, tables, images, and charts. Use when a PPTX file is an input or requested deliverable."},{"name":"office-xlsx","description":"Read, create, and modify Excel workbooks (.xlsx), including cell values, formulas, formatting, and pandas analysis. Use when an Excel workbook is an input or deliverable."}]},"role":"user","id":"{{message:4}}"},"surfaceOp":"append"} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":8192,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":1000000}} +{"type":"session/title","data":{"title":"请先加载 office-docx、office-pptx","messageSeqs":[8],"source":{"kind":"fallback"}}} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user asks me to load three skills and answer workflow questions only. Let me load them."},{"type":"tool-call","id":"call_00_2jAvhW74YFzV34o6WcLH6654","name":"skill","arguments":"{\"name\": \"office-docx\"}"},{"type":"tool-call","id":"call_01_I6imo0g6ZL4X4MdJjbZ71052","name":"skill","arguments":"{\"name\": \"office-pptx\"}"},{"type":"tool-call","id":"call_02_uzWu2TTyXsHOQJ6OzTiz1019","name":"skill","arguments":"{\"name\": \"office-xlsx\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:5}}"},"usage":{"inputTokens":7253,"outputTokens":118,"totalTokens":7371,"cacheReadTokens":0,"reasoningTokens":19},"stream":[{"type":"chunk","time":1789532001166,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1789532001166,"index":0,"dt":[86,13,11,1,0,0,0,0,12,47,0,0,0,1,0,0,0,0],"texts":["The"," user"," asks"," me"," to"," load"," three"," skills"," and"," answer"," workflow"," questions"," only","."," Let"," me"," load"," them","."]},{"type":"chunk","time":1789532001351,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1789532001351,"index":1,"dt":[8,0,0,0,0,8,0,0,0,0,7,0],"id":"call_00_2jAvhW74YFzV34o6WcLH6654","name":"skill","args":["","{","\"","name","\"",": ","\"","office","-d","oc","x","\"","}"]},{"type":"chunk","time":1789532001400,"chunk":{"type":"block-start","index":2,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1789532001401,"index":2,"dt":[13,1,0,0,0,13,1,0,0,0,14,0],"id":"call_01_I6imo0g6ZL4X4MdJjbZ71052","name":"skill","args":["","{","\"","name","\"",": ","\"","office","-","pp","tx","\"","}"]},{"type":"chunk","time":1789532001473,"chunk":{"type":"block-start","index":3,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1789532001473,"index":3,"dt":[17,0,0,0,0,13,0,0,0,0,14],"id":"call_02_uzWu2TTyXsHOQJ6OzTiz1019","name":"skill","args":["","{","\"","name","\"",": ","\"","office","-x","lsx","\"","}"]},{"type":"chunk","time":1789532001535,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user asks me to load three skills and answer workflow questions only. Let me load them."}}},{"type":"chunk","time":1789532001535,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_2jAvhW74YFzV34o6WcLH6654","name":"skill","arguments":"{\"name\": \"office-docx\"}"}}},{"type":"chunk","time":1789532001535,"chunk":{"type":"block-end","index":2,"block":{"type":"tool-call","id":"call_01_I6imo0g6ZL4X4MdJjbZ71052","name":"skill","arguments":"{\"name\": \"office-pptx\"}"}}},{"type":"chunk","time":1789532001535,"chunk":{"type":"block-end","index":3,"block":{"type":"tool-call","id":"call_02_uzWu2TTyXsHOQJ6OzTiz1019","name":"skill","arguments":"{\"name\": \"office-xlsx\"}"}}},{"type":"chunk","time":1789532001535,"chunk":{"type":"usage","usage":{"inputTokens":7253,"outputTokens":118,"totalTokens":7371,"cacheReadTokens":0,"reasoningTokens":19}}},{"type":"chunk","time":1789532001535,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_00_2jAvhW74YFzV34o6WcLH6654","name":"skill","arguments":"{\"name\": \"office-docx\"}"}} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_2jAvhW74YFzV34o6WcLH6654"},"content":[{"type":"tool-result","toolCallId":"call_00_2jAvhW74YFzV34o6WcLH6654","content":[{"type":"text","text":"\n\nBase directory for this skill: {{cwd}}/office-skills/office-docx\nResolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.\n\n\n\n# Word documents\n\nUse `python-docx` for DOCX creation and ordinary edits. Follow an explicit user or applicable AGENTS.md requirement for a project environment or another library. Otherwise call `load_workspace_dependencies` and execute the returned Python path with its bundled libraries. Do not install packages or discover a system Python for the default workflow. If the tool is unavailable, use an already configured environment and report a missing dependency only when it prevents the requested operation.\n\nKeep source scripts, intermediate files, and final documents in the task workspace; the runtime and this skill directory are read-only resources. Use the user's requested language and preserve an existing document's design unless a redesign is requested.\n\n## Create and edit\n\nFor existing files, inspect paragraphs, runs, tables, sections, headers, and footers before changing the affected content. Save to a new file unless the user requests an in-place edit. Replacing a paragraph's `.text` destroys its run formatting; change the relevant runs when formatting must survive. Reconstructing the whole document can lose features outside python-docx's supported editing API.\n\nUse paragraph styles for headings and body text. Size tables for the section that contains them, and account for merged cells and nested tables. Chinese, Japanese, and Korean text may need an explicit `w:eastAsia` font assignment in addition to `run.font.name`; font names alone do not establish glyph availability or rendered appearance.\n\nFor a new document, use the selected Python executable:\n\n```python\nfrom docx import Document\n\ndocument = Document()\ndocument.add_heading(\"Project report\", level=0)\ndocument.add_paragraph(\"Summary\", style=\"Heading 1\")\ndocument.add_paragraph(\"The requested findings go here.\")\ndocument.save(\"report.docx\")\n```\n\npython-docx does not paginate or render documents. Do not represent ordinary replacement, colored text, or comments as tracked changes. When real revisions or unsupported OOXML features matter, preserve their package parts and verify the requested operation rather than silently discarding them.\n\n## Check and deliver\n\nRun the shared checker with the selected Python executable; `` is this loaded skill's resource base:\n\n```text\n /../scripts/check_office.py --out \n```\n\nIt checks ZIP/XML integrity and internal relationships, and reports paragraphs, logical table dimensions, and sections. Optional `--contains TEXT` arguments assert required text. A successful structural check does not verify pagination, clipping, fonts, or visual appearance. Compare the summary and reopened document with the user's request, including unchanged content that matters to an edit.\n\nIf `render_document` is available and visual inspection is useful, call it on the final DOCX without `pages` to prepare page 1 and learn `pageCount`. It checks the current main model's actual image capability; do not choose a second model. On `status: \"skipped\"`, complete structural and content checks and deliver the document, briefly stating that visual layout was not inspected. On a ready result, call `read_image` on `pages[].imagePath` and request remaining pages in small batches. Check page breaks, clipped text, headings, table widths, and consistency with the requested format or source design. Fix the source and render affected pages again.\n\nUse the available rendering tool for this check. Review `warnings` such as missing fonts. LibreOffice pagination can differ from Microsoft Word. If rendering is unavailable or fails, preserve the usable document and report the inspection limit; do not require the user to install a renderer.\n\nCall `present({\"files\":[{\"path\":\"report.docx\"}]})` with the actual final DOCX path. It exposes the current source file without copying or preserving its bytes, so keep that file in place and do not present temporary QA reports unless requested. If `present` is unavailable, provide the final workspace path using the session's supported file delivery method.\n\n"}],"isError":false}],"role":"user","id":"{{message:6}}"}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_01_I6imo0g6ZL4X4MdJjbZ71052","name":"skill","arguments":"{\"name\": \"office-pptx\"}"}} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_01_I6imo0g6ZL4X4MdJjbZ71052"},"content":[{"type":"tool-result","toolCallId":"call_01_I6imo0g6ZL4X4MdJjbZ71052","content":[{"type":"text","text":"\n\nBase directory for this skill: {{cwd}}/office-skills/office-pptx\nResolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.\n\n\n\n# PowerPoint presentations\n\nFollow an explicit user or applicable AGENTS.md requirement for an environment or library. Otherwise call `load_workspace_dependencies` and use its Python executable and bundled presentation libraries. Do not install npm or pip packages or locate a system interpreter for the default workflow. If the tool is unavailable, use an already configured environment and report a missing dependency only when it prevents the requested operation.\n\nKeep scripts and output files in the task workspace. The runtime and skill directory contain shared read-only resources. Match the requested slide language and the supplied presentation's design when editing it.\n\n## Create and edit\n\nUse `python-pptx` to inspect or modify an existing presentation. Inspect slide layouts, text runs, images, tables, and charts before editing. Change only the requested content, preserve mixed text formatting, and save to a new file unless the user requests an in-place edit. Rebuilding slides can discard unsupported animation, SmartArt, or other extension content.\n\nFor a new deck, use `python-pptx` with editable text, tables, and charts. Use local image assets rather than network-dependent image URLs. Set slide dimensions, text sizes, and chart data explicitly.\n\nA minimal editable deck, run with the selected Python executable:\n\n```python\nfrom pptx import Presentation\nfrom pptx.chart.data import CategoryChartData\nfrom pptx.enum.chart import XL_CHART_TYPE\nfrom pptx.util import Inches, Pt\n\npresentation = Presentation()\npresentation.slide_width = Inches(13.333)\npresentation.slide_height = Inches(7.5)\nslide = presentation.slides.add_slide(presentation.slide_layouts[6])\ntitle = slide.shapes.add_textbox(Inches(0.6), Inches(0.4), Inches(12), Inches(0.8))\nrun = title.text_frame.paragraphs[0].add_run()\nrun.text = \"Quarterly report\"\nrun.font.size = Pt(30)\ndata = CategoryChartData()\ndata.categories = [\"Q1\", \"Q2\"]\ndata.add_series(\"Revenue\", [12, 18])\nslide.shapes.add_chart(XL_CHART_TYPE.COLUMN_CLUSTERED,\n Inches(0.8), Inches(1.6), Inches(11.5), Inches(4.8), data)\npresentation.save(\"report.pptx\")\n```\n\nUse `Inches` or `Cm` for positions and sizes and `Pt` for font sizes. To edit a generated title while retaining its other slides and charts:\n\n```python\nfrom pptx import Presentation\n\npresentation = Presentation(\"report.pptx\")\nfor shape in presentation.slides[0].shapes:\n if shape.has_text_frame and shape.text == \"Quarterly report\":\n shape.text_frame.paragraphs[0].runs[0].text = \"Quarterly results\"\npresentation.save(\"report-edited.pptx\")\n```\n\nCheck that text and images fit the slide dimensions, titles form a useful sequence, and chart labels agree with source values. A native chart's embedded workbook is part of the deliverable and must contain the intended data. Library support for writing PPTX is not a rendering engine or a guarantee that every PowerPoint feature survives editing.\n\n## Check and deliver\n\nRun the shared checker with the selected Python executable; `` is this loaded skill's resource base:\n\n```text\n /../scripts/check_office.py --out \n```\n\nIt checks ZIP/XML integrity and internal relationships and reports slide count and extracted text. Use repeated `--contains TEXT` arguments for required slide text and `--count N` for a requested slide count; `--contains` excludes chart text and speaker notes. Reopen the file to check the requested edits, chart data, and notes. Structural success does not establish text fit, alignment, readable contrast, or rendering fidelity.\n\nIf `render_document` is available and visual inspection is useful, call it on the final PPTX, initially omitting `pages` to prepare page 1 and obtain `pageCount`. The tool checks the current main model's actual image capability; do not choose a second model. If `status` is `skipped`, complete structural and content checks and deliver the PPTX, briefly stating that visual layout was not inspected. If ready, call `read_image` on its returned `pages[].imagePath`, then inspect remaining slides in small batches such as `pages: [2, 3, 4]`. Check clipping, alignment, contrast, chart labels, and consistency with the requested design or supplied reference; fix the source and render the affected pages again.\n\nUse the available rendering tool for this check. Inspect `warnings`, especially missing fonts. LibreOffice previews do not certify pixel-identical PowerPoint or Keynote output, animation, or media playback. If the rendering tool is unavailable or fails, retain the usable source and report the inspection limit; do not require the user to install another tool.\n\nCall `present({\"files\":[{\"path\":\"report-edited.pptx\"}]})` with the actual final PPTX path. It exposes the current source file without copying or preserving its bytes, so keep that file in place and leave intermediate images and reports out of delivery unless requested. If `present` is unavailable, use the session's supported file delivery method.\n\n"}],"isError":false}],"role":"user","id":"{{message:7}}"}},"sourceEventSeqs":[17],"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_02_uzWu2TTyXsHOQJ6OzTiz1019","name":"skill","arguments":"{\"name\": \"office-xlsx\"}"}} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_02_uzWu2TTyXsHOQJ6OzTiz1019"},"content":[{"type":"tool-result","toolCallId":"call_02_uzWu2TTyXsHOQJ6OzTiz1019","content":[{"type":"text","text":"\n\nBase directory for this skill: {{cwd}}/office-skills/office-xlsx\nResolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.\n\n\n\n# Excel workbooks\n\nFollow an explicit user or applicable AGENTS.md requirement for an environment or library. Otherwise call `load_workspace_dependencies` and run its Python executable with bundled `openpyxl` and `pandas`. Do not install packages or search for a system Python for the default workflow. If the tool is unavailable, use an already configured environment and report a missing dependency only when it prevents the requested operation.\n\nKeep scripts, working files, and outputs in the task workspace. The runtime and skill directory are shared read-only resources. Save to a new workbook unless the user requests an in-place edit.\n\n## Read and modify\n\nUse `openpyxl` for existing XLSX workbooks and targeted cell or style changes. Load with `data_only=False` when formulas must survive. Inspect sheet names, the affected cell types, formulas, styles, merged ranges, and referenced ranges before editing. Check the saved file by reopening it, including unchanged content that the request requires preserving.\n\nUse pandas for data analysis and transformations. A DataFrame is not the workbook: exporting it over an existing file can lose sheets, formulas, charts, and formatting. Write analysis results back to the intended ranges with openpyxl. `XlsxWriter` can create new workbooks, but cannot read or modify existing ones.\n\nFor a new workbook, the selected Python environment can write editable values, styles, and formulas directly:\n\n```python\nfrom openpyxl import Workbook\nfrom openpyxl.styles import Font\n\nworkbook = Workbook()\nsheet = workbook.active\nsheet.title = \"Revenue\"\nfor row in [(\"Quarter\", \"Revenue\"), (\"Q1\", 12), (\"Q2\", 18), (\"Total\", \"=SUM(B2:B3)\")]:\n sheet.append(row)\nfor cell in sheet[1]:\n cell.font = Font(bold=True)\nsheet.column_dimensions[\"A\"].width = 18\nsheet.column_dimensions[\"B\"].width = 18\nworkbook.save(\"report.xlsx\")\n```\n\nPreserve numbers, dates, booleans, and identifiers as the intended cell types; formatting is not a type conversion. For modifications, load the existing file with `openpyxl.load_workbook(\"input.xlsx\", data_only=False)`, change the requested ranges, and save a separate result. Avoid a DataFrame round trip when workbook features must survive.\n\nWriting a formula does not calculate its result. openpyxl and XlsxWriter do not evaluate Excel formulas; `data_only=True` returns stored cached values that may be absent or stale. Verify formulas and inputs separately, and state when current results require recalculation in a spreadsheet application. Do not replace requested formulas with constants or report cached values as newly calculated results.\n\nDo not rename `.xls`, `.xlsb`, encrypted files, or macro-enabled files to `.xlsx`. They need an appropriate supported operation. `keep_vba=True` can preserve VBA package content in an XLSM workflow, but does not execute or edit macros and does not guarantee every advanced workbook feature survives. Preserve the original and verify such requirements explicitly.\n\n## Check and deliver\n\nRun the shared checker with the selected Python executable; `` is this loaded skill's resource base:\n\n```text\n /../scripts/check_office.py --out \n```\n\nIt checks ZIP/XML integrity and internal relationships, and reports sheet names, populated-cell counts, and formula counts. Repeated `--contains TEXT` arguments check string cells and sheet names, and `--count N` checks sheet count. `--contains` excludes numeric cells and does not validate formula results; verify those separately with openpyxl. The checker does not calculate formulas or judge workbook appearance. Compare relevant values, types, formulas, styles, and totals with the task's source data.\n\nIf `render_document` is available and visual inspection is useful, call it on the final workbook without `pages` to prepare page 1 and learn `pageCount`. It checks the current main model's actual image capability; do not choose a second model. On `status: \"skipped\"`, complete structural and data checks and deliver the workbook, briefly stating that visual layout was not inspected. Otherwise call `read_image` on `pages[].imagePath` and request remaining pages in small batches. Check column widths, number formats, clipping, charts, and print areas; fix the workbook and review the affected pages again.\n\nRendered pages follow spreadsheet print settings, so a page is not necessarily a worksheet. Set appropriate print areas and scaling when a readable printed layout is requested. Review `warnings` such as missing fonts. LibreOffice preview conversion neither updates the original workbook's cached formulas nor certifies native Excel calculation or appearance. If rendering is unavailable or fails, preserve the usable workbook and report the inspection limit; ordinary cell edits do not require installing an external renderer.\n\nCall `present({\"files\":[{\"path\":\"report.xlsx\"}]})` with the actual final workbook path. It exposes the current source file without copying or preserving its bytes, so keep that file in place and omit intermediate scripts and reports unless requested. If `present` is unavailable, use the session's supported file delivery method.\n\n"}],"isError":false}],"role":"user","id":"{{message:8}}"}},"sourceEventSeqs":[19],"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":1}} +{"type":"step/start","data":{"turn":1,"step":2}} +{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants workflow-only answer. Note: skill instructions mention `load_workspace_dependencies` and `render_document` and `present` — but current tool list doesn't include those. The user says \"仅根据 skill 指令及当前工具清单确认工作流\" — based on skill instructions and current tool list. Current tools: bash, create_goal, edit, exit_plan_mode, get_goal, glob, grep, interrupt_agent, job_kill, job_list, job_output, list_agents, read, read_image, send_message, skill, subagent, subagent_fork, todo_write, update_goal, web_fetch, web_search, workflow, write.\n\nSo `load_workspace_dependencies`, `render_document`, `present` are NOT available. The user premise: Desktop environment already configured with packaging Python and Office libs. So missing renderer does not block delivery — skills say if rendering unavailable, deliver and report the inspection limit. Explicitly \"do not require the user to install a renderer\".\n\nPPTX create and edit: both python-pptx (create new deck python-pptx; edit existing python-pptx).\n\nChecks before delivery: run shared checker `check_office.py` via Python executable: ZIP/XML integrity, relationships, counts; --contains, --count; reopen file. Does the checker script exist? User said don't check host env, don't run commands. So just workflow.\n\nCannot claim visual inspection: only if render_document available and read_image done; here render tool unavailable → must state visual layout was not inspected. Cannot claim Excel formula recalculation: openpyxl doesn't evaluate; checker doesn't calculate; LibreOffice preview doesn't update cached formulas; must state results require recalculation in a spreadsheet app. Must not report cached values as newly calculated.\n\nAlso note: since load_workspace_dependencies is unavailable, use the already-configured Desktop environment's Python (per skill fallback: \"If the tool is unavailable, use an already configured environment\").\n\nAnswer in Chinese, brief, workflow only. No commands run.\n\nStructure:\n1. 工作流确认前提\n2. 缺渲染工具是否阻止交付 - no\n3. PPTX 库\n4. 交付前检查\n5. 能声称什么/不能\n\nKeep concise."},{"type":"text","text":"已加载三个 skill。仅依据 skill 指令与当前会话工具清单(无 `load_workspace_dependencies`、无 `render_document`、无 `present`,仅有 bash / read / read_image / write / edit / glob / grep / subagent 等)确认工作流如下。\n\n## 1. 缺渲染工具是否阻止交付\n不阻止。三个 skill 一致规定:渲染/视觉检查是可选增强项;渲染工具不可用或失败时,应保留可用的成品、完成结构与内容检查,并简要说明\"未做视觉布局检查\",且不得要求用户去安装渲染器。\n- 因此 DOCX / PPTX / XLSX 均可正常交付。\n- 默认交付方式:`present` 不可用,改用当前会话支持的文件交付方式(提供工作区内最终文件的真实路径)。\n- 因 `load_workspace_dependencies` 不可用,按 skill 的兜底规则,使用\"已配置好的\"Desktop Python 环境,不安装依赖、不搜寻系统 Python。\n\n## 2. PPTX 新建与编辑分别用什么库\n两者都是 `python-pptx`:\n- 新建:`Presentation()`,显式设置 `slide_width/slide_height`、字号(`Pt`)、位置尺寸(`Inches`/`Cm`);表格、图表用可编辑的原生对象,图片用本地资源而非网络 URL;图表数据写进内嵌 workbook。\n- 编辑:打开已有文件后先检查 slide layouts、text runs、图片、表格、图表,只改请求的内容,保留混合格式,另存为新文件(除非用户要求原地修改);不整片重建,以免丢失动画/SmartArt 等扩展内容。\n\n## 3. 交付前的结构与内容检查\n统一先跑共享检查器:\n` /../scripts/check_office.py --out `\n- 三者共通:ZIP/XML 完整性、内部关系。\n- DOCX:`--contains TEXT`;报告段落、逻辑表格尺寸、节;再重开文档核对请求内容及需保留的未改动内容。\n- PPTX:`--contains TEXT`(不含图表文本与备注)、`--count N` 核对页数;报告页数与提取文本;重开核对改动、图表数据、备注;抽查文字/图片是否超出页面尺寸、标题顺序、图表标签与源数据一致。\n- XLSX:`--contains TEXT`(仅字符串单元格与表名,不含数值)、`--count N`;报告表名、非空单元格数、公式数;另用 openpyxl 单独核对数值、类型、公式、样式与合计;含公式时用 `data_only=False` 保存。\n- 结论:结构检查通过 ≠ 排版/视觉通过,不能互相替代。\n\n## 4. 能否声称做了视觉检查\n不能。本会话没有 `render_document`,无法产出 `pages[].imagePath`,也就无法用 `read_image` 看图。交付时只能做结构+内容检查,并明确说明\"视觉布局未检查\"(未核对分页/裁剪、对齐、对比度、字体缺失等)。不得声称进行过视觉/渲染核对。\n\n## 5. 能否声称做了 Excel 公式重算\n不能。openpyxl / XlsxWriter 不求值公式;`data_only=True` 只返回可能缺失或过期的缓存值;检查器不计算公式;LibreOffice 预览既不更新原文件缓存公式,也不证明 Excel 原生计算结果。工作流要求:公式与输入分开验证,如实说明\"当前结果需在电子表格应用中重算\",不得把缓存值当作新算出的结果上报,也不得把请求的公式替换为常量。\n\n补充:`workflow`/`subagent` 属通用编排工具,三个 skill 均未要求在交付流程中调用;不创建文件、不运行命令,本轮仅确认工作流。"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:9}}"},"usage":{"inputTokens":3422,"outputTokens":1330,"totalTokens":12048,"cacheReadTokens":7296,"reasoningTokens":483},"stream":[{"type":"chunk","time":1789532001863,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1789532001863,"index":0,"dt":[49,20,1,0,2,0,1,14,0,11,14,0,0,0,22,0,0,0,0,1,6,0,0,0,0,0,13,0,1,0,0,0,12,0,0,0,0,0,13,0,13,0,0,13,14,0,13,0,1,0,0,0,14,13,0,0,0,0,14,0,0,0,0,0,13,0,0,0,0,14,13,0,15,0,0,0,14,0,0,0,0,0,13,13,0,0,0,0,0,15,0,0,0,0,0,12,0,0,0,0,0,13,0,0,0,14,0,0,0,0,0,13,0,0,13,1,0,0,0,0,28,0,0,0,0,0,0,1,0,0,0,0,11,0,0,0,0,0,14,0,0,0,0,13,0,1,13,0,13,0,0,1,0,0,51,1,0,0,0,0,0,0,0,0,0,1,0,0,1,0,0,32,0,0,0,0,0,0,9,1,0,0,0,12,0,0,14,0,13,0,14,0,13,0,17,0,0,11,14,0,14,0,0,0,0,13,14,0,0,0,0,14,0,0,0,0,13,0,0,0,13,16,0,0,0,0,0,12,0,0,15,15,1,0,16,1,0,0,0,0,19,0,0,0,0,0,7,0,0,0,14,0,12,1,13,1,0,0,0,12,14,13,0,33,1,0,0,0,13,0,16,1,0,14,12,1,0,0,16,20,1,0,0,7,0,0,14,13,15,0,0,13,13,14,0,13,1,0,0,0,17,1,8,1,14,0,0,12,1,13,0,0,26,0,0,0,0,1,14,14,14,13,0,0,0,14,0,0,14,0,0,0,0,13,0,0,13,0,1,0,13,0,0,13,1,13,0,0,14,0,0,0,19,0,9,0,0,13,0,13,0,0,0,15,12,14,0,0,13,1,0,0,0,13,13,1,0,0,13,0,0,0,0,0,32,0,0,0,0,0,0,0,14,1,0,0,6,1,0,0,0,14,0,0,0,15,11,0,0,0,14,0,0,0,13,0,1,0,0,0,26,0,13,13,1,0,17,0,9,1,0,0,12,14,0,14,0,13,0,0,0,13,42,0,0,0,0,0,0,0,0,0,12,0,14,1,0,0,0,13,0,0,14,1,0,0,11,0,0,13,1,0,0,0,12,1,13,13,14,0,0,14],"texts":["The"," user"," wants"," workflow","-only"," answer","."," Note",":"," skill"," instructions"," mention"," `","load","_","works","pace","_d","ependencies","`"," and"," `","render","_d","ocument","`"," and"," `","present","`"," —"," but"," current"," tool"," list"," doesn","'t"," include"," those","."," The"," user"," says"," \"","仅","根据"," skill"," ","指令","及","当前","工具","清单","确认","工作","流","\""," —"," based"," on"," skill"," instructions"," and"," current"," tool"," list","."," Current"," tools",":"," bash",","," create","_","goal",","," edit",","," exit","_","plan","_mode",","," get","_","goal",","," glob",","," grep",","," interrupt","_","agent",","," job","_k","ill",","," job","_list",","," job","_output",","," list","_","agents",","," read",","," read","_image",","," send","_message",","," skill",","," sub","agent",","," sub","agent","_f","ork",","," todo","_write",","," update","_","goal",","," web","_f","etch",","," web","_search",","," workflow",","," write",".\n\n","So"," `","load","_","works","pace","_d","ependencies","`,"," `","render","_d","ocument","`,"," `","present","`"," are"," NOT"," available","."," The"," user"," premise",":"," Desktop"," environment"," already"," configured"," with"," packaging"," Python"," and"," Office"," lib","s","."," So"," missing"," render","er"," does"," not"," block"," delivery"," —"," skills"," say"," if"," rendering"," unavailable",","," deliver"," and"," report"," the"," inspection"," limit","."," Explicit","ly"," \"","do"," not"," require"," the"," user"," to"," install"," a"," render","er","\".\n\n","PPT","X"," create"," and"," edit",":"," both"," python","-","pp","tx"," (","create"," new"," deck"," python","-","pp","tx",";"," edit"," existing"," python","-","pp","tx",").\n\n","Che","cks"," before"," delivery",":"," run"," shared"," checker"," `","check","_","office",".py","`"," via"," Python"," executable",":"," ZIP","/","XML"," integrity",","," relationships",","," counts",";"," --","contains",","," --","count",";"," reopen"," file","."," Does"," the"," checker"," script"," exist","?"," User"," said"," don","'t"," check"," host"," env",","," don","'t"," run"," commands","."," So"," just"," workflow",".\n\n","Cannot"," claim"," visual"," inspection",":"," only"," if"," render","_d","ocument"," available"," and"," read","_image"," done",";"," here"," render"," tool"," unavailable"," →"," must"," state"," visual"," layout"," was"," not"," inspected","."," Cannot"," claim"," Excel"," formula"," recal","culation",":"," open","py","xl"," doesn","'t"," evaluate",";"," checker"," doesn","'t"," calculate",";"," Libre","Office"," preview"," doesn","'t"," update"," cached"," formulas",";"," must"," state"," results"," require"," recal","culation"," in"," a"," spreadsheet"," app","."," Must"," not"," report"," cached"," values"," as"," newly"," calculated",".\n\n","Also"," note",":"," since"," load","_","works","pace","_d","ependencies"," is"," unavailable",","," use"," the"," already","-con","figured"," Desktop"," environment","'s"," Python"," (","per"," skill"," fall","back",":"," \"","If"," the"," tool"," is"," unavailable",","," use"," an"," already"," configured"," environment","\").\n\n","Answer"," in"," Chinese",","," brief",","," workflow"," only","."," No"," commands"," run",".\n\n","Structure",":\n","1","."," ","工作","流","确认","前提","\n","2","."," ","缺","渲染","工具","是否","阻止","交付"," -"," no","\n","3","."," PPT","X"," ","库","\n","4","."," ","交付","前","检查","\n","5","."," ","能","声称","什么","/","不能","\n\n","Keep"," concise","."]},{"type":"chunk","time":1789532003971,"chunk":{"type":"block-start","index":1,"blockType":"text"}},{"type":"text-chunks","time0":1789532003971,"index":1,"dt":[13,0,14,0,0,14,0,13,1,0,0,0,0,12,0,0,14,1,0,0,0,0,12,0,0,0,14,0,0,0,0,0,12,0,1,0,0,0,12,1,15,0,0,12,0,0,0,0,13,0,0,0,14,0,0,14,0,0,0,0,12,1,13,0,0,0,0,0,0,13,0,1,0,0,13,0,13,0,0,13,0,0,14,1,0,12,0,15,1,12,0,0,0,13,0,13,0,0,0,14,0,0,0,13,13,0,14,0,0,13,1,0,0,0,13,0,13,0,0,0,14,1,0,12,1,0,13,14,0,0,14,0,0,13,0,14,0,19,0,0,0,0,0,9,0,0,16,14,0,0,0,0,13,0,14,0,0,0,14,0,0,0,20,6,0,0,0,14,0,0,14,14,14,0,13,13,0,0,0,0,14,13,0,14,0,0,0,0,0,14,0,0,0,0,0,13,1,0,0,0,0,13,13,0,0,0,0,14,0,0,0,13,0,15,14,0,0,15,0,0,0,0,0,11,0,0,0,0,13,0,0,0,0,14,0,0,0,16,0,0,0,0,11,0,0,0,0,0,13,0,14,0,0,0,13,13,1,0,13,0,0,0,0,14,14,0,0,0,0,14,0,0,14,0,0,0,0,0,14,0,0,13,14,0,0,13,1,14,1,0,0,0,11,0,17,11,0,0,0,13,0,14,0,14,0,27,0,0,0,0,0,0,13,1,0,14,0,12,0,15,15,0,0,13,0,0,0,13,0,13,0,13,15,0,0,12,14,0,0,14,14,0,0,0,13,0,0,0,14,14,0,0,14,0,13,0,14,13,0,0,0,0,14,0,0,0,0,0,15,0,12,13,14,0,0,0,14,14,0,0,0,27,0,14,1,0,0,0,13,0,0,0,0,0,12,1,0,0,0,0,13,0,0,28,0,0,0,0,0,0,17,10,13,0,1,0,0,15,1,0,0,10,14,0,0,15,0,0,0,15,0,43,0,0,0,0,0,0,0,0,0,13,0,0,11,0,13,0,14,0,13,15,0,0,12,1,0,0,0,0,14,0,0,0,14,0,0,0,13,0,0,15,0,0,0,12,1,0,0,0,12,0,0,0,0,13,0,15,0,0,0,0,12,14,0,0,14,0,0,14,1,13,0,13,0,15,12,1,0,14,0,13,0,14,0,0,0,0,0,13,0,0,0,0,14,0,0,0,0,0,13,0,0,0,14,0,0,0,13,0,0,0,0,0,14,0,15,13,0,0,14,0,0,0,0,14,0,0,12,0,0,0,0,0,14,1,14,0,0,12,1,0,0,0,19,0,7,0,14,0,0,0,1,0,12,1,13,0,0,0,13,0,14,0,0,0,13,1,13,0,14,13,14,14,0,0,0,0,13,0,0,14,0,14,0,27,0,0,0,1,12,14,0,0,0,0,1,12,15,13,0,14,0,0,13,0,0,0,13,1,0,0,0,0,19,0,8,0,34,0,0,7,0,13,0,0,0,0,15,0,0,12,1,0,0,0,13,0,13,1,0,17,10,0,15,0,0,0,0,12,14,0,0,13,1,13,0,13,14,0,14,0,0,0,0,0,13,0,0,14,0,0,0,0,17,0,0,0,0,0,0,10,0,0,0,0,14,0,0,13,1,0,0,0,0,13,13,0,13,1,0,13,14,1,0,0,0,13,0,0,23,1,0,0,0,4,0,14,1,11,14,0,13,0,0,13,23,0,0,6,0,12,0,28,0,0,0,0,0,0,13,13,1,0,49,0,0,0,0,0,5,0,0,0,0,14,0,0,14,0,14,0,0,13,0,0,0,14,0,14,0,0,0,0,0,13,0,0,14,18,0,0,9,0,0,0,0,13,16,0,12,0,14,0,0,19,0,8,14,0,0,13,13,0,14,13,1,0,13,0,0,15,0,12,0,0],"texts":["已","加载","三个"," skill","。","仅","依据"," skill"," ","指令","与","当前","会话","工具","清单","(","无"," `","load","_","works","pace","_d","ependencies","`","、","无"," `","render","_d","ocument","`","、","无"," `","present","`",",","仅有"," bash"," /"," read"," /"," read","_image"," /"," write"," /"," edit"," /"," glob"," /"," grep"," /"," sub","agent"," ","等",")","确认","工作","流","如下","。\n\n","##"," ","1","."," ","缺","渲染","工具","是否","阻止","交付","\n","不","阻止","。","三个"," skill"," ","一致","规定",":","渲染","/","视觉","检查","是","可选","增强","项",";","渲染","工具","不可","用","或","失败","时",",","应","保留","可","用的","成品","、","完成","结构与","内容","检查",",","并","简要","说明","\"","未","做","视觉","布局","检查","\",","且","不得","要求","用户","去","安装","渲染","器","。\n","-"," ","因此"," DOC","X"," /"," PPT","X"," /"," X","LS","X"," ","均可","正常","交付","。\n","-"," ","默认","交付","方式",":","`","present","`"," ","不可","用",",","改用","当前","会话","支持","的文件","交付","方式","(","提供","工作","区内","最终","文件的","真实","路径",")。\n","-"," ","因"," `","load","_","works","pace","_d","ependencies","`"," ","不可","用",",","按"," skill"," ","的","兜","底","规则",",","使用","\"","已","配置","好的","\"","Desktop"," Python"," ","环境",",","不","安装","依赖","、","不","搜寻","系统"," Python","。\n\n","##"," ","2","."," PPT","X"," ","新建","与","编辑","分别","用什么","库","\n","两者","都是"," `","python","-","pp","tx","`",":\n","-"," ","新建",":","`","Presentation","()`",",","显","式","设置"," `","slide","_width","/s","lide","_height","`","、","字号","(","`","Pt","`",")、","位置","尺寸","(","`","In","ches","`","/","`","Cm","`",");","表格","、","图表","用","可","编辑","的原","生","对象",",","图片","用","本地","资源","而非","网络"," URL",";","图表","数据","写","进","内","嵌"," workbook","。\n","-"," ","编辑",":","打开","已有","文件","后","先","检查"," slide"," layouts","、","text"," runs","、","图片","、","表格","、","图表",",","只","改","请求","的内容",",","保留","混合","格式",",","另","存","为新","文件","(","除非","用户","要求","原地","修改",");","不","整","片","重建",",","以免","丢失","动画","/S","mart","Art"," ","等","扩展","内容","。\n\n","##"," ","3","."," ","交付","前的","结构与","内容","检查","\n","统一","先","跑","共享","检查","器",":\n","`","<","python",">"," <","skill","-direct","ory",">/","../","scripts","/","check","_","office",".py"," <","file",">"," --","out"," <","checks",".json",">","`\n","-"," ","三者","共通",":","Z","IP","/","XML"," ","完整性","、","内部","关系","。\n","-"," DOC","X",":","`","--","contains"," TEXT","`",";","报告","段落","、","逻辑","表格","尺寸","、","节",";","再","重","开","文档","核对","请求","内容","及","需","保留","的","未","改动","内容","。\n","-"," PPT","X",":","`","--","contains"," TEXT","`","(","不含","图表","文本","与","备注",")、","`","--","count"," N","`"," ","核对","页","数",";","报告","页","数与","提取","文本",";","重","开","核对","改动","、","图表","数据","、","备注",";","抽查","文字","/","图片","是否","超出","页面","尺寸","、","标题","顺序","、","图表","标签","与","源","数据","一致","。\n","-"," X","LS","X",":","`","--","contains"," TEXT","`","(","仅","字符串","单元格","与","表","名",",","不含","数值",")、","`","--","count"," N","`",";","报告","表","名","、","非","空","单元格","数","、","公式","数",";","另","用"," open","py","xl"," ","单独","核对","数值","、","类型","、","公式","、","样式","与","合计",";","含","公式","时","用"," `","data","_only","=False","`"," ","保存","。\n","-"," ","结论",":","结构","检查","通过"," ≠"," ","排版","/","视觉","通过",",","不能","互相","替代","。\n\n","##"," ","4","."," ","能否","声称","做了","视觉","检查","\n","不能","。","本","会话","没有"," `","render","_d","ocument","`",",","无法","产出"," `","pages","[","].","image","Path","`",",","也就","无法","用"," `","read","_image","`"," ","看图","。","交付","时","只能","做","结构","+","内容","检查",",","并","明确","说明","\"","视觉","布局","未","检查","\"","(","未","核对","分","页","/","裁剪","、","对齐","、","对比","度","、","字体","缺失","等",")。","不得","声称","进行","过","视觉","/","渲染","核对","。\n\n","##"," ","5","."," ","能否","声称","做了"," Excel"," ","公式","重","算","\n","不能","。","open","py","xl"," /"," X","lsx","Writer"," ","不求","值","公式",";","`","data","_only","=True","`"," ","只","返回","可能","缺失","或","过","期的","缓存","值",";","检查","器","不","计算","公式",";","Lib","re","Office"," ","预览","既不","更新","原","文件","缓存","公式",",","也不","证明"," Excel"," ","原生","计算结果","。","工作","流","要求",":","公式","与","输入","分开","验证",",","如实","说明","\"","当前","结果","需","在","电子","表格","应用中","重","算","\",","不得","把","缓存","值","当作","新","算","出的","结果","上报",",","也不","得","把","请求","的","公式","替换","为","常量","。\n\n","补充",":","`","work","flow","`","/","`","sub","agent","`"," ","属","通用","编排","工具",",","三个"," skill"," ","均","未","要求在","交付","流程","中","调用",";","不","创建","文件","、","不","运行","命令",",","本轮","仅","确认","工作","流","。"]},{"type":"chunk","time":1789532007814,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants workflow-only answer. Note: skill instructions mention `load_workspace_dependencies` and `render_document` and `present` — but current tool list doesn't include those. The user says \"仅根据 skill 指令及当前工具清单确认工作流\" — based on skill instructions and current tool list. Current tools: bash, create_goal, edit, exit_plan_mode, get_goal, glob, grep, interrupt_agent, job_kill, job_list, job_output, list_agents, read, read_image, send_message, skill, subagent, subagent_fork, todo_write, update_goal, web_fetch, web_search, workflow, write.\n\nSo `load_workspace_dependencies`, `render_document`, `present` are NOT available. The user premise: Desktop environment already configured with packaging Python and Office libs. So missing renderer does not block delivery — skills say if rendering unavailable, deliver and report the inspection limit. Explicitly \"do not require the user to install a renderer\".\n\nPPTX create and edit: both python-pptx (create new deck python-pptx; edit existing python-pptx).\n\nChecks before delivery: run shared checker `check_office.py` via Python executable: ZIP/XML integrity, relationships, counts; --contains, --count; reopen file. Does the checker script exist? User said don't check host env, don't run commands. So just workflow.\n\nCannot claim visual inspection: only if render_document available and read_image done; here render tool unavailable → must state visual layout was not inspected. Cannot claim Excel formula recalculation: openpyxl doesn't evaluate; checker doesn't calculate; LibreOffice preview doesn't update cached formulas; must state results require recalculation in a spreadsheet app. Must not report cached values as newly calculated.\n\nAlso note: since load_workspace_dependencies is unavailable, use the already-configured Desktop environment's Python (per skill fallback: \"If the tool is unavailable, use an already configured environment\").\n\nAnswer in Chinese, brief, workflow only. No commands run.\n\nStructure:\n1. 工作流确认前提\n2. 缺渲染工具是否阻止交付 - no\n3. PPTX 库\n4. 交付前检查\n5. 能声称什么/不能\n\nKeep concise."}}},{"type":"chunk","time":1789532007814,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"已加载三个 skill。仅依据 skill 指令与当前会话工具清单(无 `load_workspace_dependencies`、无 `render_document`、无 `present`,仅有 bash / read / read_image / write / edit / glob / grep / subagent 等)确认工作流如下。\n\n## 1. 缺渲染工具是否阻止交付\n不阻止。三个 skill 一致规定:渲染/视觉检查是可选增强项;渲染工具不可用或失败时,应保留可用的成品、完成结构与内容检查,并简要说明\"未做视觉布局检查\",且不得要求用户去安装渲染器。\n- 因此 DOCX / PPTX / XLSX 均可正常交付。\n- 默认交付方式:`present` 不可用,改用当前会话支持的文件交付方式(提供工作区内最终文件的真实路径)。\n- 因 `load_workspace_dependencies` 不可用,按 skill 的兜底规则,使用\"已配置好的\"Desktop Python 环境,不安装依赖、不搜寻系统 Python。\n\n## 2. PPTX 新建与编辑分别用什么库\n两者都是 `python-pptx`:\n- 新建:`Presentation()`,显式设置 `slide_width/slide_height`、字号(`Pt`)、位置尺寸(`Inches`/`Cm`);表格、图表用可编辑的原生对象,图片用本地资源而非网络 URL;图表数据写进内嵌 workbook。\n- 编辑:打开已有文件后先检查 slide layouts、text runs、图片、表格、图表,只改请求的内容,保留混合格式,另存为新文件(除非用户要求原地修改);不整片重建,以免丢失动画/SmartArt 等扩展内容。\n\n## 3. 交付前的结构与内容检查\n统一先跑共享检查器:\n` /../scripts/check_office.py --out `\n- 三者共通:ZIP/XML 完整性、内部关系。\n- DOCX:`--contains TEXT`;报告段落、逻辑表格尺寸、节;再重开文档核对请求内容及需保留的未改动内容。\n- PPTX:`--contains TEXT`(不含图表文本与备注)、`--count N` 核对页数;报告页数与提取文本;重开核对改动、图表数据、备注;抽查文字/图片是否超出页面尺寸、标题顺序、图表标签与源数据一致。\n- XLSX:`--contains TEXT`(仅字符串单元格与表名,不含数值)、`--count N`;报告表名、非空单元格数、公式数;另用 openpyxl 单独核对数值、类型、公式、样式与合计;含公式时用 `data_only=False` 保存。\n- 结论:结构检查通过 ≠ 排版/视觉通过,不能互相替代。\n\n## 4. 能否声称做了视觉检查\n不能。本会话没有 `render_document`,无法产出 `pages[].imagePath`,也就无法用 `read_image` 看图。交付时只能做结构+内容检查,并明确说明\"视觉布局未检查\"(未核对分页/裁剪、对齐、对比度、字体缺失等)。不得声称进行过视觉/渲染核对。\n\n## 5. 能否声称做了 Excel 公式重算\n不能。openpyxl / XlsxWriter 不求值公式;`data_only=True` 只返回可能缺失或过期的缓存值;检查器不计算公式;LibreOffice 预览既不更新原文件缓存公式,也不证明 Excel 原生计算结果。工作流要求:公式与输入分开验证,如实说明\"当前结果需在电子表格应用中重算\",不得把缓存值当作新算出的结果上报,也不得把请求的公式替换为常量。\n\n补充:`workflow`/`subagent` 属通用编排工具,三个 skill 均未要求在交付流程中调用;不创建文件、不运行命令,本轮仅确认工作流。"}}},{"type":"chunk","time":1789532007814,"chunk":{"type":"usage","usage":{"inputTokens":3422,"outputTokens":1330,"totalTokens":12048,"cacheReadTokens":7296,"reasoningTokens":483}}},{"type":"chunk","time":1789532007814,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":2}} +{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/snapshots/session/office-skills-no-renderer/snapshot.yml b/snapshots/session/office-skills-no-renderer/snapshot.yml new file mode 100644 index 0000000000..72a88392b0 --- /dev/null +++ b/snapshots/session/office-skills-no-renderer/snapshot.yml @@ -0,0 +1,12 @@ +version: 1 +scenario: office-skills-no-renderer +profile: headless +composition: office-skills-no-renderer +recording: live +header: + class: office-skills-no-renderer + pin: true + systemPromptSource: text-turn + toolSchemasSource: text-turn +workspace: + setup: office-skills diff --git a/snapshots/session/office-skills-no-renderer/system-prompt.expected.md b/snapshots/session/office-skills-no-renderer/system-prompt.expected.md new file mode 120000 index 0000000000..4ca0dd39b2 --- /dev/null +++ b/snapshots/session/office-skills-no-renderer/system-prompt.expected.md @@ -0,0 +1 @@ +../text-turn/system-prompt.expected.md \ No newline at end of file diff --git a/snapshots/session/office-skills-no-renderer/tool-schemas.expected.json b/snapshots/session/office-skills-no-renderer/tool-schemas.expected.json new file mode 120000 index 0000000000..11d7ccbf24 --- /dev/null +++ b/snapshots/session/office-skills-no-renderer/tool-schemas.expected.json @@ -0,0 +1 @@ +../text-turn/tool-schemas.expected.json \ No newline at end of file diff --git a/snapshots/session/office-skills/cordis.snapshot.yml b/snapshots/session/office-skills/cordis.snapshot.yml new file mode 100644 index 0000000000..fe31218bff --- /dev/null +++ b/snapshots/session/office-skills/cordis.snapshot.yml @@ -0,0 +1,51 @@ +# Replay patch shared by the ordinary headless snapshot composition. The model +# script comes from the scenario's committed session JSONL. + +- id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + disabled: true + +- id: plugin-package-inventory-deepseek + disabled: true + +- id: session-title-llm + disabled: true + +- id: session-persistence-jsonl + name: '@deepseek-ai/dsh-session-persistence-jsonl' + config: + root: !!js dshHomePath('sessions') + compression: none + +- id: sandbox + name: '@deepseek-ai/dsh-sandbox-local' + config: + runnerCommand: + - bash + - -c + - while [ "$1" != "--" ]; do shift; done; shift; exec "$@" + - passthrough-runner + runnerFailureSignatures: + - 'passthrough-runner: profile rejected' + +- insert: + - id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' + config: + providers: + - id: deepseek-official + name: DeepSeek + models: + - id: deepseek-v4-flash + - id: deepseek-v4-pro + - id: deepseek-messages + name: DeepSeek Messages + models: + - id: deepseek-v4-flash + - id: deepseek-v4-pro + +- insert: + - id: office-skills + name: '@deepseek-ai/dsh-skill-office' + config: + assetRoot: !!js process.cwd() + '/office-skills' diff --git a/snapshots/session/office-skills/cordis.yml b/snapshots/session/office-skills/cordis.yml new file mode 100644 index 0000000000..846be736ad --- /dev/null +++ b/snapshots/session/office-skills/cordis.yml @@ -0,0 +1,5 @@ +- insert: + - id: office-skills + name: '@deepseek-ai/dsh-skill-office' + config: + assetRoot: !!js process.cwd() + '/office-skills' diff --git a/snapshots/session/office-skills/session.v3.jsonl b/snapshots/session/office-skills/session.v3.jsonl new file mode 100644 index 0000000000..a835c42df9 --- /dev/null +++ b/snapshots/session/office-skills/session.v3.jsonl @@ -0,0 +1,25 @@ +{"type":"session","version":3,"id":"{{session:1}}","createdAt":1783654655599,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0} +{"type":"permission/preset","data":{"preset":"danger-full-access"}} +{"type":"sandbox/mode","data":{"mode":"danger-full-access"}} +{"type":"approval/policy","data":{"policy":"never"}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Load the office-pptx and office-xlsx skills with the skill tool, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"}]}} +{"type":"turn/start","data":{"turn":1}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","data":{"turn":1,"step":1}} +{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{message:2}}"}},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Load the office-pptx and office-xlsx skills with the skill tool, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"{{message:3}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"\nA skill is a reusable set of task-specific instructions. The following skills are available in this session:\n\n\n- `office-docx`: Create, read, edit, and check Word documents (.docx), including reports, letters, and formatted tables. Use when a DOCX file is an input or requested deliverable.\n- `office-pptx`: Create, read, edit, and check PowerPoint presentations (.pptx), including slide text, tables, images, and charts. Use when a PPTX file is an input or requested deliverable.\n- `office-xlsx`: Read, create, and modify Excel workbooks (.xlsx), including cell values, formulas, formatting, and pandas analysis. Use when an Excel workbook is an input or deliverable.\n\n\nIf the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.\nA user may also invoke a skill directly; its block then appears in this conversation. Follow it, and do not call the `skill` tool again for that skill.\n"}],"source":{"kind":"skill-catalog","form":"catalog","entries":[{"name":"office-docx","description":"Create, read, edit, and check Word documents (.docx), including reports, letters, and formatted tables. Use when a DOCX file is an input or requested deliverable."},{"name":"office-pptx","description":"Create, read, edit, and check PowerPoint presentations (.pptx), including slide text, tables, images, and charts. Use when a PPTX file is an input or requested deliverable."},{"name":"office-xlsx","description":"Read, create, and modify Excel workbooks (.xlsx), including cell values, formulas, formatting, and pandas analysis. Use when an Excel workbook is an input or deliverable."}]},"role":"user","id":"{{message:4}}"},"surfaceOp":"append"} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"session/title","data":{"title":"Load the office-pptx and office-xlsx","messageSeqs":[8],"source":{"kind":"fallback"}}} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"Load the requested skill."},{"type":"tool-call","id":"call_skill_load","name":"skill","arguments":"{\"name\":\"office-pptx\"}"},{"type":"tool-call","id":"call_skill_xlsx","name":"skill","arguments":"{\"name\":\"office-xlsx\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:5}}"},"usage":{"inputTokens":100,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":5},"stream":[{"type":"chunk","time":1789490612936,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1789490612936,"index":0,"dt":[],"texts":["Load the requested skill."]},{"type":"chunk","time":1789490612936,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1789490612936,"index":1,"dt":[],"id":"call_skill_load","name":"skill","args":["{\"name\":\"office-pptx\"}"]},{"type":"chunk","time":1789490612936,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Load the requested skill."}}},{"type":"chunk","time":1789490612936,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_skill_load","name":"skill","arguments":"{\"name\":\"office-pptx\"}"}}},{"type":"chunk","time":1789490612936,"chunk":{"type":"block-start","index":2,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1789490612936,"index":2,"dt":[],"id":"call_skill_xlsx","name":"skill","args":["{\"name\":\"office-xlsx\"}"]},{"type":"chunk","time":1789490612936,"chunk":{"type":"block-end","index":2,"block":{"type":"tool-call","id":"call_skill_xlsx","name":"skill","arguments":"{\"name\":\"office-xlsx\"}"}}},{"type":"chunk","time":1789490612936,"chunk":{"type":"usage","usage":{"inputTokens":100,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":5}}},{"type":"chunk","time":1789490612936,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_skill_load","name":"skill","arguments":"{\"name\":\"office-pptx\"}"}} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_skill_load"},"content":[{"type":"tool-result","toolCallId":"call_skill_load","content":[{"type":"text","text":"\n\nBase directory for this skill: {{cwd}}/office-skills/office-pptx\nResolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.\n\n\n\n# PowerPoint presentations\n\nFollow an explicit user or applicable AGENTS.md requirement for an environment or library. Otherwise call `load_workspace_dependencies` and use its Python executable and bundled presentation libraries. Do not install npm or pip packages or locate a system interpreter for the default workflow. If the tool is unavailable, use an already configured environment and report a missing dependency only when it prevents the requested operation.\n\nKeep scripts and output files in the task workspace. The runtime and skill directory contain shared read-only resources. Match the requested slide language and the supplied presentation's design when editing it.\n\n## Create and edit\n\nUse `python-pptx` to inspect or modify an existing presentation. Inspect slide layouts, text runs, images, tables, and charts before editing. Change only the requested content, preserve mixed text formatting, and save to a new file unless the user requests an in-place edit. Rebuilding slides can discard unsupported animation, SmartArt, or other extension content.\n\nFor a new deck, use `python-pptx` with editable text, tables, and charts. Use local image assets rather than network-dependent image URLs. Set slide dimensions, text sizes, and chart data explicitly.\n\nA minimal editable deck, run with the selected Python executable:\n\n```python\nfrom pptx import Presentation\nfrom pptx.chart.data import CategoryChartData\nfrom pptx.enum.chart import XL_CHART_TYPE\nfrom pptx.util import Inches, Pt\n\npresentation = Presentation()\npresentation.slide_width = Inches(13.333)\npresentation.slide_height = Inches(7.5)\nslide = presentation.slides.add_slide(presentation.slide_layouts[6])\ntitle = slide.shapes.add_textbox(Inches(0.6), Inches(0.4), Inches(12), Inches(0.8))\nrun = title.text_frame.paragraphs[0].add_run()\nrun.text = \"Quarterly report\"\nrun.font.size = Pt(30)\ndata = CategoryChartData()\ndata.categories = [\"Q1\", \"Q2\"]\ndata.add_series(\"Revenue\", [12, 18])\nslide.shapes.add_chart(XL_CHART_TYPE.COLUMN_CLUSTERED,\n Inches(0.8), Inches(1.6), Inches(11.5), Inches(4.8), data)\npresentation.save(\"report.pptx\")\n```\n\nUse `Inches` or `Cm` for positions and sizes and `Pt` for font sizes. To edit a generated title while retaining its other slides and charts:\n\n```python\nfrom pptx import Presentation\n\npresentation = Presentation(\"report.pptx\")\nfor shape in presentation.slides[0].shapes:\n if shape.has_text_frame and shape.text == \"Quarterly report\":\n shape.text_frame.paragraphs[0].runs[0].text = \"Quarterly results\"\npresentation.save(\"report-edited.pptx\")\n```\n\nCheck that text and images fit the slide dimensions, titles form a useful sequence, and chart labels agree with source values. A native chart's embedded workbook is part of the deliverable and must contain the intended data. Library support for writing PPTX is not a rendering engine or a guarantee that every PowerPoint feature survives editing.\n\n## Check and deliver\n\nRun the shared checker with the selected Python executable; `` is this loaded skill's resource base:\n\n```text\n /../scripts/check_office.py --out \n```\n\nIt checks ZIP/XML integrity and internal relationships and reports slide count and extracted text. Use repeated `--contains TEXT` arguments for required slide text and `--count N` for a requested slide count; `--contains` excludes chart text and speaker notes. Reopen the file to check the requested edits, chart data, and notes. Structural success does not establish text fit, alignment, readable contrast, or rendering fidelity.\n\nIf `render_document` is available and visual inspection is useful, call it on the final PPTX, initially omitting `pages` to prepare page 1 and obtain `pageCount`. The tool checks the current main model's actual image capability; do not choose a second model. If `status` is `skipped`, complete structural and content checks and deliver the PPTX, briefly stating that visual layout was not inspected. If ready, call `read_image` on its returned `pages[].imagePath`, then inspect remaining slides in small batches such as `pages: [2, 3, 4]`. Check clipping, alignment, contrast, chart labels, and consistency with the requested design or supplied reference; fix the source and render the affected pages again.\n\nUse the available rendering tool for this check. Inspect `warnings`, especially missing fonts. LibreOffice previews do not certify pixel-identical PowerPoint or Keynote output, animation, or media playback. If the rendering tool is unavailable or fails, retain the usable source and report the inspection limit; do not require the user to install another tool.\n\nCall `present({\"files\":[{\"path\":\"report-edited.pptx\"}]})` with the actual final PPTX path. It exposes the current source file without copying or preserving its bytes, so keep that file in place and leave intermediate images and reports out of delivery unless requested. If `present` is unavailable, use the session's supported file delivery method.\n\n"}],"isError":false}],"role":"user","id":"{{message:6}}"}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_skill_xlsx","name":"skill","arguments":"{\"name\":\"office-xlsx\"}"}} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_skill_xlsx"},"content":[{"type":"tool-result","toolCallId":"call_skill_xlsx","content":[{"type":"text","text":"\n\nBase directory for this skill: {{cwd}}/office-skills/office-xlsx\nResolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.\n\n\n\n# Excel workbooks\n\nFollow an explicit user or applicable AGENTS.md requirement for an environment or library. Otherwise call `load_workspace_dependencies` and run its Python executable with bundled `openpyxl` and `pandas`. Do not install packages or search for a system Python for the default workflow. If the tool is unavailable, use an already configured environment and report a missing dependency only when it prevents the requested operation.\n\nKeep scripts, working files, and outputs in the task workspace. The runtime and skill directory are shared read-only resources. Save to a new workbook unless the user requests an in-place edit.\n\n## Read and modify\n\nUse `openpyxl` for existing XLSX workbooks and targeted cell or style changes. Load with `data_only=False` when formulas must survive. Inspect sheet names, the affected cell types, formulas, styles, merged ranges, and referenced ranges before editing. Check the saved file by reopening it, including unchanged content that the request requires preserving.\n\nUse pandas for data analysis and transformations. A DataFrame is not the workbook: exporting it over an existing file can lose sheets, formulas, charts, and formatting. Write analysis results back to the intended ranges with openpyxl. `XlsxWriter` can create new workbooks, but cannot read or modify existing ones.\n\nFor a new workbook, the selected Python environment can write editable values, styles, and formulas directly:\n\n```python\nfrom openpyxl import Workbook\nfrom openpyxl.styles import Font\n\nworkbook = Workbook()\nsheet = workbook.active\nsheet.title = \"Revenue\"\nfor row in [(\"Quarter\", \"Revenue\"), (\"Q1\", 12), (\"Q2\", 18), (\"Total\", \"=SUM(B2:B3)\")]:\n sheet.append(row)\nfor cell in sheet[1]:\n cell.font = Font(bold=True)\nsheet.column_dimensions[\"A\"].width = 18\nsheet.column_dimensions[\"B\"].width = 18\nworkbook.save(\"report.xlsx\")\n```\n\nPreserve numbers, dates, booleans, and identifiers as the intended cell types; formatting is not a type conversion. For modifications, load the existing file with `openpyxl.load_workbook(\"input.xlsx\", data_only=False)`, change the requested ranges, and save a separate result. Avoid a DataFrame round trip when workbook features must survive.\n\nWriting a formula does not calculate its result. openpyxl and XlsxWriter do not evaluate Excel formulas; `data_only=True` returns stored cached values that may be absent or stale. Verify formulas and inputs separately, and state when current results require recalculation in a spreadsheet application. Do not replace requested formulas with constants or report cached values as newly calculated results.\n\nDo not rename `.xls`, `.xlsb`, encrypted files, or macro-enabled files to `.xlsx`. They need an appropriate supported operation. `keep_vba=True` can preserve VBA package content in an XLSM workflow, but does not execute or edit macros and does not guarantee every advanced workbook feature survives. Preserve the original and verify such requirements explicitly.\n\n## Check and deliver\n\nRun the shared checker with the selected Python executable; `` is this loaded skill's resource base:\n\n```text\n /../scripts/check_office.py --out \n```\n\nIt checks ZIP/XML integrity and internal relationships, and reports sheet names, populated-cell counts, and formula counts. Repeated `--contains TEXT` arguments check string cells and sheet names, and `--count N` checks sheet count. `--contains` excludes numeric cells and does not validate formula results; verify those separately with openpyxl. The checker does not calculate formulas or judge workbook appearance. Compare relevant values, types, formulas, styles, and totals with the task's source data.\n\nIf `render_document` is available and visual inspection is useful, call it on the final workbook without `pages` to prepare page 1 and learn `pageCount`. It checks the current main model's actual image capability; do not choose a second model. On `status: \"skipped\"`, complete structural and data checks and deliver the workbook, briefly stating that visual layout was not inspected. Otherwise call `read_image` on `pages[].imagePath` and request remaining pages in small batches. Check column widths, number formats, clipping, charts, and print areas; fix the workbook and review the affected pages again.\n\nRendered pages follow spreadsheet print settings, so a page is not necessarily a worksheet. Set appropriate print areas and scaling when a readable printed layout is requested. Review `warnings` such as missing fonts. LibreOffice preview conversion neither updates the original workbook's cached formulas nor certifies native Excel calculation or appearance. If rendering is unavailable or fails, preserve the usable workbook and report the inspection limit; ordinary cell edits do not require installing an external renderer.\n\nCall `present({\"files\":[{\"path\":\"report.xlsx\"}]})` with the actual final workbook path. It exposes the current source file without copying or preserving its bytes, so keep that file in place and omit intermediate scripts and reports unless requested. If `present` is unavailable, use the session's supported file delivery method.\n\n"}],"isError":false}],"role":"user","id":"{{message:7}}"}},"sourceEventSeqs":[17],"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":1}} +{"type":"step/start","data":{"turn":1,"step":2}} +{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The skill is loaded."},{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:8}}"},"usage":{"inputTokens":180,"outputTokens":10,"cacheReadTokens":0,"reasoningTokens":4},"stream":[{"type":"chunk","time":1789490612960,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1789490612960,"index":0,"dt":[],"texts":["The skill is loaded."]},{"type":"chunk","time":1789490612960,"chunk":{"type":"block-start","index":1,"blockType":"text"}},{"type":"text-chunks","time0":1789490612961,"index":1,"dt":[],"texts":["DONE"]},{"type":"chunk","time":1789490612961,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The skill is loaded."}}},{"type":"chunk","time":1789490612961,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}},{"type":"chunk","time":1789490612961,"chunk":{"type":"usage","usage":{"inputTokens":180,"outputTokens":10,"cacheReadTokens":0,"reasoningTokens":4}}},{"type":"chunk","time":1789490612961,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":2}} +{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/snapshots/session/office-skills/snapshot.yml b/snapshots/session/office-skills/snapshot.yml new file mode 100644 index 0000000000..24b0d4a06a --- /dev/null +++ b/snapshots/session/office-skills/snapshot.yml @@ -0,0 +1,12 @@ +version: 1 +scenario: office-skills +profile: headless +composition: office-skills +recording: authored +header: + class: office-skills + pin: true + systemPromptSource: text-turn + toolSchemasSource: text-turn +workspace: + setup: office-skills diff --git a/snapshots/session/office-skills/system-prompt.expected.md b/snapshots/session/office-skills/system-prompt.expected.md new file mode 120000 index 0000000000..4ca0dd39b2 --- /dev/null +++ b/snapshots/session/office-skills/system-prompt.expected.md @@ -0,0 +1 @@ +../text-turn/system-prompt.expected.md \ No newline at end of file diff --git a/snapshots/session/office-skills/tool-schemas.expected.json b/snapshots/session/office-skills/tool-schemas.expected.json new file mode 120000 index 0000000000..11d7ccbf24 --- /dev/null +++ b/snapshots/session/office-skills/tool-schemas.expected.json @@ -0,0 +1 @@ +../text-turn/tool-schemas.expected.json \ No newline at end of file diff --git a/tsconfig.base.json b/tsconfig.base.json index b55a3f3e6e..e23e3ac3e1 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -421,6 +421,7 @@ "@deepseek-ai/dsh-skill": ["./packages/skill/skill/src"], "@deepseek-ai/dsh-skill-badge": ["./packages/skill/skill-badge/src"], "@deepseek-ai/dsh-skill-filesystem": ["./packages/skill/skill-filesystem/src"], + "@deepseek-ai/dsh-skill-office": ["./packages/skill/skill-office/src"], "@deepseek-ai/dsh-spill": ["./packages/spill/spill/src"], "@deepseek-ai/dsh-spill-local": ["./packages/spill/spill-local/src"], "@deepseek-ai/dsh-spill-policy": ["./packages/spill/spill-policy/src"], diff --git a/tsconfig.host.json b/tsconfig.host.json index cf53223326..f5f583e3df 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -149,6 +149,7 @@ "scripts/client-bundle-purity.spec.ts" ], "references": [ + { "path": "./packages/skill/skill-office" }, { "path": "./packages/browser-use/browser-use" }, { "path": "./packages/experimental/browser-use-runtime" }, { "path": "./packages/experimental/browser-use-playwright-mcp" }, From 82ab876331c57736aed15af05e461401e5a15df4 Mon Sep 17 00:00:00 2001 From: yudshj Date: Wed, 16 Sep 2026 14:04:01 +0800 Subject: [PATCH 15/15] fix(office): validate strict and corrupt OOXML --- ...2026-09-15-bundled-office-skills.i18n.yaml | 4 +- .../2026-09-15-bundled-office-skills.md | 2 +- .../2026-09-15-bundled-office-skills.zh.md | 2 +- apps/desktop-host/src/office.ts | 1 + docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 3 + docs/module-graph.zh.md | 3 + docs/subsystems/skills.i18n.yaml | 4 +- docs/subsystems/skills.md | 4 +- docs/subsystems/skills.zh.md | 4 +- packages/skill/skill-office/README.i18n.yaml | 4 +- packages/skill/skill-office/README.md | 2 +- packages/skill/skill-office/README.zh.md | 2 +- .../assets/scripts/check_office.py | 131 ++++++++++++------ .../skill-office/tests/check_office_test.py | 54 +++++++- 15 files changed, 166 insertions(+), 58 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-09-15-bundled-office-skills.i18n.yaml b/.agents/notes/implemented/feature/2026-09-15-bundled-office-skills.i18n.yaml index 3d46b8afa8..00f68c0337 100644 --- a/.agents/notes/implemented/feature/2026-09-15-bundled-office-skills.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-15-bundled-office-skills.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-15-bundled-office-skills.md -2026-09-15-bundled-office-skills.md: e041e52cbfc3d4d33870fa3c8faf5fac3c0b35a4 -2026-09-15-bundled-office-skills.zh.md: 3f36a0e6883f08df66c6781cbf8784a6a4c358b0 +2026-09-15-bundled-office-skills.md: 089ca0326a7c53d4ee023608010efd49a8616db2 +2026-09-15-bundled-office-skills.zh.md: cd10aa863b1149594e9eb6b6a8e479511424f20d diff --git a/.agents/notes/implemented/feature/2026-09-15-bundled-office-skills.md b/.agents/notes/implemented/feature/2026-09-15-bundled-office-skills.md index e041e52cbf..089ca0326a 100644 --- a/.agents/notes/implemented/feature/2026-09-15-bundled-office-skills.md +++ b/.agents/notes/implemented/feature/2026-09-15-bundled-office-skills.md @@ -12,7 +12,7 @@ Office tasks need format-specific editing guidance and dependable file checks. R The [Office provider](../../../../packages/skill/skill-office/README.md) contributes three independently discoverable skills at the bundled rank. The default workflow uses `load_workspace_dependencies` and its Python executable; explicit user and AGENTS.md environment choices take precedence. A configurable absolute asset root lets Desktop expose Python-readable resources outside its application archive. Registration validates the required resources and YAML descriptions; loaded instructions exclude the metadata. Disposal removes every candidate. -One standard-library checker validates ZIP/XML integrity and internal relationships, reports format-specific structure, and checks only explicit text or count assertions. DOCX table summaries count logical grid columns, including merged cells. Section geometry is reported rather than judged against the final section; font filenames do not establish glyph coverage. XLSX formula counts never imply recalculation. Text assertions follow section and note references and worksheet string indices, so retained headers, unused note definitions, comments, glossary entries, and unused strings cannot satisfy requested wording. +One standard-library checker recognizes Transitional and Strict OOXML namespaces, validates ZIP/XML integrity and internal relationships, reports format-specific structure, and checks only explicit text or count assertions. Corrupt or encrypted ZIP members produce the same JSON package-failure report as other invalid documents. DOCX table summaries count logical grid columns, including merged cells. Section geometry is reported rather than judged against the final section; font filenames do not establish glyph coverage. XLSX formula counts never imply recalculation. Text assertions follow section and note references and worksheet string indices, so retained headers, unused note definitions, comments, glossary entries, and unused strings cannot satisfy requested wording. Desktop mounts the skill provider and runtime query independently of document rendering. Word uses python-docx, PowerPoint creation and editing use python-pptx, and Excel uses openpyxl and pandas. The managed payload and the ordinary creation examples require neither a rendering engine nor a separate presentation authoring library. diff --git a/.agents/notes/implemented/feature/2026-09-15-bundled-office-skills.zh.md b/.agents/notes/implemented/feature/2026-09-15-bundled-office-skills.zh.md index 3f36a0e688..cd10aa863b 100644 --- a/.agents/notes/implemented/feature/2026-09-15-bundled-office-skills.zh.md +++ b/.agents/notes/implemented/feature/2026-09-15-bundled-office-skills.zh.md @@ -12,7 +12,7 @@ Office 任务需要针对文件格式的编辑指引和可靠的文件检查。 [Office 提供方](../../../../packages/skill/skill-office/README.zh.md)以内置优先级提供三个可独立发现的 skill(技能)。默认工作流使用 `load_workspace_dependencies` 及其 Python 可执行文件;用户和 AGENTS.md 明确指定的环境优先。可配置的绝对资源根目录让 Desktop 在应用归档之外暴露 Python 可读取的资源。注册时验证必需资源与 YAML 描述;加载后的指令不包含元数据。卸载时移除全部候选项。 -一个仅依赖标准库的检查器验证 ZIP/XML 完整性与包内引用,报告各格式的结构,并只检查明确指定的文本或数量断言。DOCX 表格摘要计算逻辑网格列数,包含合并单元格。分节几何信息仅报告,不以最终分节判断全文;字体文件名不能证明字形覆盖范围。XLSX 公式数量不代表已执行重算。文本断言跟随分节与脚注/尾注引用及工作表字符串索引,因此保留的页眉、未使用的脚注/尾注定义、批注、词库条目和未使用的字符串不能满足措辞要求。 +一个仅依赖标准库的检查器识别 Transitional 与 Strict OOXML 命名空间,验证 ZIP/XML 完整性与包内引用,报告各格式的结构,并只检查明确指定的文本或数量断言。损坏或加密的 ZIP 成员与其他无效文档一样生成 JSON 包失败报告。DOCX 表格摘要计算逻辑网格列数,包含合并单元格。分节几何信息仅报告,不以最终分节判断全文;字体文件名不能证明字形覆盖范围。XLSX 公式数量不代表已执行重算。文本断言跟随分节与脚注/尾注引用及工作表字符串索引,因此保留的页眉、未使用的脚注/尾注定义、批注、词库条目和未使用的字符串不能满足措辞要求。 Desktop 独立于文档渲染挂载技能提供者和运行时查询。Word 使用 python-docx,PowerPoint 创建和编辑使用 python-pptx,Excel 使用 openpyxl 和 pandas。受管理产物和普通创建示例都不要求渲染引擎或单独的演示文稿创作库。 diff --git a/apps/desktop-host/src/office.ts b/apps/desktop-host/src/office.ts index 6f38e20587..9645435e8c 100644 --- a/apps/desktop-host/src/office.ts +++ b/apps/desktop-host/src/office.ts @@ -11,6 +11,7 @@ export const name = 'desktop-office' export interface Config { /** Bundled payload directory. Missing sibling `office-skills` resources fail Host startup. */ readonly source: string + /** Harness-home directory where workspace dependencies are installed. */ readonly root: string } diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 21ea52cffa..a6cd1fdedd 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: 719273c14784fcf5dd98b2fddabfb7666c666139 -module-graph.zh.md: b1098cc39fdc1d44f9d7edc3555d18a2f2c26bc5 +module-graph.md: ae72394aa8ab8cf0d00d9d6cf707a17281292517 +module-graph.zh.md: 65fac7e5ccf176d65a2b600c8652820c95884788 diff --git a/docs/module-graph.md b/docs/module-graph.md index 719273c147..ae72394aa8 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -64,6 +64,7 @@ flowchart TD pkg_skill["skill"] pkg_skill_badge["skill-badge"] pkg_skill_filesystem["skill-filesystem"] + pkg_skill_office["skill-office"] pkg_tool_skill["tool-skill"] end subgraph group_subagent["packages/subagent"] @@ -456,6 +457,7 @@ flowchart TD pkg_subprocess_local --> pkg_subprocess pkg_subprocess_local --> pkg_timeout pkg_skill_badge --> pkg_skill + pkg_skill_office --> pkg_skill pkg_spill --> pkg_brand pkg_spill --> pkg_llm pkg_spill --> pkg_session @@ -1418,6 +1420,7 @@ flowchart TD | [`sandbox-windows-acl`](../packages/sandbox/sandbox-windows-acl) | `sandbox` | [`subprocess`](../packages/subprocess/subprocess) | | [`subprocess-local`](../packages/subprocess/subprocess-local) | `subprocess` | [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`skill-badge`](../packages/skill/skill-badge) | `skill` | [`skill`](../packages/skill/skill) | +| [`skill-office`](../packages/skill/skill-office) | `skill` | [`skill`](../packages/skill/skill) | | [`spill`](../packages/spill/spill) | `spill` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`app-boot`](../packages/boot/app-boot) | `boot` | [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment), [`system-prompt`](../packages/core/system-prompt) | | [`persona`](../packages/preset/persona) | `preset` | [`system-prompt`](../packages/core/system-prompt) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index b1098cc39f..65fac7e5cc 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -66,6 +66,7 @@ flowchart TD pkg_skill["skill"] pkg_skill_badge["skill-badge"] pkg_skill_filesystem["skill-filesystem"] + pkg_skill_office["skill-office"] pkg_tool_skill["tool-skill"] end subgraph group_subagent["packages/subagent"] @@ -458,6 +459,7 @@ flowchart TD pkg_subprocess_local --> pkg_subprocess pkg_subprocess_local --> pkg_timeout pkg_skill_badge --> pkg_skill + pkg_skill_office --> pkg_skill pkg_spill --> pkg_brand pkg_spill --> pkg_llm pkg_spill --> pkg_session @@ -1420,6 +1422,7 @@ flowchart TD | [`sandbox-windows-acl`](../packages/sandbox/sandbox-windows-acl) | `sandbox` | [`subprocess`](../packages/subprocess/subprocess) | | [`subprocess-local`](../packages/subprocess/subprocess-local) | `subprocess` | [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`skill-badge`](../packages/skill/skill-badge) | `skill` | [`skill`](../packages/skill/skill) | +| [`skill-office`](../packages/skill/skill-office) | `skill` | [`skill`](../packages/skill/skill) | | [`spill`](../packages/spill/spill) | `spill` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`app-boot`](../packages/boot/app-boot) | `boot` | [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment), [`system-prompt`](../packages/core/system-prompt) | | [`persona`](../packages/preset/persona) | `preset` | [`system-prompt`](../packages/core/system-prompt) | diff --git a/docs/subsystems/skills.i18n.yaml b/docs/subsystems/skills.i18n.yaml index 6fd0768992..482b0c83ae 100644 --- a/docs/subsystems/skills.i18n.yaml +++ b/docs/subsystems/skills.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/subsystems/skills.md -skills.md: 84165578d37c0d947f20435d9605bb685a5b673b -skills.zh.md: 02fd41d5bc3a93ea4aa6de2a9996bc91bd648859 +skills.md: 7e45418b8b63955619b1d964e0cfc40ee7264632 +skills.zh.md: ead07c09fe7174d960a36173d842c652fa544c07 diff --git a/docs/subsystems/skills.md b/docs/subsystems/skills.md index 84165578d3..7e45418b8b 100644 --- a/docs/subsystems/skills.md +++ b/docs/subsystems/skills.md @@ -2,9 +2,9 @@ English | [中文](skills.zh.md) -The [skill capability family](../../packages/skill) includes the Service Definition ([dsh-skill](../../packages/skill/skill), `ctx.skills`), the local Service Provider ([dsh-skill-filesystem](../../packages/skill/skill-filesystem)), the optional packaged badge provider ([dsh-skill-badge](../../packages/skill/skill-badge)), and the Consumer ([dsh-tool-skill](../../packages/skill/tool-skill)). The registry merges provider catalogs across its host and per-scope layers; providers contribute local or packaged skills; the Consumer owns the initial and replacement catalogs plus the model-facing `skill` tool. Skills are optional instructions, not session events, so their vocabulary lives here rather than in [core.md](core.md). +The [skill capability family](../../packages/skill) includes the Service Definition ([dsh-skill](../../packages/skill/skill), `ctx.skills`), the local Service Provider ([dsh-skill-filesystem](../../packages/skill/skill-filesystem)), optional packaged providers ([dsh-skill-badge](../../packages/skill/skill-badge) and [dsh-skill-office](../../packages/skill/skill-office)), and the Consumer ([dsh-tool-skill](../../packages/skill/tool-skill)). The registry merges provider catalogs across its host and per-scope layers; providers contribute local or packaged skills; the Consumer owns the initial and replacement catalogs plus the model-facing `skill` tool. Skills are optional instructions, not session events, so their vocabulary lives here rather than in [core.md](core.md). -Source: [`packages/skill/skill/src/index.ts`](../../packages/skill/skill/src/index.ts), [`packages/skill/skill-filesystem/src/index.ts`](../../packages/skill/skill-filesystem/src/index.ts), [`packages/skill/skill-badge/src/index.ts`](../../packages/skill/skill-badge/src/index.ts), and [`packages/skill/tool-skill/src/index.ts`](../../packages/skill/tool-skill/src/index.ts). +Source: [`packages/skill/skill/src/index.ts`](../../packages/skill/skill/src/index.ts), [`packages/skill/skill-filesystem/src/index.ts`](../../packages/skill/skill-filesystem/src/index.ts), [`packages/skill/skill-badge/src/index.ts`](../../packages/skill/skill-badge/src/index.ts), [`packages/skill/skill-office/src/index.ts`](../../packages/skill/skill-office/src/index.ts), and [`packages/skill/tool-skill/src/index.ts`](../../packages/skill/tool-skill/src/index.ts). ## Provider registry diff --git a/docs/subsystems/skills.zh.md b/docs/subsystems/skills.zh.md index 02fd41d5bc..ead07c09fe 100644 --- a/docs/subsystems/skills.zh.md +++ b/docs/subsystems/skills.zh.md @@ -2,9 +2,9 @@ [English](skills.md) | 中文 -[skill(技能)能力族](../../packages/skill) 包含 Service Definition([dsh-skill](../../packages/skill/skill),`ctx.skills`)、本地 Service Provider([dsh-skill-filesystem](../../packages/skill/skill-filesystem))、可选的随包徽章提供方([dsh-skill-badge](../../packages/skill/skill-badge))和 Consumer([dsh-tool-skill](../../packages/skill/tool-skill))。注册表在其宿主层与各 scope 层之间合并各提供方的目录;提供方贡献本地或随包 skill;Consumer 拥有初始目录和替换目录,以及面向模型的 `skill` 工具。skill 是可选的指令而非会话事件,因此其词汇定义在此处而非 [core.md](core.zh.md)。 +[skill(技能)能力族](../../packages/skill) 包含 Service Definition([dsh-skill](../../packages/skill/skill),`ctx.skills`)、本地 Service Provider([dsh-skill-filesystem](../../packages/skill/skill-filesystem))、可选的随包提供方([dsh-skill-badge](../../packages/skill/skill-badge) 与 [dsh-skill-office](../../packages/skill/skill-office))和 Consumer([dsh-tool-skill](../../packages/skill/tool-skill))。注册表在其宿主层与各 scope 层之间合并各提供方的目录;提供方贡献本地或随包 skill;Consumer 拥有初始目录和替换目录,以及面向模型的 `skill` 工具。skill 是可选的指令而非会话事件,因此其词汇定义在此处而非 [core.md](core.zh.md)。 -源码:[`packages/skill/skill/src/index.ts`](../../packages/skill/skill/src/index.ts)、[`packages/skill/skill-filesystem/src/index.ts`](../../packages/skill/skill-filesystem/src/index.ts)、[`packages/skill/skill-badge/src/index.ts`](../../packages/skill/skill-badge/src/index.ts) 与 [`packages/skill/tool-skill/src/index.ts`](../../packages/skill/tool-skill/src/index.ts)。 +源码:[`packages/skill/skill/src/index.ts`](../../packages/skill/skill/src/index.ts)、[`packages/skill/skill-filesystem/src/index.ts`](../../packages/skill/skill-filesystem/src/index.ts)、[`packages/skill/skill-badge/src/index.ts`](../../packages/skill/skill-badge/src/index.ts)、[`packages/skill/skill-office/src/index.ts`](../../packages/skill/skill-office/src/index.ts) 与 [`packages/skill/tool-skill/src/index.ts`](../../packages/skill/tool-skill/src/index.ts)。 ## 提供方注册表 diff --git a/packages/skill/skill-office/README.i18n.yaml b/packages/skill/skill-office/README.i18n.yaml index 1bf89bcdc8..3449e641b5 100644 --- a/packages/skill/skill-office/README.i18n.yaml +++ b/packages/skill/skill-office/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/skill-office/README.md -README.md: 7ed8f0c0d839db3c62718f5ce97a55875bd65ddc -README.zh.md: 6b6e84bbd7db1a08a4c19c073759523b6cb4d1fd +README.md: 41150b81daa4a38fcf5905f4d1e07d2745a7616f +README.zh.md: 8b9342e137e526060fd072f0bfece904d2b7c1dc diff --git a/packages/skill/skill-office/README.md b/packages/skill/skill-office/README.md index 7ed8f0c0d8..41150b81da 100644 --- a/packages/skill/skill-office/README.md +++ b/packages/skill/skill-office/README.md @@ -41,7 +41,7 @@ Relative paths, missing resources, and skill files without a YAML frontmatter de ### Structural checks -The shared Python checker reads DOCX, PPTX, or XLSX without modifying the source. It validates ZIP/XML and internal package relationships, reports document structure, and optionally checks required text or slide/sheet count. DOCX text assertions cover the main body, section-referenced headers and footers, and body-referenced footnotes and endnotes; comments, glossary text, and unreferenced parts or notes do not satisfy them. It uses only the Python standard library. Invalid packages, encrypted ZIP members, and report-file write failures produce a JSON failure report on stdout. A passing report does not establish appearance, feature preservation, or calculated formula results. +The shared Python checker reads DOCX, PPTX, or XLSX without modifying the source. It recognizes Transitional and Strict OOXML namespaces, validates ZIP/XML and internal package relationships, reports document structure, and optionally checks required text or slide/sheet count. DOCX text assertions cover the main body, section-referenced headers and footers, and body-referenced footnotes and endnotes; comments, glossary text, and unreferenced parts or notes do not satisfy them. It uses only the Python standard library. Invalid packages, corrupt or encrypted ZIP members, and report-file write failures produce a JSON failure report on stdout. A passing report does not establish appearance, feature preservation, or calculated formula results. ----- diff --git a/packages/skill/skill-office/README.zh.md b/packages/skill/skill-office/README.zh.md index 6b6e84bbd7..8b9342e137 100644 --- a/packages/skill/skill-office/README.zh.md +++ b/packages/skill/skill-office/README.zh.md @@ -41,7 +41,7 @@ Agent(智能体)可以加载 Word、PowerPoint 和 Excel 工作流,默认 ### 结构检查 -共享 Python 检查器读取 DOCX、PPTX 或 XLSX,不修改源文件。它验证 ZIP/XML 和包内引用关系,报告文档结构,并可检查必需文本或幻灯片/工作表数量。DOCX 文本断言覆盖正文、分节引用的页眉和页脚,以及正文引用的脚注和尾注;批注、词库文本与未引用的部件或脚注/尾注不能满足断言。它只使用 Python 标准库。无效包、加密 ZIP 成员和报告文件写入失败都会在标准输出中产生 JSON 失败报告。检查通过不代表外观、特性保留或公式计算结果已得到验证。 +共享 Python 检查器读取 DOCX、PPTX 或 XLSX,不修改源文件。它识别 Transitional 与 Strict OOXML 命名空间,验证 ZIP/XML 和包内引用关系,报告文档结构,并可检查必需文本或幻灯片/工作表数量。DOCX 文本断言覆盖正文、分节引用的页眉和页脚,以及正文引用的脚注和尾注;批注、词库文本与未引用的部件或脚注/尾注不能满足断言。它只使用 Python 标准库。无效包、损坏或加密的 ZIP 成员和报告文件写入失败都会在标准输出中产生 JSON 失败报告。检查通过不代表外观、特性保留或公式计算结果已得到验证。 ----- diff --git a/packages/skill/skill-office/assets/scripts/check_office.py b/packages/skill/skill-office/assets/scripts/check_office.py index faa5bb4596..c62f8e764e 100644 --- a/packages/skill/skill-office/assets/scripts/check_office.py +++ b/packages/skill/skill-office/assets/scripts/check_office.py @@ -16,15 +16,31 @@ import json import posixpath import sys import zipfile +import zlib from pathlib import Path from urllib.parse import unquote, urlsplit from xml.etree import ElementTree as ET -W = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}" -A = "{http://schemas.openxmlformats.org/drawingml/2006/main}" -P = "{http://schemas.openxmlformats.org/presentationml/2006/main}" -S = "{http://schemas.openxmlformats.org/spreadsheetml/2006/main}" -R = "{http://schemas.openxmlformats.org/officeDocument/2006/relationships}" +W_NAMESPACES = ( + "http://schemas.openxmlformats.org/wordprocessingml/2006/main", + "http://purl.oclc.org/ooxml/wordprocessingml/main", +) +A_NAMESPACES = ( + "http://schemas.openxmlformats.org/drawingml/2006/main", + "http://purl.oclc.org/ooxml/drawingml/main", +) +P_NAMESPACES = ( + "http://schemas.openxmlformats.org/presentationml/2006/main", + "http://purl.oclc.org/ooxml/presentationml/main", +) +S_NAMESPACES = ( + "http://schemas.openxmlformats.org/spreadsheetml/2006/main", + "http://purl.oclc.org/ooxml/spreadsheetml/main", +) +R_NAMESPACES = ( + "http://schemas.openxmlformats.org/officeDocument/2006/relationships", + "http://purl.oclc.org/ooxml/officeDocument/relationships", +) MAIN_PARTS = { ".docx": ("word/document.xml", "wordprocessingml.document.main+xml"), ".pptx": ("ppt/presentation.xml", "presentationml.presentation.main+xml"), @@ -32,6 +48,34 @@ MAIN_PARTS = { } +def namespace(root: ET.Element, supported: tuple[str, ...], part: str) -> str: + """Return the main XML namespace after checking its OOXML variant.""" + uri = root.tag[1:].split("}", 1)[0] if root.tag.startswith("{") else "" + if uri not in supported: + raise ValueError(f"{part} uses unsupported XML namespace: {uri or '(none)'}") + return "{" + uri + "}" + + +def relationship_id(node: ET.Element, part: str) -> str: + """Read an office-document relationship id from Transitional or Strict OOXML.""" + for uri in R_NAMESPACES: + value = node.get("{" + uri + "}id") + if value is not None: + return value + raise ValueError(f"{part} has a reference without a relationship id") + + +def relationship_types(kind: str) -> set[str]: + """Return the Transitional and Strict relationship type names for one role.""" + return {f"{uri}/{kind}" for uri in R_NAMESPACES} + + +def iter_namespaces(root: ET.Element, namespaces: tuple[str, ...], local_name: str): + """Iterate matching elements across Transitional and Strict namespaces.""" + for uri in namespaces: + yield from root.iter("{" + uri + "}" + local_name) + + def relationship_target(part: str, target: str) -> str: """Resolve a package relationship without fetching external resources.""" path = unquote(urlsplit(target).path) @@ -62,78 +106,85 @@ def related_xml(part: str, reference: str, links: dict[str, str], xml: dict[str, def inspect_docx(xml: dict[str, ET.Element]) -> tuple[dict, str]: part = "word/document.xml" root = xml[part] - body = root.find(f"{W}body") + w = namespace(root, W_NAMESPACES, part) + body = root.find(f"{w}body") if body is None: raise ValueError("word/document.xml has no document body") tables = [] - for table in body.iter(f"{W}tbl"): - grid = table.findall(f"{W}tblGrid/{W}gridCol") - rows = table.findall(f"{W}tr") + for table in body.iter(f"{w}tbl"): + grid = table.findall(f"{w}tblGrid/{w}gridCol") + rows = table.findall(f"{w}tr") # Merged cells span logical grid columns; counting physical cells loses them. columns = len(grid) if grid else max((sum( - int(cell.find(f"{W}tcPr/{W}gridSpan").get(f"{W}val", "1")) - if cell.find(f"{W}tcPr/{W}gridSpan") is not None else 1 - for cell in row.findall(f"{W}tc") + int(cell.find(f"{w}tcPr/{w}gridSpan").get(f"{w}val", "1")) + if cell.find(f"{w}tcPr/{w}gridSpan") is not None else 1 + for cell in row.findall(f"{w}tc") ) for row in rows), default=0) tables.append({"rows": len(rows), "columns": columns}) sections = [] - for section in body.iter(f"{W}sectPr"): - size = section.find(f"{W}pgSz") - margins = section.find(f"{W}pgMar") + for section in body.iter(f"{w}sectPr"): + size = section.find(f"{w}pgSz") + margins = section.find(f"{w}pgMar") sections.append({ - "page_twips": {} if size is None else {key.removeprefix(W): value for key, value in size.attrib.items()}, - "margins_twips": {} if margins is None else {key.removeprefix(W): value for key, value in margins.attrib.items()}, + "page_twips": {} if size is None else {key.removeprefix(w): value for key, value in size.attrib.items()}, + "margins_twips": {} if margins is None else {key.removeprefix(w): value for key, value in margins.attrib.items()}, }) text_parts = [body] for kind in ("header", "footer"): - links = relationships(part, xml, {f"{R[1:-1]}/{kind}"}) - for section in body.iter(f"{W}sectPr"): - for reference in section.findall(f"{W}{kind}Reference"): - text_parts.append(related_xml(part, reference.attrib[f"{R}id"], links, xml)) + links = relationships(part, xml, relationship_types(kind)) + for section in body.iter(f"{w}sectPr"): + for reference in section.findall(f"{w}{kind}Reference"): + text_parts.append(related_xml(part, relationship_id(reference, part), links, xml)) for kind in ("footnote", "endnote"): - references = {node.attrib[f"{W}id"] for node in body.iter(f"{W}{kind}Reference")} + references = {node.attrib[f"{w}id"] for node in body.iter(f"{w}{kind}Reference")} if not references: continue - links = relationships(part, xml, {f"{R[1:-1]}/{kind}s"}) + links = relationships(part, xml, relationship_types(f"{kind}s")) for reference in links: tree = related_xml(part, reference, links, xml) - text_parts.extend(note for note in tree.findall(f"{W}{kind}") if note.get(f"{W}id") in references) - text = "\n".join("".join(node.text or "" for node in paragraph.iter(f"{W}t")) for tree in text_parts - for paragraph in tree.iter(f"{W}p")) - return {"paragraphs": len(list(body.iter(f"{W}p"))), "tables": tables, "sections": sections}, text + text_parts.extend(note for note in tree.findall(f"{w}{kind}") if note.get(f"{w}id") in references) + text = "\n".join("".join(node.text or "" for node in paragraph.iter(f"{w}t")) for tree in text_parts + for paragraph in tree.iter(f"{w}p")) + return {"paragraphs": len(list(body.iter(f"{w}p"))), "tables": tables, "sections": sections}, text def inspect_pptx(xml: dict[str, ET.Element]) -> tuple[dict, str]: part = "ppt/presentation.xml" links = relationships(part, xml) - slides = xml[part].findall(f"{P}sldIdLst/{P}sldId") + root = xml[part] + p = namespace(root, P_NAMESPACES, part) + slides = root.findall(f"{p}sldIdLst/{p}sldId") texts = [] for slide in slides: - reference = slide.attrib[f"{R}id"] + reference = relationship_id(slide, part) tree = related_xml(part, reference, links, xml) - texts.append("\n".join("".join(node.text or "" for node in paragraph.iter(f"{A}t")) - for paragraph in tree.iter(f"{A}p"))) + texts.append("\n".join("".join(node.text or "" for node in iter_namespaces(paragraph, A_NAMESPACES, "t")) + for paragraph in iter_namespaces(tree, A_NAMESPACES, "p"))) return {"slides": len(slides)}, "\n".join(texts) def inspect_xlsx(xml: dict[str, ET.Element]) -> tuple[dict, str]: part = "xl/workbook.xml" links = relationships(part, xml) + root = xml[part] + s = namespace(root, S_NAMESPACES, part) sheets = [] texts = [] shared = xml.get("xl/sharedStrings.xml") + shared_s = s if shared is None else namespace(shared, S_NAMESPACES, "xl/sharedStrings.xml") shared_strings = [] if shared is None else [ - "".join(node.text or "" for node in item.iter(f"{S}t")) for item in shared.iter(f"{S}si") + "".join(node.text or "" for node in item.iter(f"{shared_s}t")) for item in shared.iter(f"{shared_s}si") ] - for sheet in xml[part].findall(f"{S}sheets/{S}sheet"): - reference = sheet.attrib[f"{R}id"] + for sheet in root.findall(f"{s}sheets/{s}sheet"): + reference = relationship_id(sheet, part) tree = related_xml(part, reference, links, xml) - cells = list(tree.iter(f"{S}c")) - formulas = sum(cell.find(f"{S}f") is not None for cell in cells) + sheet_s = namespace(tree, S_NAMESPACES, links[reference]) + cells = list(tree.iter(f"{sheet_s}c")) + formulas = sum(cell.find(f"{sheet_s}f") is not None for cell in cells) sheets.append({"name": sheet.attrib["name"], "cells": len(cells), "formulas": formulas}) for cell in cells: if cell.get("t") == "s": - value = cell.findtext(f"{S}v", "") + value = cell.findtext(f"{sheet_s}v", "") location = f"{links[reference]} cell {cell.get('r', '(no reference)')}" try: index = int(value) @@ -142,8 +193,8 @@ def inspect_xlsx(xml: dict[str, ET.Element]) -> tuple[dict, str]: if not 0 <= index < len(shared_strings): raise ValueError(f"{location}: shared string index out of range: {index}") texts.append(shared_strings[index]) - texts.extend("".join(node.text or "" for node in cell.iter(f"{S}t")) for cell in cells) - texts.extend(cell.findtext(f"{S}v", "") for cell in cells if cell.get("t") == "str") + texts.extend("".join(node.text or "" for node in cell.iter(f"{sheet_s}t")) for cell in cells) + texts.extend(cell.findtext(f"{sheet_s}v", "") for cell in cells if cell.get("t") == "str") texts.append(sheet.attrib["name"]) return {"sheets": sheets, "formulas_evaluated": False}, "\n".join(texts) @@ -206,7 +257,7 @@ def main() -> int: if args.count is not None: actual = summary.get("slides", len(summary.get("sheets", []))) checks.append({"id": "count", "status": "pass" if actual == args.count else "fail", "expected": args.count, "actual": actual}) - except (OSError, ValueError, KeyError, RuntimeError, ET.ParseError, zipfile.BadZipFile) as error: + except (OSError, ValueError, KeyError, RuntimeError, ET.ParseError, zipfile.BadZipFile, zlib.error) as error: checks.append({"id": "package", "status": "fail", "detail": str(error)}) failed = any(check["status"] == "fail" for check in checks) report = {"format": args.input.suffix.lower()[1:], "verdict": "fail" if failed else "pass", "checks": checks, "summary": summary} diff --git a/packages/skill/skill-office/tests/check_office_test.py b/packages/skill/skill-office/tests/check_office_test.py index ccb530de0d..92ce6654e9 100644 --- a/packages/skill/skill-office/tests/check_office_test.py +++ b/packages/skill/skill-office/tests/check_office_test.py @@ -14,6 +14,11 @@ P = "http://schemas.openxmlformats.org/presentationml/2006/main" A = "http://schemas.openxmlformats.org/drawingml/2006/main" S = "http://schemas.openxmlformats.org/spreadsheetml/2006/main" R = "http://schemas.openxmlformats.org/officeDocument/2006/relationships" +STRICT_W = "http://purl.oclc.org/ooxml/wordprocessingml/main" +STRICT_P = "http://purl.oclc.org/ooxml/presentationml/main" +STRICT_A = "http://purl.oclc.org/ooxml/drawingml/main" +STRICT_S = "http://purl.oclc.org/ooxml/spreadsheetml/main" +STRICT_R = "http://purl.oclc.org/ooxml/officeDocument/relationships" PKG = "http://schemas.openxmlformats.org/package/2006/relationships" @@ -23,14 +28,14 @@ class OfficeCheckTest(unittest.TestCase): self.addCleanup(self.directory.cleanup) self.root = Path(self.directory.name) - def package(self, suffix, parts): + def package(self, suffix, parts, compression=zipfile.ZIP_STORED): primary, mime = { "docx": ("word/document.xml", "wordprocessingml.document.main+xml"), "pptx": ("ppt/presentation.xml", "presentationml.presentation.main+xml"), "xlsx": ("xl/workbook.xml", "spreadsheetml.sheet.main+xml"), }[suffix] path = self.root / ("中文 document." + suffix) - with zipfile.ZipFile(path, "w") as archive: + with zipfile.ZipFile(path, "w", compression=compression) as archive: archive.writestr("[Content_Types].xml", '' f'') for name, content in parts.items(): @@ -154,6 +159,33 @@ class OfficeCheckTest(unittest.TestCase): self.assertEqual(code, 0) self.assertEqual(report["summary"], {"sheets": [{"name": "Data", "cells": 3, "formulas": 1}], "formulas_evaluated": False}) + def test_strict_ooxml_namespaces_are_inspected(self): + docx = self.package("docx", { + "word/document.xml": f'Strict Word', + }) + self.assertEqual(self.run_check(docx, "--contains", "Strict Word")[0], 0) + + pptx = self.package("pptx", { + "ppt/presentation.xml": f'', + "ppt/_rels/presentation.xml.rels": f'', + "ppt/slides/slide1.xml": f'Strict Slides', + }) + self.assertEqual(self.run_check(pptx, "--contains", "Strict Slides", "--count", "1")[0], 0) + + xlsx = self.package("xlsx", { + "xl/workbook.xml": f''' + + ''', + "xl/_rels/workbook.xml.rels": f''' + + ''', + "xl/worksheets/sheet1.xml": f'Strict Sheet', + "xl/worksheets/sheet2.xml": f'', + }) + code, report = self.run_check(xlsx, "--contains", "Strict Sheet", "--count", "2") + self.assertEqual(code, 0) + self.assertEqual([sheet["name"] for sheet in report["summary"]["sheets"]], ["First", "Second"]) + def test_xlsx_checks_only_cell_referenced_shared_strings(self): parts = { "xl/workbook.xml": f'', @@ -212,6 +244,24 @@ class OfficeCheckTest(unittest.TestCase): self.assertEqual(code, 1) self.assertIn("encrypted", report["checks"][0]["detail"]) + def test_corrupt_deflate_member_returns_json_package_failure(self): + path = self.package("docx", { + "word/document.xml": f'', + }, compression=zipfile.ZIP_DEFLATED) + with zipfile.ZipFile(path) as archive: + member = archive.getinfo("word/document.xml") + data = bytearray(path.read_bytes()) + name_length = int.from_bytes(data[member.header_offset + 26:member.header_offset + 28], "little") + extra_length = int.from_bytes(data[member.header_offset + 28:member.header_offset + 30], "little") + compressed = member.header_offset + 30 + name_length + extra_length + data[compressed] = 0x07 # BTYPE=3 is reserved and invalid in a DEFLATE block. + path.write_bytes(data) + code, report = self.run_check(path) + self.assertEqual(code, 1) + self.assertEqual(report["checks"][0]["id"], "package") + self.assertEqual(report["checks"][0]["status"], "fail") + self.assertTrue(report["checks"][0]["detail"]) + def test_invalid_zip_and_xml_fail_and_output_cannot_overwrite_input(self): path = self.root / "broken.docx" path.write_bytes(b"not an Office archive")