From dbd881ab46abce3fe01dc22f49dbe347eb86b103 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 27 Oct 2026 17:07:25 +0800 Subject: [PATCH] feat(cua): deliver notarized helpers through on-demand plugin installation --- .github/workflows/cua-helper-package.yml | 83 ++++++++++++ .github/workflows/release-publish.yml | 19 +++ apps/cli/package.json | 1 - .../tests/cua-optional-package.spec.ts | 16 +-- .../guide.i18n.yaml | 4 +- .../computer-use-helper-permissions/guide.md | 2 +- .../guide.zh.md | 2 +- packages/boot/plugin-manager/src/index.ts | 1 + .../boot/plugin-manager/tests/manager.spec.ts | 3 + .../src/client/manager-store.ts | 8 +- .../tests/manager-store.client.spec.ts | 16 +++ .../experimental/cua-bundle/README.i18n.yaml | 8 +- packages/experimental/cua-bundle/README.md | 4 +- packages/experimental/cua-bundle/README.zh.md | 6 +- .../cua-macos/native/README.i18n.yaml | 4 +- .../experimental/cua-macos/native/README.md | 4 + .../cua-macos/native/README.zh.md | 4 + packages/experimental/cua-macos/package.json | 1 + .../util/official-bundles/README.i18n.yaml | 4 +- packages/util/official-bundles/README.md | 2 +- packages/util/official-bundles/README.zh.md | 2 +- packages/util/official-bundles/src/bundles.ts | 2 +- .../official-bundles/src/catalog.generated.ts | 10 ++ packages/util/official-bundles/src/index.ts | 2 + pnpm-lock.yaml | 9 +- scripts/cua-helper/package.ts | 86 +++++++++++++ scripts/cua-helper/payload.spec.ts | 86 +++++++++++++ scripts/cua-helper/payload.ts | 119 ++++++++++++++++++ scripts/cua-helper/smoke.py | 73 +++++++++++ scripts/cua-helper/stage.ts | 14 +++ scripts/cua-helper/verify-tarball.ts | 24 ++++ scripts/gen-official-bundle-catalog.spec.ts | 11 ++ scripts/gen-official-bundle-catalog.ts | 12 +- scripts/release/publish.ts | 7 ++ 34 files changed, 610 insertions(+), 39 deletions(-) create mode 100644 .github/workflows/cua-helper-package.yml create mode 100644 scripts/cua-helper/package.ts create mode 100644 scripts/cua-helper/payload.spec.ts create mode 100644 scripts/cua-helper/payload.ts create mode 100644 scripts/cua-helper/smoke.py create mode 100644 scripts/cua-helper/stage.ts create mode 100644 scripts/cua-helper/verify-tarball.ts diff --git a/.github/workflows/cua-helper-package.yml b/.github/workflows/cua-helper-package.yml new file mode 100644 index 0000000000..20ecb7526f --- /dev/null +++ b/.github/workflows/cua-helper-package.yml @@ -0,0 +1,83 @@ +name: Package CUA helper · macOS + +on: + workflow_dispatch: + inputs: + source_ref: + description: '同仓库可信源码分支或 dsh-v* tag' + type: string + default: master + required: true + preset: + description: '机器上的签名配置' + type: choice + options: [test, production] + default: test + required: true + workflow_call: + inputs: + source_ref: + type: string + required: true + preset: + type: string + default: production + outputs: + artifact: + description: '包含两个架构的已签名、公证 helper 归档' + value: ${{ jobs.package.outputs.artifact }} + +permissions: + contents: read + +# Desktop uses the same keychain search list and optional system proxy transaction. +concurrency: + group: macos-desktop-package + cancel-in-progress: false + +jobs: + package: + runs-on: [self-hosted, macOS, ARM64, dsh-mac-package-trial] + timeout-minutes: 60 + outputs: + artifact: ${{ steps.artifact.outputs.name }} + env: + DSH_PACKAGING_PRESET: ${{ inputs.preset }} + DSH_TELEMETRY_DISABLED: '1' + PACKAGE_SOURCE_REF: ${{ inputs.source_ref }} + steps: + - uses: actions/checkout@v7.0.1 + with: + ref: ${{ inputs.source_ref }} + persist-credentials: false + clean: true + - name: Validate source selection + run: | + node -e 'const cp=require("node:child_process"); const fs=require("node:fs"); const ref=process.env.PACKAGE_SOURCE_REF; + if(ref.startsWith("refs/pull/") || /^[a-f0-9]{40}$/i.test(ref)) throw Error("Select a trusted repository branch or release tag"); + const tag=ref.replace(/^refs\/tags\//, ""); + if(tag.startsWith("dsh-v") && tag!=="dsh-v"+JSON.parse(fs.readFileSync("package.json","utf8")).version) throw Error("Release tag must match product version"); + if(!["test","production"].includes(process.env.DSH_PACKAGING_PRESET)) throw Error("Invalid preset");' + - uses: pnpm/action-setup@v5.0.0 + with: + dest: ${{ runner.temp }}/setup-pnpm-cua-${{ github.run_id }}-${{ github.run_attempt }} + - uses: actions/setup-node@v7.0.0 + with: + node-version: '24' + package-manager-cache: false + - name: Install locked dependencies + run: pnpm install --frozen-lockfile + - name: Build and verify notarized applications + run: | + mkdir -p dist + pnpm exec tsx scripts/cua-helper/package.ts --out dist/cua-helper + - name: Name retained artifact + id: artifact + run: echo "name=cua-helper-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" >> "$GITHUB_OUTPUT" + - uses: actions/upload-artifact@v7.0.1 + with: + name: ${{ steps.artifact.outputs.name }} + path: dist/cua-helper/* + if-no-files-found: error + compression-level: 0 + retention-days: 14 diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml index ec03a117de..9a4df26999 100644 --- a/.github/workflows/release-publish.yml +++ b/.github/workflows/release-publish.yml @@ -16,8 +16,16 @@ env: DSH_TELEMETRY_DISABLED: '1' jobs: + cua-helper: + if: startsWith(github.ref, 'refs/tags/dsh-v') + uses: ./.github/workflows/cua-helper-package.yml + with: + source_ref: ${{ github.ref }} + preset: production + pack: name: Pack npm tarballs + needs: cua-helper runs-on: ubuntu-24.04 steps: # Complete history: the release scripts read tags. @@ -60,6 +68,17 @@ jobs: - name: Build run: pnpm run build:official + - uses: actions/download-artifact@v8.0.1 + with: + name: ${{ needs.cua-helper.outputs.artifact }} + path: dist/cua-helper + + - name: Stage verified native helper applications + run: | + mkdir dist/cua-helper/extracted + tar -xzf dist/cua-helper/cua-helper.tgz -C dist/cua-helper/extracted + pnpm exec tsx scripts/cua-helper/stage.ts --from dist/cua-helper/extracted + - name: Pack release tarballs run: pnpm run release:pack --family dsh --out dist/npm diff --git a/apps/cli/package.json b/apps/cli/package.json index e372b13247..c4cc1bf08a 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -106,7 +106,6 @@ "@deepseek-ai/dsh-experimental-terminal-bundle": "workspace:*", "@deepseek-ai/dsh-experimental-session-search": "workspace:*", "@deepseek-ai/dsh-experimental-tool-worktree": "workspace:*", - "@deepseek-ai/dsh-cua-bundle": "workspace:*", "@deepseek-ai/dsh-experimental-plugin-market-bundle": "workspace:*", "@deepseek-ai/dsh-personalized-recommendations": "workspace:*" }, diff --git a/apps/desktop/tests/cua-optional-package.spec.ts b/apps/desktop/tests/cua-optional-package.spec.ts index a6b42cdcfd..85ef15f0ee 100644 --- a/apps/desktop/tests/cua-optional-package.spec.ts +++ b/apps/desktop/tests/cua-optional-package.spec.ts @@ -1,4 +1,4 @@ -/** Cold pnpm requests exercise shipped CUA dependency/platform metadata with inert package contents. */ +/** Cold pnpm requests exercise on-demand CUA dependency/platform metadata with inert package contents. */ import { createHash } from 'node:crypto' import { createServer } from 'node:http' import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' @@ -37,14 +37,14 @@ it('fetches the macOS optional tarball only for Darwin, with an empty cache for const cli = await readManifest('apps/cli') const bundle = await readManifest('packages/experimental/cua-bundle') const native = await readManifest('packages/experimental/cua-macos') - expect(cli.dependencies).toHaveProperty(bundleName) + expect(cli.dependencies).not.toHaveProperty(bundleName) expect(bundle.dependencies).not.toHaveProperty(providerName) expect(bundle.optionalDependencies).toEqual({ [providerName]: 'workspace:*' }) expect(native.os).toEqual(['darwin']) const version = '1.0.0' const bundleDependencies = Object.fromEntries(Object.keys(bundle.dependencies as Record).map(name => [name, version])) const manifests = [ - { name: cli.name, version, dependencies: { [bundleName]: version } }, + { name: cli.name, version }, { name: bundleName, version, dependencies: bundleDependencies, optionalDependencies: { [providerName]: version } }, ...Object.keys(bundleDependencies).map(name => ({ name, version })), { name: providerName, version, os: native.os, files: native.files }, @@ -102,13 +102,13 @@ it('fetches the macOS optional tarball only for Darwin, with an empty cache for await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve) }) const address = server.address() if (!address || typeof address === 'string') throw new Error('fixture registry has no TCP address') - for (const target of ['darwin', 'win32', 'linux', 'linux-unrestricted']) { + for (const target of ['darwin-base', 'darwin', 'win32', 'linux', 'linux-unrestricted']) { unrestricted = target === 'linux-unrestricted' const project = join(root, target) await mkdir(project) - await writeFile(join(project, 'package.json'), JSON.stringify({ private: true, dependencies: { [String(cli.name)]: version } })) + await writeFile(join(project, 'package.json'), JSON.stringify({ private: true, dependencies: { [target === 'darwin-base' ? String(cli.name) : bundleName]: version } })) await writeFile(join(project, 'pnpm-workspace.yaml'), [ - 'autoInstallPeers: false', 'supportedArchitectures:', ` os: [${unrestricted ? 'linux' : target}]`, + 'autoInstallPeers: false', 'supportedArchitectures:', ` os: [${unrestricted ? 'linux' : target === 'darwin-base' ? 'darwin' : target}]`, ' cpu: [arm64]', 'minimumReleaseAge: 0', '', ].join('\n')) requests.length = 0 @@ -117,8 +117,8 @@ it('fetches the macOS optional tarball only for Darwin, with an empty cache for `--config.registry=http://127.0.0.1:${address.port}/`, `--config.store-dir=${join(project, 'store')}`, `--config.cache-dir=${join(project, 'cache')}`, `--config.state-dir=${join(project, 'state')}`, ], project) - expect(requests).toContain(`/${bundleName}.tgz`) - expect(requests).toContain('/@deepseek-ai/dsh-cua.tgz') + expect(requests.includes(`/${bundleName}.tgz`)).toBe(target !== 'darwin-base') + expect(requests.includes('/@deepseek-ai/dsh-cua.tgz')).toBe(target !== 'darwin-base') expect(requests.includes(`/${providerName}.tgz`), `${target}: ${JSON.stringify(requests)}\n${output}`) .toBe(target === 'darwin' || unrestricted) } diff --git a/docs/upgrade-guide/v0.2.1-rc.3/computer-use-helper-permissions/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-rc.3/computer-use-helper-permissions/guide.i18n.yaml index 7e4e4247a2..f8584a6842 100644 --- a/docs/upgrade-guide/v0.2.1-rc.3/computer-use-helper-permissions/guide.i18n.yaml +++ b/docs/upgrade-guide/v0.2.1-rc.3/computer-use-helper-permissions/guide.i18n.yaml @@ -12,5 +12,5 @@ en: 1552866718133b10 zh: 1c760c5c2d5cfc0b /authorize-the-computer-use-helper/migration: - en: 2a28385e24468734 - zh: 725c1856a604bc0c + en: a79e5995415ff456 + zh: 6f818a092e53af2c diff --git a/docs/upgrade-guide/v0.2.1-rc.3/computer-use-helper-permissions/guide.md b/docs/upgrade-guide/v0.2.1-rc.3/computer-use-helper-permissions/guide.md index 9e57c7c569..c913488785 100644 --- a/docs/upgrade-guide/v0.2.1-rc.3/computer-use-helper-permissions/guide.md +++ b/docs/upgrade-guide/v0.2.1-rc.3/computer-use-helper-permissions/guide.md @@ -13,7 +13,7 @@ macOS Computer Use runs as `DSH Computer Use.app`, launched through LaunchServic ## Migration -1. Restart DSH after upgrading the Computer Use package. In the Computer Use plugin page, grant Accessibility and Screen Recording access to `DSH Computer Use.app` when macOS requests it. +1. Computer Use now installs on demand. In **Plugins → Add plugin**, install `@deepseek-ai/dsh-cua-bundle` and enable it; profiles that previously selected the shipped copy need this installation. Restart DSH if requested. In the Computer Use plugin page, grant Accessibility and Screen Recording access to `DSH Computer Use.app` when macOS requests it. 2. Remove an obsolete `helperPath` override or `DSH_CUA_HELPER` value to use automatic preparation. For a custom build, point it to the executable at `DSH Computer Use.app/Contents/MacOS/dsh-cua-native` inside the complete application bundle. 3. Confirm that the permission page identifies `DSH Computer Use.app` and both permissions are allowed before resuming native application tasks. Target-application consent remains required. 4. If a terminal or Desktop launcher was granted these permissions only for Computer Use, revoke those obsolete grants in System Settings. Authorizing the helper does not revoke other applications’ existing permissions; programs the model runs still follow their launcher’s system permissions and the DSH sandbox policy. diff --git a/docs/upgrade-guide/v0.2.1-rc.3/computer-use-helper-permissions/guide.zh.md b/docs/upgrade-guide/v0.2.1-rc.3/computer-use-helper-permissions/guide.zh.md index a6c3a2f634..f25a4b805d 100644 --- a/docs/upgrade-guide/v0.2.1-rc.3/computer-use-helper-permissions/guide.zh.md +++ b/docs/upgrade-guide/v0.2.1-rc.3/computer-use-helper-permissions/guide.zh.md @@ -13,7 +13,7 @@ macOS Computer Use 通过 LaunchServices 以 `DSH Computer Use.app` 运行。终 ## 迁移 -1. 升级 Computer Use 包后重启 DSH。在 Computer Use 插件页按 macOS 提示,给 `DSH Computer Use.app` 授予辅助功能和屏幕录制权限。 +1. Computer Use 改为按需安装。在“插件 → 添加插件”中安装 `@deepseek-ai/dsh-cua-bundle` 并启用;此前选中过内置副本的 profile 也需完成这次安装。按提示重启 DSH。在 Computer Use 插件页按 macOS 提示,给 `DSH Computer Use.app` 授予辅助功能和屏幕录制权限。 2. 删除过时的 `helperPath` 覆盖配置或 `DSH_CUA_HELPER` 值,使用自动准备。使用自定义构建时,将其指向完整应用包内的 `DSH Computer Use.app/Contents/MacOS/dsh-cua-native` 可执行文件。 3. 恢复原生应用任务前,确认权限页显示 `DSH Computer Use.app`,且两项权限均已允许。具体目标应用仍需另行许可。 4. 若终端或 Desktop 启动器的这两项权限只用于 Computer Use,可在系统设置中撤销这些旧授权。给 helper 授权不会撤销其他应用已有的权限;模型运行的程序仍遵循启动器的系统权限和 DSH 沙箱策略。 diff --git a/packages/boot/plugin-manager/src/index.ts b/packages/boot/plugin-manager/src/index.ts index 271e6af0d3..7c70cc8526 100644 --- a/packages/boot/plugin-manager/src/index.ts +++ b/packages/boot/plugin-manager/src/index.ts @@ -522,6 +522,7 @@ export class PluginManager extends TypertRemoteService { catch (error) { platformError = managementError(error) } const error = platformError ?? targetError ?? (enabled ? { code: 'not-bundle' as const } : undefined) bundles.push({ name, official, availability: 'missing', ...catalogMeta, ...target, enabled, installed, optional, + ...offered.listedByDefault === undefined ? {} : { listedByDefault: offered.listedByDefault }, ...platformError?.code === 'unsupported-platform' ? { listedByDefault: false } : {}, removable: enabled, rows: [], overrides: [], ...error === undefined ? {} : { error } }) diff --git a/packages/boot/plugin-manager/tests/manager.spec.ts b/packages/boot/plugin-manager/tests/manager.spec.ts index 0f8290a48d..a7873169f5 100644 --- a/packages/boot/plugin-manager/tests/manager.spec.ts +++ b/packages/boot/plugin-manager/tests/manager.spec.ts @@ -1776,6 +1776,9 @@ it('lists absent Official entries offline without resolving their packages or co expect(bundles.filter(bundle => bundle.name === entry.packageName)).toEqual([{ name: entry.packageName, official: true, availability: 'missing', enabled: false, installed: false, optional: false, removable: false, updatable: false, rows: [], overrides: [], meta: entry.meta, + ...entry.listedByDefault === undefined ? {} : { listedByDefault: entry.listedByDefault }, + ...entry.platforms !== undefined && !entry.platforms.includes(process.platform) + ? { listedByDefault: false, error: { code: 'unsupported-platform' } } : {}, installTarget: { spec: `${entry.packageName}@${getDshRuntimeVersion()}`, version: getDshRuntimeVersion() }, }]) expect(resolve.mock.calls.some(([name]) => name === entry.packageName)).toBe(false) diff --git a/packages/client/ui-plugin-manager/src/client/manager-store.ts b/packages/client/ui-plugin-manager/src/client/manager-store.ts index b4b982ebf5..d857ae0eb1 100644 --- a/packages/client/ui-plugin-manager/src/client/manager-store.ts +++ b/packages/client/ui-plugin-manager/src/client/manager-store.ts @@ -1140,7 +1140,8 @@ export class PluginManagerController { return } this.abortInspect() - const registry = this.registryMemory.getSnapshot() ?? OFFICIAL_REGISTRY + const current = this.getSnapshot().install + const registry = current.open ? current.registry : this.registryMemory.getSnapshot() ?? OFFICIAL_REGISTRY const local = target.spec.startsWith('link:') const subject: InstallSubject = { status: 'accepted', kind: local ? 'path' : 'registry', name: pkg.name, version: target.version, bundle: true, @@ -1170,6 +1171,11 @@ export class PluginManagerController { } const listed = state.packages.find(pkg => pkg.name === spec) const catalogSubject = install.subject?.selection === undefined || install.subject.spec !== spec ? undefined : install.subject + if (listed?.availability === 'missing' && listed.installTarget !== undefined + && listed.error === undefined && catalogSubject === undefined) { + this.openCatalogInstall(listed, listed.installTarget, false) + return + } if (listed?.availability === 'installation' && listed.optional && listed.listedByDefault === false && !listed.enabled && listed.error === undefined && listed.readOnlyReason === undefined) { this.abortInspect() diff --git a/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts b/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts index 47dc13c330..6e4f61c026 100644 --- a/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts +++ b/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts @@ -1919,6 +1919,22 @@ const CATALOG: BundleInfo = { installTarget: { spec: '@deepseek-ai/dsh-subagent-codex@2.0.0', version: '2.0.0' }, } +it.each(['registry', 'source'] as const)('installs a typed default-hidden Official name using its %s target', async (mode) => { + const name = '@deepseek-ai/dsh-cua-bundle' + const spec = mode === 'source' ? 'link:/workspace/cua-bundle' : `${name}@2.0.0` + const entry = { ...CATALOG, name, listedByDefault: false, installTarget: { spec, version: '2.0.0' } } + const { controller, plugins, face, state } = bench({ listBundles: vi.fn().mockResolvedValue(ok([entry])) }) + await controller.load() + face.openInstall() + face.editInstallSpec(name) + face.runInstall() + await vi.waitFor(() => { expect(plugins.installBundle).toHaveBeenCalledOnce() }) + expect(plugins.inspect).not.toHaveBeenCalled() + expect(plugins.installBundle).toHaveBeenCalledWith(spec, { + enabled: false, ...mode === 'registry' ? { saveExact: true } : {}, requestId: state().install.requestId, registry: null, + }) +}) + it('installs an absent Official entry directly at the host target and enables it through the normal installer', async () => { const { controller, plugins, face, state } = bench({ listBundles: vi.fn().mockResolvedValue(ok([CATALOG])) }) await controller.load() diff --git a/packages/experimental/cua-bundle/README.i18n.yaml b/packages/experimental/cua-bundle/README.i18n.yaml index 8a9c5bf5c8..dcf7ac373c 100644 --- a/packages/experimental/cua-bundle/README.i18n.yaml +++ b/packages/experimental/cua-bundle/README.i18n.yaml @@ -15,8 +15,8 @@ en: b563e4ec4727d0de zh: d0228da978238e05 /deepseek-ai-dsh-cua-bundle/use-this-package: - en: dc35841a42753d00 - zh: 3c8a03a2ecbec26f + en: c17e2ac48c68a2fd + zh: 90df835c4e9bac75 /deepseek-ai-dsh-cua-bundle/understand-the-implementation: en: b1011db52cfc17cd zh: 6fa8798fedc5c0b6 @@ -39,8 +39,8 @@ en: 417c40acd93df532 zh: 69cc5cf7140c75a9 /deepseek-ai-dsh-cua-bundle/known-limitations-and-deferred-work: - en: 5586cb62bb16d601 - zh: 8b06372c77d0cd48 + en: 7d91694e2f6b111e + zh: 54bcc0cef9496ed6 /deepseek-ai-dsh-cua-bundle/known-limitations-and-deferred-work/dev-note: en: 0a1253c6f7b5e8b9 zh: ce96645b3e75874e diff --git a/packages/experimental/cua-bundle/README.md b/packages/experimental/cua-bundle/README.md index 07bb816d6d..573106bbff 100644 --- a/packages/experimental/cua-bundle/README.md +++ b/packages/experimental/cua-bundle/README.md @@ -24,7 +24,7 @@ Enable macOS Computer Use from Plugins for existing and new PTC sessions. Window ## Use this package -This workflow targets developers running macOS Web or unpackaged Desktop from source. In **Plugins → Add plugin**, enter `@deepseek-ai/dsh-cua-bundle`, choose **Install**, then **Enable now**. DSH enables its shipped copy without downloading another one. Enabled bundles remain in Plugins, where you can open Computer Use to manage permissions or switch it off. Switching off hides its card again unless the profile separately installed it. The permission page identifies the responsible application reported by macOS when the optional system query is available. Request access and enable that application in System Settings; follow the application named by macOS if it differs from the detected source. For Web, run these commands from the repository root: +In macOS Desktop, open **Plugins → Add plugin**, enter `@deepseek-ai/dsh-cua-bundle`, choose **Install**, then **Enable now**. DSH installs the official version matching its runtime into the profile. Published packages contain the signed and notarized helper; users need no compiler. Source Web and development Desktop link the workspace bundle and prepare an ad-hoc helper when enabled. Open Computer Use details, choose PTC if needed, and grant Accessibility and Screen Recording to `DSH Computer Use.app`. Disabled installed bundles remain available for management. For source Web, run these commands from the repository root: ```sh pnpm run build @@ -85,7 +85,7 @@ CUA activation and removal change the PTC SDK and system guidance; subsequent re -- Source Web and Desktop share permission controls on macOS. Packaged Desktop helper distribution, signing, and permission attribution are deferred. +- First installation requires a reachable registry carrying the matching DSH version. System permissions require the user’s approval in macOS; installation does not grant them. - PTC execution is shared by the profile and is configured independently. A Host without HMR applies saved changes at restart. diff --git a/packages/experimental/cua-bundle/README.zh.md b/packages/experimental/cua-bundle/README.zh.md index 2189839134..e537ab287e 100644 --- a/packages/experimental/cua-bundle/README.zh.md +++ b/packages/experimental/cua-bundle/README.zh.md @@ -24,7 +24,7 @@ kind: "package-bundle" ## 使用本包 -本流程面向从源码运行 macOS Web 或未打包 Desktop 的开发者。在「插件 → 添加插件」中输入 `@deepseek-ai/dsh-cua-bundle`,点击「安装」,再点击「立即启用」。DSH 会启用随应用提供的副本,无需重新下载。已启用的组合包保留在插件页,可打开 Computer Use 管理权限或将其关闭。关闭后卡片再次隐藏,除非该 profile 单独安装了它。权限页通过可选系统查询显示 macOS 报告的责任应用,指引用户申请权限并在系统设置中开启对应开关。若 macOS 显示其他应用名称,以系统提示为准。Web 版在仓库根目录运行以下命令: +在 macOS Desktop 的“插件 → 添加插件”中输入 `@deepseek-ai/dsh-cua-bundle`,点击“安装”,再点击“立即启用”。DSH 将与当前运行时版本匹配的官方插件安装到 profile。发布包包含已签名和公证的 helper,用户无需编译工具。源码 Web 和开发 Desktop 链接工作区组合包,启用时准备临时签名的 helper。打开 Computer Use 详情,按需设置 PTC,并为 `DSH Computer Use.app` 授予辅助功能和屏幕录制权限。已安装的组合包关闭后仍可管理。源码 Web 在仓库根目录运行以下命令: ```sh pnpm run build @@ -37,7 +37,7 @@ pnpm dsh web 关闭会卸载提供方,并从其 Agent 作用域中撤回 CUA 工具。执行器配置、新会话默认值和已保存的授权保留。 -Computer Use 仅支持 macOS DSH Host。原生提供方是仅在 macOS 安装的可选依赖,共享 CUA 和 Client 包仍随安装提供。其他 Host 默认不在插件列表和模型推荐中展示此组合包,并在保存设置前拒绝启用。profile 显式安装的依赖和已保存的选择仍保留管理入口。已选中的组合包会在启动时跳过并报告原因,仍可取消选择;已保存的用户设置保留。其他操作系统上的浏览器可以连接受支持的 Mac Host。 +Computer Use 仅支持 macOS DSH Host。原生提供方是仅在 macOS 安装的可选依赖,组合包和共享库均按需安装。其他 Host 默认不在插件列表和模型推荐中展示此组合包,并在保存设置前拒绝启用。profile 显式安装的依赖和已保存的选择仍保留管理入口。已选中的组合包会在启动时跳过并报告原因,仍可取消选择;已保存的用户设置保留。其他操作系统上的浏览器可以连接受支持的 Mac Host。 准备失败时,已保存的选择保留,CUA 操作不可用。刷新或重新打开插件页查看组件诊断,修复后重启 DSH。 @@ -85,7 +85,7 @@ CUA 生效和卸载会改变 PTC SDK 与系统指引,后续请求使用更新 -- 源码 Web 与 Desktop 共用 macOS 权限控件。桌面安装包的 helper 分发、签名和权限归属留待后续处理。 +- 首次安装需要能访问提供匹配 DSH 版本的注册表。系统权限需用户在 macOS 中确认,安装插件不会自动授予权限。 - PTC 执行方式由 profile 共享,需单独配置。未启用 HMR 的 Host 在重启时应用已保存的变更。 diff --git a/packages/experimental/cua-macos/native/README.i18n.yaml b/packages/experimental/cua-macos/native/README.i18n.yaml index a2b0f92a6f..c8ac345c9f 100644 --- a/packages/experimental/cua-macos/native/README.i18n.yaml +++ b/packages/experimental/cua-macos/native/README.i18n.yaml @@ -15,8 +15,8 @@ en: e8cbc43d38786c40 zh: 52b3ba85d98011b9 /dsh-cua-native-helper/build-and-service: - en: 532ba00d53c5bcb3 - zh: b5653aaafa2cbc89 + en: 5c1a2ff8728d4e60 + zh: db48888732b31c66 /dsh-cua-native-helper/request-protocol: en: fc8043c425322cdd zh: cf1edb9a78fda301 diff --git a/packages/experimental/cua-macos/native/README.md b/packages/experimental/cua-macos/native/README.md index c348882fc4..d4631d6062 100644 --- a/packages/experimental/cua-macos/native/README.md +++ b/packages/experimental/cua-macos/native/README.md @@ -27,6 +27,10 @@ Run `pnpm --dir packages/experimental/cua-macos run build:native` to publish a s Generations in `native/.build/prepared/` and `native/bin/darwin-` are retained without automatic garbage collection. Use a fresh `--output-directory` for release staging, or clean the packaging output before building while no client uses it, so old generations do not enter the tarball. Secure signing timestamps can create distinct generations for identical sources. +The manual **Package CUA helper · macOS** [workflow](../../../../.github/workflows/cua-helper-package.yml) builds both architectures on `dsh-mac-package-trial`. Select a trusted `source_ref` and machine `preset`; credentials use the existing [macOS packaging configuration](../../../../apps/desktop/packaging/README.md#macos-actions). The runner needs an active macOS login session and Rosetta for the x64 startup check. `pnpm exec tsx scripts/cua-helper/package.ts --out ` runs the same preparation from the repository root. It signs, notarizes and staples each complete application before selecting its final content digest, then verifies signatures, tickets, Gatekeeper, helper identity and cleanup from the extracted `cua-helper.tgz`. Permission checks do not request access or inspect applications. + +The workflow retains `cua-helper.tgz` and `cua-helper.json`, which binds the product version, source commit, Team ID and both application digests. [Release publish](../../../../.github/workflows/release-publish.yml) consumes this artifact before packing the provider for npm; ordinary PR release rehearsals have no signing credentials. Staging rejects a source/version mismatch, incomplete architectures, stale generations, links, changed bytes and missing executable permissions. npm publication rechecks the exact provider tarball before any registry write. A re-signed release has different bytes; preserve the retained artifact when retrying a partially published version. + Startup errors retain their code and message through an owner-only, per-attempt failure file and the launcher’s JSON error frame. Launch-lock and readiness waits share one deadline; operating-system launch time consumes that budget. Readiness must name the newly launched PID. A failed new instance is force-terminated and awaited before the launcher reports failure; this cleanup has a separate bound equal to the startup timeout. Existing shared services are not terminated by a failed attaching launcher. The application icon is generated from the [Computer Use artwork](../../cua-bundle/icon.svg) with `pnpm --dir packages/experimental/cua-macos run build:icon`. System permission prompts and the DSH permission page use this application icon. diff --git a/packages/experimental/cua-macos/native/README.zh.md b/packages/experimental/cua-macos/native/README.zh.md index 7da3c99c8f..72cae09077 100644 --- a/packages/experimental/cua-macos/native/README.zh.md +++ b/packages/experimental/cua-macos/native/README.zh.md @@ -27,6 +27,10 @@ kind: "package-reference" `native/.build/prepared/` 和 `native/bin/darwin-` 中的各代应用不会自动清理。正式打包时使用全新的 `--output-directory`,或确认没有客户端使用后清理打包输出再构建,避免旧应用进入 tarball。安全签名时间戳可能让相同源码生成不同代应用。 +手动工作流 **Package CUA helper · macOS** 的[配置](../../../../.github/workflows/cua-helper-package.yml)在 `dsh-mac-package-trial` 构建两种架构。选择可信的 `source_ref` 和机器 `preset`;凭据沿用[macOS 打包配置](../../../../apps/desktop/packaging/README.zh.md#macos-actions)。runner 需要已登录的 macOS 桌面会话,以及用于 x64 启动检查的 Rosetta。在仓库根目录运行 `pnpm exec tsx scripts/cua-helper/package.ts --out ` 可执行相同准备。完整应用签名、公证和贴票后才确定最终内容摘要,再从解包后的 `cua-helper.tgz` 验证签名、票据、Gatekeeper、helper 身份和退出清理。权限查询不会申请授权或观察应用。 + +工作流保留 `cua-helper.tgz` 和 `cua-helper.json`,后者记录产品版本、源码 commit、Team ID 和两份应用摘要。[Release publish](../../../../.github/workflows/release-publish.yml)在生成 provider 的 npm 包前接入该产物;普通 PR 发布检查不使用签名凭据。准备步骤拒绝源码或版本不匹配、架构不全、旧应用目录、链接、字节变化和执行权限缺失。npm 发布在写入注册表前重新检查实际 provider tarball。重新签名会改变产物字节,重试部分发布的版本时需保留原产物。 + 启动错误通过当前用户专用、每次启动独立的错误文件和 launcher 的 JSON 错误帧保留错误码与消息。等待启动锁和就绪状态共用一个期限,系统启动应用的耗时计入该预算。就绪记录必须指向本次启动的 PID。新实例启动失败后,launcher 强制终止并等待其退出,再报告失败;清理另有与启动超时等长的期限。连接已有服务失败时不会终止该服务。 应用图标通过 `pnpm --dir packages/experimental/cua-macos run build:icon` 从 [Computer Use 图形](../../cua-bundle/icon.svg)生成。系统授权提示和 DSH 权限页使用这个应用图标。 diff --git a/packages/experimental/cua-macos/package.json b/packages/experimental/cua-macos/package.json index a7a3a98f7d..8cfaf309af 100644 --- a/packages/experimental/cua-macos/package.json +++ b/packages/experimental/cua-macos/package.json @@ -26,6 +26,7 @@ "locale/*.json", "lib/index.js", "native/bin", + "cua-helper.json", "native/Package.swift", "native/Info.plist", "native/DSHComputerUse.icns", diff --git a/packages/util/official-bundles/README.i18n.yaml b/packages/util/official-bundles/README.i18n.yaml index b58cb9c8e7..e843d9aadd 100644 --- a/packages/util/official-bundles/README.i18n.yaml +++ b/packages/util/official-bundles/README.i18n.yaml @@ -27,8 +27,8 @@ en: 27c22abdfff69a41 zh: e60d828cdb75c263 /deepseek-ai-dsh-official-bundles/use-this-package/on-demand-bundles: - en: 6ba39ba5bfefec79 - zh: 788a71b5b6f75404 + en: ba4f412474854ba1 + zh: 17dae8155c013367 /deepseek-ai-dsh-official-bundles/understand-the-implementation: en: 3fb2b58db529d8be zh: 46064459a23d458d diff --git a/packages/util/official-bundles/README.md b/packages/util/official-bundles/README.md index 9af85af79f..f1e0d1224b 100644 --- a/packages/util/official-bundles/README.md +++ b/packages/util/official-bundles/README.md @@ -41,7 +41,7 @@ Edit this package when the installation's bundle selection or the plugin manager ### On-demand bundles -`ON_DEMAND_BUNDLES` names the public Official packages installed only on request. `OFFICIAL_ON_DEMAND_CATALOG` embeds their package-owned localized metadata, icons, and optional `dsh.bundle.platforms` declarations for offline discovery and Host platform checks before installation; `pnpm gen-official-bundle-catalog` explicitly updates generated content, and `verify-official-bundle-catalog` checks resource completeness and freshness; builds consume the committed catalog without importing provider code. The catalog contains no install target: the plugin manager derives it from the running version and installation and uses the ordinary bundle installer. The [product-use check](../../../scripts/verify-product-use.ts) composes shipped and on-demand selections against Web without executing providers. +`ON_DEMAND_BUNDLES` names the public Official packages installed only on request. `OFFICIAL_ON_DEMAND_CATALOG` embeds their package-owned localized metadata, icons, and optional `dsh.bundle.platforms` declarations for offline discovery and Host platform checks before installation; `pnpm gen-official-bundle-catalog` explicitly updates generated content, and `verify-official-bundle-catalog` checks resource completeness and freshness; builds consume the committed catalog without importing provider code. The catalog contains no install target: the plugin manager derives it from the running version and installation and uses the ordinary bundle installer. The [product-use check](../../../scripts/verify-product-use.ts) composes shipped and on-demand selections against Web without executing providers. The catalog also preserves `listedByDefault` so a default-hidden bundle stays hidden until explicitly installed or enabled. ----- diff --git a/packages/util/official-bundles/README.zh.md b/packages/util/official-bundles/README.zh.md index 91c0722cf7..2ce46257d2 100644 --- a/packages/util/official-bundles/README.zh.md +++ b/packages/util/official-bundles/README.zh.md @@ -41,7 +41,7 @@ kind: "package-library" ### 按需组合包 -`ON_DEMAND_BUNDLES` 列出只在请求时安装的官方公开包。`OFFICIAL_ON_DEMAND_CATALOG` 内嵌这些包自己持有的本地化元信息、图标和可选的 `dsh.bundle.platforms` 声明,用于离线发现与安装前的 Host 平台检查;`pnpm gen-official-bundle-catalog` 显式更新生成内容,`verify-official-bundle-catalog` 检查资源完整性与时效;构建使用已提交的目录,不导入提供方代码。目录不包含安装目标:插件管理器根据运行中的版本和安装推导目标,并使用普通的组合包安装器。[产品使用检查](../../../scripts/verify-product-use.ts)针对 Web 组合随附选择和按需选择,不执行提供方。 +`ON_DEMAND_BUNDLES` 列出只在请求时安装的官方公开包。`OFFICIAL_ON_DEMAND_CATALOG` 内嵌这些包自己持有的本地化元信息、图标和可选的 `dsh.bundle.platforms` 声明,用于离线发现与安装前的 Host 平台检查;`pnpm gen-official-bundle-catalog` 显式更新生成内容,`verify-official-bundle-catalog` 检查资源完整性与时效;构建使用已提交的目录,不导入提供方代码。目录不包含安装目标:插件管理器根据运行中的版本和安装推导目标,并使用普通的组合包安装器。[产品使用检查](../../../scripts/verify-product-use.ts)针对 Web 组合随附选择和按需选择,不执行提供方。 目录也保留 `listedByDefault`,使默认隐藏的组合包在用户显式安装或启用前保持隐藏。 ----- diff --git a/packages/util/official-bundles/src/bundles.ts b/packages/util/official-bundles/src/bundles.ts index 9e2b760a3e..13b9a29928 100644 --- a/packages/util/official-bundles/src/bundles.ts +++ b/packages/util/official-bundles/src/bundles.ts @@ -54,7 +54,6 @@ export const DEFAULT_PROFILE_BUNDLES: readonly string[] = ['@deepseek-ai/dsh-bas */ export const OPTIONAL_BUNDLES: readonly string[] = [ '@deepseek-ai/dsh-personalized-recommendations', - '@deepseek-ai/dsh-cua-bundle', '@deepseek-ai/dsh-experimental-session-search', '@deepseek-ai/dsh-experimental-ralph-bundle', '@deepseek-ai/dsh-experimental-terminal-bundle', @@ -73,4 +72,5 @@ export const OPTIONAL_BUNDLES: readonly string[] = [ export const ON_DEMAND_BUNDLES: readonly string[] = [ '@deepseek-ai/dsh-subagent-claude-code', '@deepseek-ai/dsh-subagent-codex', + '@deepseek-ai/dsh-cua-bundle', ] diff --git a/packages/util/official-bundles/src/catalog.generated.ts b/packages/util/official-bundles/src/catalog.generated.ts index 8a0cfb7579..3a494f90e6 100644 --- a/packages/util/official-bundles/src/catalog.generated.ts +++ b/packages/util/official-bundles/src/catalog.generated.ts @@ -20,4 +20,14 @@ export const OFFICIAL_ON_DEMAND_CATALOG: readonly OfficialBundleCatalogEntry[] = icon: 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzYiIGhlaWdodD0iMzYiIHZpZXdCb3g9IjAgMCAzNiAzNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB4PSIxNC45ODkzIiB5PSIxNS4xODc3IiB3aWR0aD0iMTIuMjM2NSIgaGVpZ2h0PSIxMi4yMzY1IiByeD0iMiIgZmlsbD0idXJsKCNkc2hfcGx1Z2luX2FydF9SMGEpIiBmaWxsLW9wYWNpdHk9IjAuOCI+PC9yZWN0PjxyZWN0IHg9IjguODcxMDkiIHk9IjkuMDY5NyIgd2lkdGg9IjEyLjIzNjUiIGhlaWdodD0iMTIuMjM2NSIgcng9IjIiIGZpbGw9InVybCgjZHNoX3BsdWdpbl9hcnRfUjBiKSIgZmlsbC1vcGFjaXR5PSIwLjgiPjwvcmVjdD48ZGVmcz48bGluZWFyR3JhZGllbnQgaWQ9ImRzaF9wbHVnaW5fYXJ0X1IwYSIgeDE9IjIxLjEwNzUiIHkxPSIxNS4xODc3IiB4Mj0iMjEuMTA3NSIgeTI9IjI3LjQyNDMiIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIj48c3RvcCBzdG9wLWNvbG9yPSIjNDVFN0E0Ij48L3N0b3A+PHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjMDU5MDlEIj48L3N0b3A+PC9saW5lYXJHcmFkaWVudD48bGluZWFyR3JhZGllbnQgaWQ9ImRzaF9wbHVnaW5fYXJ0X1IwYiIgeDE9IjE0Ljk4OTQiIHkxPSI5LjA2OTciIHgyPSIxNC45ODk0IiB5Mj0iMjEuMzA2MyIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiPjxzdG9wIHN0b3AtY29sb3I9IiM2OUI5RkYiPjwvc3RvcD48c3RvcCBvZmZzZXQ9IjEiIHN0b3AtY29sb3I9IiMzMjRERTIiPjwvc3RvcD48L2xpbmVhckdyYWRpZW50PjwvZGVmcz48L3N2Zz4K', }, }, + { + packageName: '@deepseek-ai/dsh-cua-bundle', + platforms: ['darwin'], + listedByDefault: false, + meta: { + title: { 'de': 'Computernutzung', 'en': 'Computer use', 'es': 'Uso de la computadora', 'fr': 'Utilisation de l\'ordinateur', 'ja': 'Computer Use', 'pt': 'Uso do computador', 'ru': '\u0418\u0441\u043F\u043E\u043B\u044C\u0437\u043E\u0432\u0430\u043D\u0438\u0435 \u043A\u043E\u043C\u043F\u044C\u044E\u0442\u0435\u0440\u0430', 'zh': 'Computer Use' }, + description: { 'de': 'Unterst\u00FCtzt derzeit macOS; Die Windows-Unterst\u00FCtzung befindet sich in der Entwicklung. L\u00E4dt die Computernutzung f\u00FCr bestehende und neue PTC-Sitzungen. Wechseln Sie in den PTC-Modus und erteilen Sie die Berechtigungen f\u00FCr Barrierefreiheit und Bildschirmaufzeichnung, um ihn zu verwenden.', 'en': 'Currently supports macOS; Windows support is in development. Loads computer use for existing and new PTC sessions. Switch to PTC mode and grant Accessibility and Screen Recording permissions to use it.', 'es': 'Actualmente es compatible con macOS; El soporte para Windows est\u00E1 en desarrollo. Carga el uso de la computadora para sesiones PTC nuevas y existentes. Cambie al modo PTC y otorgue permisos de Accesibilidad y Grabaci\u00F3n de pantalla para usarlo.', 'fr': 'Prend actuellement en charge macOS\u00A0; La prise en charge de Windows est en cours de d\u00E9veloppement. Charge l\'utilisation de l\'ordinateur pour les sessions PTC existantes et nouvelles. Passez en mode PTC et accordez les autorisations d\u2019accessibilit\u00E9 et d\u2019enregistrement d\u2019\u00E9cran pour l\u2019utiliser.', 'ja': '\u73FE\u5728\u306F macOS \u306E\u307F\u5BFE\u5FDC\u3057\u3001Windows \u5BFE\u5FDC\u306F\u958B\u767A\u4E2D\u3067\u3059\u3002\u65E2\u5B58\u304A\u3088\u3073\u65B0\u898F\u306E PTC \u30BB\u30C3\u30B7\u30E7\u30F3\u306B Computer Use \u3092\u8AAD\u307F\u8FBC\u307F\u307E\u3059\u3002PTC \u30E2\u30FC\u30C9\u306B\u5207\u308A\u66FF\u3048\u3001\u30A2\u30AF\u30BB\u30B7\u30D3\u30EA\u30C6\u30A3\u3068\u753B\u9762\u53CE\u9332\u306E\u6A29\u9650\u3092\u8A31\u53EF\u3057\u3066\u4F7F\u7528\u3057\u3066\u304F\u3060\u3055\u3044\u3002', 'pt': 'Atualmente suporta macOS; O suporte Windows est\u00E1 em desenvolvimento. Carrega o uso do computador para sess\u00F5es PTC novas e existentes. Mude para o modo PTC e conceda permiss\u00F5es de acessibilidade e grava\u00E7\u00E3o de tela para us\u00E1-lo.', 'ru': '\u0412 \u043D\u0430\u0441\u0442\u043E\u044F\u0449\u0435\u0435 \u0432\u0440\u0435\u043C\u044F \u043F\u043E\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u0442\u0441\u044F macOS; \u041F\u043E\u0434\u0434\u0435\u0440\u0436\u043A\u0430 Windows \u043D\u0430\u0445\u043E\u0434\u0438\u0442\u0441\u044F \u0432 \u0440\u0430\u0437\u0440\u0430\u0431\u043E\u0442\u043A\u0435. \u0417\u0430\u0433\u0440\u0443\u0436\u0430\u0435\u0442 \u0438\u0441\u043F\u043E\u043B\u044C\u0437\u043E\u0432\u0430\u043D\u0438\u0435 \u043A\u043E\u043C\u043F\u044C\u044E\u0442\u0435\u0440\u0430 \u0434\u043B\u044F \u0441\u0443\u0449\u0435\u0441\u0442\u0432\u0443\u044E\u0449\u0438\u0445 \u0438 \u043D\u043E\u0432\u044B\u0445 \u0441\u0435\u0430\u043D\u0441\u043E\u0432 PTC. \u041F\u0435\u0440\u0435\u043A\u043B\u044E\u0447\u0438\u0442\u0435\u0441\u044C \u0432 \u0440\u0435\u0436\u0438\u043C PTC \u0438 \u043F\u0440\u0435\u0434\u043E\u0441\u0442\u0430\u0432\u044C\u0442\u0435 \u0440\u0430\u0437\u0440\u0435\u0448\u0435\u043D\u0438\u044F \u043D\u0430 \u0434\u043E\u0441\u0442\u0443\u043F\u043D\u043E\u0441\u0442\u044C \u0438 \u0437\u0430\u043F\u0438\u0441\u044C \u044D\u043A\u0440\u0430\u043D\u0430 \u0434\u043B\u044F \u0435\u0433\u043E \u0438\u0441\u043F\u043E\u043B\u044C\u0437\u043E\u0432\u0430\u043D\u0438\u044F.', 'zh': '\u76EE\u524D\u4EC5\u652F\u6301 macOS\uFF0CWindows \u652F\u6301\u6B63\u5728\u5F00\u53D1\u4E2D\u3002\u4E3A\u5DF2\u6709\u53CA\u65B0\u5EFA PTC \u4F1A\u8BDD\u52A0\u8F7D Computer Use\u3002\u8BF7\u5207\u6362\u5230 PTC \u6A21\u5F0F\u6765\u4F7F\u7528\uFF0C\u5E76\u6388\u4E88\u8F85\u52A9\u529F\u80FD\u548C\u5C4F\u5E55\u5F55\u5236\u6743\u9650\u3002' }, + icon: 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzYiIGhlaWdodD0iMzYiIHZpZXdCb3g9IjAgMCAzNiAzNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICA8IS0tCiAgICBDb21wdXRlciBVc2UgcGx1Z2luIGljb24uCiAgICBBZGFwdGVkIGZyb20gTHVjaWRlICJtb3VzZS1wb2ludGVyLWNsaWNrIiAoaHR0cHM6Ly9sdWNpZGUuZGV2L2ljb25zL21vdXNlLXBvaW50ZXItY2xpY2spLgogICAgQ2hhbmdlczogc29saWQtZmlsbGVkIHBvaW50ZXIsIHByb2plY3QgcGFsZXR0ZSwgMzZ4MzYgY2FudmFzLgoKICAgIElTQyBMaWNlbnNlCiAgICBDb3B5cmlnaHQgKGMpIDIwMjYgTHVjaWRlIEljb25zIGFuZCBDb250cmlidXRvcnMKICAgIGh0dHBzOi8vZ2l0aHViLmNvbS9sdWNpZGUtaWNvbnMvbHVjaWRlL2Jsb2IvbWFpbi9MSUNFTlNFCgogICAgUGVybWlzc2lvbiB0byB1c2UsIGNvcHksIG1vZGlmeSwgYW5kL29yIGRpc3RyaWJ1dGUgdGhpcyBzb2Z0d2FyZSBmb3IgYW55CiAgICBwdXJwb3NlIHdpdGggb3Igd2l0aG91dCBmZWUgaXMgaGVyZWJ5IGdyYW50ZWQsIHByb3ZpZGVkIHRoYXQgdGhlIGFib3ZlCiAgICBjb3B5cmlnaHQgbm90aWNlIGFuZCB0aGlzIHBlcm1pc3Npb24gbm90aWNlIGFwcGVhciBpbiBhbGwgY29waWVzLgogICAgVEhFIFNPRlRXQVJFIElTIFBST1ZJREVEICJBUyBJUyIgQU5EIFRIRSBBVVRIT1IgRElTQ0xBSU1TIEFMTCBXQVJSQU5USUVTCiAgICBXSVRIIFJFR0FSRCBUTyBUSElTIFNPRlRXQVJFIElOQ0xVRElORyBBTEwgSU1QTElFRCBXQVJSQU5USUVTIE9GCiAgICBNRVJDSEFOVEFCSUxJVFkgQU5EIEZJVE5FU1MuIElOIE5PIEVWRU5UIFNIQUxMIFRIRSBBVVRIT1IgQkUgTElBQkxFIEZPUgogICAgQU5ZIFNQRUNJQUwsIERJUkVDVCwgSU5ESVJFQ1QsIE9SIENPTlNFUVVFTlRJQUwgREFNQUdFUyBPUiBBTlkgREFNQUdFUwogICAgV0hBVFNPRVZFUiBSRVNVTFRJTkcgRlJPTSBMT1NTIE9GIFVTRSwgREFUQSBPUiBQUk9GSVRTLCBXSEVUSEVSIElOIEFOCiAgICBBQ1RJT04gT0YgQ09OVFJBQ1QsIE5FR0xJR0VOQ0UgT1IgT1RIRVIgVE9SVElPVVMgQUNUSU9OLCBBUklTSU5HIE9VVCBPRgogICAgT1IgSU4gQ09OTkVDVElPTiBXSVRIIFRIRSBVU0UgT1IgUEVSRk9STUFOQ0UgT0YgVEhJUyBTT0ZUV0FSRS4KICAtLT4KICA8ZyB0cmFuc2Zvcm09InRyYW5zbGF0ZSg0LjAzNiA0LjAzNikgc2NhbGUoMS4yMjAxKSI+CiAgICA8cGF0aCBkPSJNOS4wMzcgOS42OWEuNDk4LjQ5OCAwIDAgMSAuNjUzLS42NTNsMTEgNC41YS41LjUgMCAwIDEtLjA3NC45NDlsLTQuMzQ5IDEuMDQxYTEgMSAwIDAgMC0uNzQuNzM5bC0xLjA0IDQuMzVhLjUuNSAwIDAgMS0uOTUuMDc0eiIgZmlsbD0idXJsKCNnKSIgc3Ryb2tlPSJ1cmwoI2cpIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIC8+CiAgICA8cGF0aCBkPSJNMTQgNC4xIDEyIDYiIHN0cm9rZT0iIzUxQ0ZEQiIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIC8+CiAgICA8cGF0aCBkPSJtNS4xIDgtMi45LS44IiBzdHJva2U9IiM1MUNGREIiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiAvPgogICAgPHBhdGggZD0ibTYgMTItMS45IDIiIHN0cm9rZT0iIzUxQ0ZEQiIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIC8+CiAgICA8cGF0aCBkPSJNNy4yIDIuMiA4IDUuMSIgc3Ryb2tlPSIjNTFDRkRCIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgLz4KICA8L2c+CiAgPGRlZnM+CiAgICA8bGluZWFyR3JhZGllbnQgaWQ9ImciIHgxPSI5IiB5MT0iOSIgeDI9IjIxIiB5Mj0iMjEiIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIj4KICAgICAgPHN0b3Agc3RvcC1jb2xvcj0iIzg2QjhGNSIgLz4KICAgICAgPHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjNTI2OUUxIiAvPgogICAgPC9saW5lYXJHcmFkaWVudD4KICA8L2RlZnM+Cjwvc3ZnPgo=', + }, + }, ] diff --git a/packages/util/official-bundles/src/index.ts b/packages/util/official-bundles/src/index.ts index 433ff321a4..e84033d6f0 100644 --- a/packages/util/official-bundles/src/index.ts +++ b/packages/util/official-bundles/src/index.ts @@ -17,6 +17,8 @@ export interface OfficialBundleCatalogEntry { readonly packageName: string /** Package-owned Host platform declaration, available before installation. */ readonly platforms?: DshBundleManifest['platforms'] + /** Package-owned discovery visibility before installation. */ + readonly listedByDefault?: boolean /** Package-owned localized text and a self-contained icon, embedded without provider code. */ readonly meta: Required> } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 98230d746f..659884802d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -325,9 +325,6 @@ importers: '@deepseek-ai/dsh-cordis-client-runner': specifier: workspace:* version: link:../../packages/extensions/cordis-client-runner - '@deepseek-ai/dsh-cua-bundle': - specifier: workspace:* - version: link:../../packages/experimental/cua-bundle '@deepseek-ai/dsh-experimental-agent-team-profile': specifier: workspace:* version: link:../../packages/experimental/agent-team-profile @@ -747,12 +744,12 @@ importers: '@deepseek-ai/dsh-home-paths': specifier: workspace:* version: link:../../packages/util/home-paths - '@deepseek-ai/dsh-plugin-manager': - specifier: workspace:* - version: link:../../packages/boot/plugin-manager '@deepseek-ai/dsh-official-bundles': specifier: workspace:* version: link:../../packages/util/official-bundles + '@deepseek-ai/dsh-plugin-manager': + specifier: workspace:* + version: link:../../packages/boot/plugin-manager '@deepseek-ai/dsh-util-file-drag': specifier: workspace:* version: link:../../packages/util/file-drag diff --git a/scripts/cua-helper/package.ts b/scripts/cua-helper/package.ts new file mode 100644 index 0000000000..ccc847bf76 --- /dev/null +++ b/scripts/cua-helper/package.ts @@ -0,0 +1,86 @@ +/** Build company-signed, notarized CUA applications using the existing macOS packaging credentials. */ +import { execFileSync } from 'node:child_process' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { parseArgs } from 'node:util' +import { execa } from 'execa' +import { create, extract } from 'tar' +import { loadDesktopPackageEnvironment } from '../../apps/desktop/scripts/desktop-package-environment.mjs' +import { resolveMacOSNotarizationEnvironment, resolveMacOSSigningEnvironment } from '../../apps/desktop/scripts/desktop-release-environment.mjs' +import { withMacOSSigningKeychain } from '../../apps/desktop/scripts/macos-signing-keychain.mjs' +import { withMacOSNotarizationProxy } from '../../apps/desktop/scripts/macos-notarization-proxy.ts' +import { resolveMacOSPackageSettings } from '../../apps/desktop/scripts/macos-package-settings.mjs' +import { notarizeMacOS } from '../../apps/desktop/scripts/notarize-macos.mjs' +import { verifyMacOSRuntimeCode } from '../../apps/desktop/scripts/verify-macos-signature.mjs' +import { CUA_APPLICATION, CUA_ARCHITECTURES, CUA_MANIFEST, readCuaApplication, sealCuaApplication, verifyCuaHelperPayload, type CuaHelperManifest } from './payload.ts' + +async function main(): Promise { + const { values } = parseArgs({ options: { out: { type: 'string' } }, allowPositionals: false }) + if (process.platform !== 'darwin' || !values.out) throw new Error('Usage on macOS: package.ts --out ') + const root = resolve(import.meta.dirname, '../..') + const output = resolve(values.out) + const environment = loadDesktopPackageEnvironment('darwin') + const signing = resolveMacOSSigningEnvironment(environment) + const notary = resolveMacOSNotarizationEnvironment(environment) + const settings = resolveMacOSPackageSettings(environment) + const { version } = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { version: string } + const commit = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8' }).trim() + mkdirSync(output, { recursive: false }) + const scratch = mkdtempSync(join(tmpdir(), 'dsh-cua-package-')) + const controller = new AbortController() + const stop = (): void => { controller.abort() } + process.once('SIGINT', stop) + process.once('SIGTERM', stop) + const run = async (command: string, args: string[], env = environment): Promise => { + await execa(command, args, { cwd: root, env, extendEnv: false, stdio: 'inherit', cancelSignal: controller.signal, killDescendants: true }) + } + const verify = async (application: string, architecture: 'arm64' | 'x64'): Promise => { + verifyMacOSRuntimeCode(application, signing) + await run('/usr/bin/lipo', ['-verify_arch', architecture === 'x64' ? 'x86_64' : 'arm64', join(application, 'Contents/MacOS/dsh-cua-native')]) + await run('/usr/bin/xcrun', ['stapler', 'validate', application]) + await run('/usr/sbin/spctl', ['--assess', '--type', 'execute', '--verbose=4', application]) + } + try { + const payload = join(scratch, 'payload') + const applications: CuaHelperManifest['applications'] = [] + await withMacOSSigningKeychain(environment, async (signingEnvironment) => { + for (const architecture of CUA_ARCHITECTURES) { + controller.signal.throwIfAborted() + const directory = join(payload, 'native/bin', `darwin-${architecture}`) + await run('/usr/bin/xcrun', ['python3', join(root, 'packages/experimental/cua-macos/native/build_app.py'), + '--arch', architecture === 'x64' ? 'x86_64' : 'arm64', '--scratch-path', join(scratch, `swift-${architecture}`), + '--output-directory', directory, '--identity', `Developer ID Application: ${signing.signingIdentity}`], signingEnvironment) + const { application } = readCuaApplication(directory) + await withMacOSNotarizationProxy(settings.notarizationProxy, () => notarizeMacOS({ appPath: application, ...notary })) + await verify(application, architecture) + applications.push({ architecture, bundle: sealCuaApplication(directory, application) }) + } + }) + const manifest: CuaHelperManifest = { schemaVersion: 1, version, commit, teamId: signing.teamId, applications } + writeFileSync(join(payload, CUA_MANIFEST), `${JSON.stringify(manifest, null, 2)}\n`) + verifyCuaHelperPayload(payload, { version, commit }) + const archive = join(output, 'cua-helper.tgz') + await create({ cwd: payload, file: archive, gzip: true, portable: true }, [CUA_MANIFEST, 'native']) + const extracted = join(scratch, 'extracted') + mkdirSync(extracted) + await extract({ cwd: extracted, file: archive, strict: true }) + verifyCuaHelperPayload(extracted, { version, commit }) + for (const { architecture, bundle } of applications) { + const application = join(extracted, 'native/bin', `darwin-${architecture}`, bundle, CUA_APPLICATION) + await verify(application, architecture) + await run('/usr/bin/xcrun', ['python3', join(root, 'scripts/cua-helper/smoke.py'), join(application, 'Contents/MacOS/dsh-cua-native')]) + } + writeFileSync(join(output, CUA_MANIFEST), `${JSON.stringify(manifest, null, 2)}\n`) + console.log(`CUA helper artifact: ${archive}`) + } catch (error) { + rmSync(join(output, 'cua-helper.tgz'), { force: true }) + throw error + } finally { + process.off('SIGINT', stop) + process.off('SIGTERM', stop) + rmSync(scratch, { recursive: true, force: true }) + } +} + +await main() diff --git a/scripts/cua-helper/payload.spec.ts b/scripts/cua-helper/payload.spec.ts new file mode 100644 index 0000000000..08c1c5e94c --- /dev/null +++ b/scripts/cua-helper/payload.spec.ts @@ -0,0 +1,86 @@ +/** Final application bytes and source identity survive packaging; invalid inputs never reach publication. */ +import { execFileSync } from 'node:child_process' +import { chmodSync, cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { create } from 'tar' +import { expect, it, onTestFinished } from 'vitest' +import { CUA_APPLICATION, CUA_ARCHITECTURES, CUA_MANIFEST, cuaApplicationDigest, readCuaApplication, sealCuaApplication, stageCuaHelperPayload, verifyCuaHelperPayload } from './payload.ts' +import { verifyCuaHelperTarball } from './verify-tarball.ts' + +const identity = { version: '1.0.0', commit: 'a'.repeat(40) } +function fixture() { + const root = mkdtempSync(join(tmpdir(), 'cua-payload-test-')) + onTestFinished(() => { rmSync(root, { recursive: true, force: true }) }) + const payload = join(root, 'payload') + const applications = CUA_ARCHITECTURES.map((architecture) => { + const directory = join(payload, 'native/bin', `darwin-${architecture}`) + const application = join(directory, 'unsigned', CUA_APPLICATION) + for (const [path, bytes] of Object.entries({ + 'Contents/Info.plist': 'fixture identity', 'Contents/MacOS/dsh-cua-native': `executable ${architecture}`, + 'Contents/Resources/DSHComputerUse.icns': 'icon', 'Contents/_CodeSignature/CodeResources': 'signature', + })) { + mkdirSync(join(application, path, '..'), { recursive: true }) + writeFileSync(join(application, path), bytes) + } + chmodSync(join(application, 'Contents/MacOS/dsh-cua-native'), 0o755) + return { architecture, bundle: sealCuaApplication(directory, application) } + }) + const manifest = { schemaVersion: 1, ...identity, teamId: 'ABCDEFGHIJ', applications } + writeFileSync(join(payload, CUA_MANIFEST), JSON.stringify(manifest)) + return { root, payload, manifest, directory: join(payload, 'native/bin/darwin-arm64') } +} + +it.skipIf(process.platform === 'win32')('matches the native build receipt hash and includes post-signing ticket bytes', () => { + const f = fixture() + const before = readCuaApplication(f.directory) + const script = resolve('packages/experimental/cua-macos/native/build_app.py') + const digest = execFileSync('python3', ['-c', 'import runpy,sys; from pathlib import Path; print(runpy.run_path(sys.argv[1])["bundle_digest"](Path(sys.argv[2])))', script, before.application], { encoding: 'utf8' }).trim() + expect(cuaApplicationDigest(before.application)).toBe(digest) + writeFileSync(join(before.application, 'Contents/ticket'), 'notarization ticket') + expect(() => readCuaApplication(f.directory)).toThrow('digest') + const after = sealCuaApplication(f.directory, before.application) + expect(after).not.toBe(before.bundle) + expect(readFileSync(join(readCuaApplication(f.directory).application, 'Contents/ticket'), 'utf8')).toBe('notarization ticket') +}) + +it.skipIf(process.platform === 'win32')('verifies staged and repacked provider bytes before publication', async () => { + const f = fixture() + expect(verifyCuaHelperPayload(f.payload, identity)).toEqual(f.manifest) + const packageRoot = join(f.root, 'package') + stageCuaHelperPayload(f.payload, packageRoot, identity) + expect(() => stageCuaHelperPayload(f.payload, packageRoot, identity)).toThrow('empty native/bin') + const tarball = join(f.root, 'provider.tgz') + await create({ file: tarball, cwd: f.root, gzip: true, portable: true }, ['package']) + await verifyCuaHelperTarball(tarball, identity) + const application = readCuaApplication(join(packageRoot, 'native/bin/darwin-arm64')).application + writeFileSync(join(application, 'Contents/MacOS/dsh-cua-native'), 'changed after signing') + await create({ file: tarball, cwd: f.root, gzip: true, portable: true }, ['package']) + await expect(verifyCuaHelperTarball(tarball, identity)).rejects.toThrow('digest') +}) + +it.skipIf(process.platform === 'win32').each(['version', 'commit', 'architecture', 'missing', 'extra', 'permissions', 'symlink'] as const)('rejects %s damage before staging', (damage) => { + const f = fixture() + const before = readCuaApplication(f.directory) + if (damage === 'version') f.manifest.version = '2.0.0' + if (damage === 'commit') f.manifest.commit = 'b'.repeat(40) + if (damage === 'architecture') f.manifest.applications[1] = f.manifest.applications[0]! + if (damage === 'missing') rmSync(join(before.application, 'Contents/Info.plist')) + if (damage === 'extra') cpSync(before.application, join(f.directory, 'old-generation', CUA_APPLICATION), { recursive: true }) + if (damage === 'permissions') chmodSync(join(before.application, 'Contents/MacOS/dsh-cua-native'), 0o644) + if (damage === 'symlink') symlinkSync(join(f.root, 'outside'), join(before.application, 'outside')) + writeFileSync(join(f.payload, CUA_MANIFEST), JSON.stringify(f.manifest)) + expect(() => stageCuaHelperPayload(f.payload, join(f.root, 'consumer'), identity)).toThrow() +}) + +it('keeps company signing outside pull-request release rehearsals', () => { + const workflow = readFileSync(resolve('.github/workflows/cua-helper-package.yml'), 'utf8') + expect(workflow).toContain('workflow_dispatch:') + expect(workflow).toContain('workflow_call:') + expect(workflow).not.toContain('pull_request:') + expect(workflow).toContain('dsh-mac-package-trial') + const release = readFileSync(resolve('.github/workflows/release-publish.yml'), 'utf8') + expect(release).toContain('needs: cua-helper') + expect(release).toContain('scripts/cua-helper/stage.ts') + expect(readFileSync(resolve('.github/workflows/release.yml'), 'utf8')).not.toContain('cua-helper-package.yml') +}) diff --git a/scripts/cua-helper/payload.ts b/scripts/cua-helper/payload.ts new file mode 100644 index 0000000000..2f5c00fc64 --- /dev/null +++ b/scripts/cua-helper/payload.ts @@ -0,0 +1,119 @@ +/** Integrity and source binding for notarized Computer Use applications consumed by npm packaging. */ +import { createHash } from 'node:crypto' +import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { z } from 'zod' + +/** Architectures carried by each published macOS provider package. */ +export const CUA_ARCHITECTURES = ['arm64', 'x64'] as const +/** Native application name shared with the provider receipt. */ +export const CUA_APPLICATION = 'DSH Computer Use.app' +/** Completion record written only after both applications pass distribution checks. */ +export const CUA_MANIFEST = 'cua-helper.json' +const Digest = z.string().regex(/^[a-f0-9]{64}$/u) +const Manifest = z.object({ + schemaVersion: z.literal(1), version: z.string().min(1), commit: z.string().regex(/^[a-f0-9]{40}$/u), + teamId: z.string().regex(/^[A-Z0-9]{10}$/u), + applications: z.array(z.object({ architecture: z.enum(CUA_ARCHITECTURES), bundle: Digest }).strict()).length(2), +}).strict() +/** Verified build identity and application generations. */ +export type CuaHelperManifest = z.infer + +/** + * Hash application-relative file names, bytes and executable bits, matching build_app.py. + * @param application - Real application directory; links and special files are rejected. + * @returns SHA-256 of the complete application, including its notarization ticket. + */ +export function cuaApplicationDigest(application: string): string { + const root = lstatSync(application) + if (!root.isDirectory() || root.isSymbolicLink()) throw new Error('CUA application must be a real directory') + const files: string[] = [] + const visit = (directory: string): void => { + for (const name of readdirSync(join(application, directory))) { + const path = directory ? `${directory}/${name}` : name + const stat = lstatSync(join(application, path)) + if (stat.isDirectory()) visit(path) + else if (stat.isFile()) files.push(path) + else throw new Error(`CUA application contains a link or special file: ${path}`) + } + } + visit('') + const digest = createHash('sha256') + for (const path of files.sort()) { + const file = join(application, path) + digest.update(`${path}\0`).update(createHash('sha256').update(readFileSync(file)).digest()) + .update(Buffer.from([(lstatSync(file).mode & 0o111) === 0 ? 0 : 1])) + } + return digest.digest('hex') +} + +/** + * Select the complete signed application and validate its immutable receipt. + * @param directory - One architecture's native/bin directory. + * @returns Selected application path and its content digest. + */ +export function readCuaApplication(directory: string): { application: string; bundle: string } { + const { bundle } = z.object({ bundle: Digest }).strict().parse(JSON.parse(readFileSync(join(directory, 'current.json'), 'utf8'))) + const application = join(directory, bundle, CUA_APPLICATION) + for (const path of ['Contents/Info.plist', 'Contents/MacOS/dsh-cua-native', 'Contents/Resources/DSHComputerUse.icns', 'Contents/_CodeSignature/CodeResources']) { + if (!lstatSync(join(application, path)).isFile()) throw new Error(`CUA application omits ${path}`) + } + if ((lstatSync(join(application, 'Contents/MacOS/dsh-cua-native')).mode & 0o111) === 0) throw new Error('CUA helper is not executable') + if (cuaApplicationDigest(application) !== bundle) throw new Error('CUA application digest does not match current.json') + if (readdirSync(directory).sort().join(',') !== [bundle, 'current.json'].sort().join(',')) throw new Error('CUA release contains unselected application generations') + return { application, bundle } +} + +/** + * Reselect a build after notarization has added its ticket, before distribution. + * @param directory - Fresh, build-owned architecture directory. + * @param application - Application just notarized and verified by the producer. + * @returns Final content digest; old build receipts are replaced. + */ +export function sealCuaApplication(directory: string, application: string): string { + const bundle = cuaApplicationDigest(application) + const destination = join(directory, bundle, CUA_APPLICATION) + if (destination !== application) { + mkdirSync(join(directory, bundle)) + renameSync(application, destination) + for (const name of readdirSync(directory)) { + if (name !== bundle && name !== 'current.json') rmSync(join(directory, name), { recursive: true, force: true }) + } + } + writeFileSync(join(directory, 'current.json'), `${JSON.stringify({ bundle })}\n`) + readCuaApplication(directory) + return bundle +} + +/** + * Verify the producer record and every final application before copying or packing. + * @param root - Extracted helper artifact containing native/bin and cua-helper.json. + * @param expected - Source commit and product version of the consuming checkout. + * @returns Validated producer metadata. + */ +export function verifyCuaHelperPayload(root: string, expected: { version: string; commit: string }): CuaHelperManifest { + const manifest = Manifest.parse(JSON.parse(readFileSync(join(root, CUA_MANIFEST), 'utf8'))) + if (manifest.version !== expected.version || manifest.commit !== expected.commit) throw new Error('CUA artifact does not match the release source and version') + if (new Set(manifest.applications.map(entry => entry.architecture)).size !== 2) throw new Error('CUA artifact must contain arm64 and x64') + const bin = join(root, 'native/bin') + if (readdirSync(bin).sort().join(',') !== 'darwin-arm64,darwin-x64') throw new Error('CUA artifact has unexpected architecture directories') + for (const entry of manifest.applications) { + const { bundle } = readCuaApplication(join(bin, `darwin-${entry.architecture}`)) + if (bundle !== entry.bundle) throw new Error('CUA application differs from the producer manifest') + } + return manifest +} + +/** + * Stage verified native applications for the provider's ordinary pnpm pack command. + * @param root - Extracted helper artifact. + * @param packageRoot - Provider package to receive native/bin. + * @param expected - Source identity required by the release. + */ +export function stageCuaHelperPayload(root: string, packageRoot: string, expected: { version: string; commit: string }): void { + verifyCuaHelperPayload(root, expected) + const destination = join(packageRoot, 'native/bin') + if (existsSync(destination)) throw new Error('CUA staging requires an empty native/bin; use a clean release checkout') + cpSync(join(root, 'native/bin'), destination, { recursive: true }) + cpSync(join(root, CUA_MANIFEST), join(packageRoot, CUA_MANIFEST)) +} diff --git a/scripts/cua-helper/smoke.py b/scripts/cua-helper/smoke.py new file mode 100644 index 0000000000..e33f0f8853 --- /dev/null +++ b/scripts/cua-helper/smoke.py @@ -0,0 +1,73 @@ +"""Query a distributed helper without requesting system permissions or inspecting applications.""" +import hashlib +import json +import os +from pathlib import Path +import signal +import socket +import subprocess +import sys +import tempfile +import time + + +def main(): + executable = Path(sys.argv[1]).resolve(strict=True) + key = hashlib.sha256(executable.read_bytes()).hexdigest() + pid = None + with tempfile.TemporaryDirectory(prefix="cua-smoke-", dir="/tmp") as directory: + os.chmod(directory, 0o700) + try: + started = subprocess.run([str(executable), "--ensure-shared", "--runtime-key", key, + "--runtime-directory", directory, "--startup-timeout-ms", "15000"], + check=True, capture_output=True, text=True, timeout=25) + ready = json.loads(started.stdout) + assert ready["runtimeKey"] == key and ready["type"] == "ready", ready + pid = ready["pid"] + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection: + connection.settimeout(20) + connection.connect(f"{directory}/{key}.sock") + stream = connection.makefile("rwb", buffering=0) + hello = {"type": "hello", "protocol": 1, "runtimeKey": key, "config": { + "maxRequestBytes": 1048576, "maxResponseBytes": 33554432, "axTimeoutMs": 3000, + "textEventIntervalMs": 0.25, "maxClickHoldMs": 5000, "screenshotTimeoutMs": 5000, + "timeoutMs": 30000, "graceMs": 5000, + }} + stream.write((json.dumps(hello) + "\n").encode()) + admitted = json.loads(stream.readline()) + assert admitted["type"] == "ready" and admitted["pid"] == pid, admitted + for method in ["getCapabilities", "permissionStatus"]: + stream.write((json.dumps({"id": method, "method": method, "params": {}}) + "\n").encode()) + response = json.loads(stream.readline()) + assert response["id"] == method and "result" in response, response + result = response["result"] + if method == "getCapabilities": + assert result["platform"] == "macos", result + else: + assert result["launchApplication"]["name"] == "DSH Computer Use.app", result.get("launchApplication", {}).get("name") + assert isinstance(result["accessibility"], bool) and isinstance(result["screenRecording"], bool) + connection.shutdown(socket.SHUT_WR) + assert stream.read() == b"", "helper did not acknowledge connection cleanup" + stream.close() + deadline = time.monotonic() + 10 + while time.monotonic() < deadline: + try: + os.kill(pid, 0) + except ProcessLookupError: + pid = None + break + time.sleep(0.025) + assert pid is None, "helper remained alive after its final connection closed" + print("CUA distribution smoke: capabilities, helper identity and cleanup passed") + finally: + if pid is not None: + try: + os.kill(pid, signal.SIGTERM) + except ProcessLookupError: + pass + subprocess.run(["/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister", + "-u", str(executable.parents[2])], check=True, capture_output=True, timeout=10) + + +if __name__ == "__main__": + main() diff --git a/scripts/cua-helper/stage.ts b/scripts/cua-helper/stage.ts new file mode 100644 index 0000000000..5ea7264ad1 --- /dev/null +++ b/scripts/cua-helper/stage.ts @@ -0,0 +1,14 @@ +/** Stage the signed helper artifact selected by the credential-free npm pack job. */ +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { parseArgs } from 'node:util' +import { stageCuaHelperPayload } from './payload.ts' + +const { values } = parseArgs({ options: { from: { type: 'string' } }, allowPositionals: false }) +if (!values.from) throw new Error('Usage: stage.ts --from ') +const root = resolve(import.meta.dirname, '../..') +const { version } = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')) as { version: string } +const commit = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8' }).trim() +stageCuaHelperPayload(resolve(values.from), resolve(root, 'packages/experimental/cua-macos'), { version, commit }) +console.log(`CUA helper staged for ${version} (${commit})`) diff --git a/scripts/cua-helper/verify-tarball.ts b/scripts/cua-helper/verify-tarball.ts new file mode 100644 index 0000000000..36bcd76258 --- /dev/null +++ b/scripts/cua-helper/verify-tarball.ts @@ -0,0 +1,24 @@ +/** Reject incomplete or modified CUA payloads before any npm publication. */ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { extract } from 'tar' +import { verifyCuaHelperPayload } from './payload.ts' + +/** + * Verify both helper applications in the exact provider tarball being published. + * @param tarball - pnpm-produced provider package archive. + * @param expected - Product version and source commit of the publishing checkout. + * @returns Resolves only when the archive has complete, digest-verified application generations. + */ +export async function verifyCuaHelperTarball(tarball: string, expected: { version: string; commit: string }): Promise { + const root = mkdtempSync(join(tmpdir(), 'cua-publish-verify-')) + try { + await extract({ file: tarball, cwd: root, strict: true, filter: (path, entry) => { + if (!path.startsWith('package/') || path.split('/').includes('..') + || !['File', 'Directory'].includes(entry.type)) throw new Error(`Invalid CUA package entry: ${path}`) + return true + } }) + verifyCuaHelperPayload(join(root, 'package'), expected) + } finally { rmSync(root, { recursive: true, force: true }) } +} diff --git a/scripts/gen-official-bundle-catalog.spec.ts b/scripts/gen-official-bundle-catalog.spec.ts index 316f4ea800..8ca4381dce 100644 --- a/scripts/gen-official-bundle-catalog.spec.ts +++ b/scripts/gen-official-bundle-catalog.spec.ts @@ -81,6 +81,17 @@ describe('Official bundle catalog generation', () => { expect(() => { syncOfficialBundleCatalog(root, true) }).toThrow('is stale') }) + it('preserves default-hidden discovery before installation', () => { + const root = fixture() + const name = ON_DEMAND_BUNDLES[0]! + updateManifest(root, name, { dsh: { bundle: { patch: './cordis.patch.yml', listedByDefault: false } } }) + expect(generateOfficialBundleCatalog(root)[0]?.listedByDefault).toBe(false) + syncOfficialBundleCatalog(root, false) + expect(readFileSync(join(root, output), 'utf8')).toContain('listedByDefault: false') + updateManifest(root, name, { dsh: { bundle: { patch: './cordis.patch.yml', listedByDefault: 'hidden' } } }) + expect(() => generateOfficialBundleCatalog(root)).toThrow('listedByDefault must be a boolean') + }) + it.each([[], ['macos'], 'darwin'].map(platforms => ({ platforms })))('rejects invalid package platform metadata $platforms', ({ platforms }) => { const root = fixture() updateManifest(root, ON_DEMAND_BUNDLES[0]!, { dsh: { bundle: { patch: './cordis.patch.yml', platforms } } }) diff --git a/scripts/gen-official-bundle-catalog.ts b/scripts/gen-official-bundle-catalog.ts index 4336e998c2..b9f703b927 100644 --- a/scripts/gen-official-bundle-catalog.ts +++ b/scripts/gen-official-bundle-catalog.ts @@ -22,7 +22,7 @@ const OUTPUT = 'packages/util/official-bundles/src/catalog.generated.ts' */ export function generateOfficialBundleCatalog(root: string, names: readonly string[] = ON_DEMAND_BUNDLES): OfficialBundleCatalogEntry[] { if (names.length === 0 || new Set(names).size !== names.length) throw new Error('Official on-demand catalog must contain distinct package names') - const manifests = new Map() + const manifests = new Map() for (const path of globSync(['packages/*/*/package.json', 'apps/*/package.json', 'vendor/*/package.json'], { cwd: root }).map(path => path.replaceAll('\\', '/')).sort()) { const manifest = JSON.parse(readFileSync(resolve(root, path), 'utf8')) as { name: string @@ -30,12 +30,15 @@ export function generateOfficialBundleCatalog(root: string, names: readonly stri dependencies?: Record optionalDependencies?: Record peerDependencies?: Record - dsh?: { bundle?: { patch?: unknown; platforms?: DshBundleManifest['platforms'] } } + dsh?: { bundle?: { patch?: unknown; platforms?: DshBundleManifest['platforms']; listedByDefault?: boolean } } } if (manifests.has(manifest.name)) throw new Error(`duplicate workspace package ${manifest.name}`) + const listedByDefault = manifest.dsh?.bundle?.listedByDefault + if (listedByDefault !== undefined && typeof listedByDefault !== 'boolean') throw new Error(`${manifest.name}: dsh.bundle.listedByDefault must be a boolean`) const runtime = ['dependencies', 'optionalDependencies', 'peerDependencies'] as const manifests.set(manifest.name, { path, private: manifest.private === true, bundle: manifest.dsh?.bundle?.patch !== undefined, ...manifest.dsh?.bundle?.platforms === undefined ? {} : { platforms: manifest.dsh.bundle.platforms }, + ...listedByDefault === undefined ? {} : { listedByDefault }, runtime: runtime.flatMap(section => Object.keys(manifest[section] ?? {})) }) } const visited = new Set() @@ -77,7 +80,9 @@ export function generateOfficialBundleCatalog(root: string, names: readonly stri } } } - return { packageName, ...entry.platforms === undefined ? {} : { platforms: entry.platforms }, + return { packageName, + ...entry.listedByDefault === undefined ? {} : { listedByDefault: entry.listedByDefault }, + ...entry.platforms === undefined ? {} : { platforms: entry.platforms }, meta: { title: meta.title, description: meta.description, icon: meta.icon } } }) } @@ -95,6 +100,7 @@ export function renderOfficialBundleCatalog(entries: readonly OfficialBundleCata + entries.map(entry => ' {\n' + ` packageName: ${quote(entry.packageName)},\n` + (entry.platforms === undefined ? '' : ` platforms: [${entry.platforms.map(quote).join(', ')}],\n`) + + (entry.listedByDefault === undefined ? '' : ` listedByDefault: ${entry.listedByDefault},\n`) + ' meta: {\n' + ` title: ${localized(entry.meta.title)},\n` + ` description: ${localized(entry.meta.description)},\n` diff --git a/scripts/release/publish.ts b/scripts/release/publish.ts index 732ff46943..0a2309b118 100644 --- a/scripts/release/publish.ts +++ b/scripts/release/publish.ts @@ -12,6 +12,8 @@ * the same artifact safe. */ +import { execFileSync } from 'node:child_process' +import { verifyCuaHelperTarball } from '../cua-helper/verify-tarball.ts' import { createHash } from 'node:crypto' import { readFileSync } from 'node:fs' import { join, resolve } from 'node:path' @@ -184,6 +186,11 @@ export async function publishRelease(familyId: string, directory: string, distTa const tarball = join(directory, filename) return { tarball, ...packedIdentity(tarball) } }) + for (const member of order) { + if (member.name !== '@deepseek-ai/dsh-experimental-cua-macos') continue + const commit = execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim() + await verifyCuaHelperTarball(member.tarball, { version: member.version, commit }) + } if (distTag !== undefined) { for (const member of order) verifyDistTag(member, distTag) }