fix(office): 升级 LibreOffice Kit 到 0.2.3,Kit CLI 定位失败时隔离并保留技能

- 将 Kit 引擎包解析提取为 office-engine-resolver,并与 master 的发布配置对齐
- LibreOffice Kit 升级到 0.2.3 正式版
- Desktop 的 Office CLI 改由 skill-office 解析 Kit,去掉 desktop-host 对 Kit 的依赖
- Kit 缺失时给出可操作错误;Office CLI 定位失败时隔离错误并保留 Office 技能
- 按 Kit 0.2.3 的 helper 授予 macOS JIT 权限,并按新 Host 签名转发夹具参数
This commit is contained in:
yudshj committed 2026-11-04 18:17:57 +08:00
1 parent 498d436db5
commit ee06bf3f9b
71 files changed
+1006 -210

No files matched your search

@@ -107,7 +107,7 @@ it.skipIf(!process.env.DEEPSEEK_API_KEY || process.platform === 'win32')(`agent
for (const operation of ['capabilities', 'convert']) expect(commandText, diagnostic).toMatch(new RegExp(`\\s${operation}\\b`, 'u'))
expect(existsSync(decoyMarker), 'the agent must not invoke system Office when the bundled CLI is supplied').toBe(false)
const capabilities = JSON.parse(await readFile(join(root, 'capabilities.json'), 'utf8')) as { runtime: { version: string; cliPath: string } }
expect(capabilities.runtime.version).toBe('0.1.5')
expect(capabilities.runtime.version).toBe('0.2.3')
expect(capabilities.runtime.cliPath).toMatch(/lib[/\\]cli\.js$/u)
const pdf = await readFile(join(root, 'result.pdf'))
expect(pdf.subarray(0, 5).toString()).toBe('%PDF-')
-1
View File
@@ -12,7 +12,6 @@
],
"dependencies": {
"koffi": "3.1.1",
"@deepseek-ai/libreoffice-kit": "0.2.3-rc.4",
"@deepseek-ai/cordis": "workspace:~",
"@deepseek-ai/dsh": "workspace:*",
"@deepseek-ai/dsh-agent": "workspace:*",
+2 -2
View File
@@ -27,8 +27,8 @@
en: 3724c53da4f7647b
zh: 76ae549d43c99bb4
/deepseek-harness-desktop/key-technical-decisions/bundled-workspace-dependencies:
en: 20dd884240f67420
zh: b2333a952af45463
en: 8a1e304605a27e0a
zh: f35e080335d10447
/deepseek-harness-desktop/bundled-command-runtime:
en: 372297005b57a08b
zh: 074a635ac17e4362
+3 -1
View File
@@ -103,7 +103,9 @@ Signed Windows packaging scans the primary runtime and application production de
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 exposes the compact `office` router and named authoring/operation subskills. Authoring uses bundled Python libraries; operation skills invoke the configured Node and CLI paths. Skills live in `runtime/office-skills` outside ASAR. A missing or invalid skill file removes only that skill from the catalog, and Office profile rows that cannot load leave Office skills or `load_workspace_dependencies` unavailable; neither failure stops Host startup. See the [Office skill package](../../packages/skill/skill-office/README.md) for progressive loading and limitations.
Desktop exposes the compact `office` router and named authoring/operation subskills. Authoring uses bundled Python libraries; operation skills invoke the configured Node and CLI paths. The shell resolves the Kit CLI relative to `dsh-skill-office/package.json`; ASAR runtimes use that anchor under the physical unpacked runtime and reject Kit paths outside it. The skill package itself may remain in ASAR. Skills live in `runtime/office-skills` outside ASAR. A missing or invalid skill file removes only that skill from the catalog, and Office profile rows that cannot load leave Office skills or `load_workspace_dependencies` unavailable; neither failure stops Host startup. See the [Office skill package](../../packages/skill/skill-office/README.md) for progressive loading and limitations.
If the shell cannot locate the Kit CLI, it clears `DSH_BUNDLED_OFFICE_CLI` from the Host environment and the Office profile row uses `cli: false`. Readable Office skills remain registered; operation instructions report that Kit is disabled instead of publishing executable paths. The independent dependency-query row is unaffected. The shell writes the latest failure, timestamp, and reinstall guidance to `office-kit-unavailable.log` in Electron's application logs directory; a write failure goes to stderr without stopping the Host. There is no GUI notification. Required Host components must still start successfully, and packaging smoke checks still reject an unavailable Kit.
The payload follows the Desktop release. `runtime.json` records the Desktop version, target, top-level interpreter/package-manager versions and the Python distribution map, and a digest of the selected target’s locked payload inputs and assembly format. Distribution names use PEP 503 normalization; duplicate normalized names reject the manifest. Legacy `components` manifests remain readable through normalization, with their existing library-version consistency checks. 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.
+3 -1
View File
@@ -103,7 +103,9 @@ Windows 签名打包按 PE 文件内容扫描第一方运行时和应用生产
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` 路由及可具名调用的创作/操作子技能。创作使用内置 Python 库;操作技能调用配置的 Node 和 CLI 路径。技能位于 ASAR 外的 `runtime/office-skills`。缺失或无效的技能文件只会把该技能移出目录;Office 配置行无法加载时,Office 技能或 `load_workspace_dependencies` 不可用。这两种失败都不会阻止 Host 启动。渐进加载与限制见 [Office 技能包](../../packages/skill/skill-office/README.zh.md)。
Desktop 展示精简的 `office` 路由及可具名调用的创作/操作子技能。创作使用内置 Python 库;操作技能调用配置的 Node 和 CLI 路径。壳以 `dsh-skill-office/package.json` 为锚点解析 Kit CLI;ASAR 运行时在实体解包运行时下使用该锚点,并拒绝指向其外部的 Kit 路径。技能包本身可以保留在 ASAR 内。技能位于 ASAR 外的 `runtime/office-skills`。缺失或无效的技能文件只会把该技能移出目录;Office 配置行无法加载时,Office 技能或 `load_workspace_dependencies` 不可用。这两种失败都不会阻止 Host 启动。渐进加载与限制见 [Office 技能包](../../packages/skill/skill-office/README.zh.md)。
壳无法定位 Kit CLI 时,会从 Host 环境中清除 `DSH_BUNDLED_OFFICE_CLI`,Office 配置行使用 `cli: false`。可读的 Office 技能仍会注册;操作说明报告 Kit 已禁用,不再提供可执行文件路径。独立的依赖查询行不受影响。壳把最近一次失败、时间戳和重装指引写入 Electron 应用日志目录中的 `office-kit-unavailable.log`;写入失败时输出到 stderr,不阻止 Host 启动。不提供 GUI 通知。Host 必需组件仍须成功启动,打包冒烟检查仍会拒绝不可用的 Kit。
该产物随 Desktop 版本发布。`runtime.json` 记录 Desktop 版本、目标平台、顶层解释器和包管理器版本及 Python 分发包版本表,以及所选目标的锁定产物输入与组装格式的摘要。分发包名称按 PEP 503 归一化;名称归一化后重复时,清单会被拒绝。旧 `components` 清单通过归一化继续可读,并保留其原有库版本一致性校验。匹配的安装会被复用;依赖或压缩包变化后,即使 Desktop 版本不变,也会在完整暂存副本完成后替换目录。不含摘要的旧清单会在下次安装时被替换。用户自行添加的 Python 包仅在产物身份一致时保留。目录替换失败时保留之前的安装;解释器仍在运行时,Windows 可能拒绝替换。
+1 -1
View File
@@ -252,7 +252,7 @@ Desktop packs workspace packages locally and installs external dependencies thro
The [Electron shell file list](../scripts/electron-builder-config.mjs) excludes recognized JavaScript/CSS/TypeScript source maps and TypeScript build caches from the separate `resources/app.asar/node_modules` dependency collection. These exclusions preserve runtime code, native modules, WASM, data files (including ordinary `.map` assets), licenses, and notices; [shell archive tests](../tests/shell-file-selection.spec.ts) check retained bytes and exclusions.
The [Office conversion provider](../../../packages/document/office-to-pdf/README.md) carries the target’s declared native engine, or WASM when the kit declares no matching native target. Preparation rejects a missing target engine before packaging. The complete Office dependencies, including CLI, JavaScript libraries, and the selected engine’s executables, data, licenses, and notices, are unpacked under `resources/app.asar.unpacked/dsh/node_modules/`. The Desktop Host resolves engine manifests to these physical directories and supplies loaded skills with absolute standalone Node and unpacked CLI paths. Node lives in `resources/runtime/primary-runtime/dependencies/node/bin/`; the CLI is the unpacked `@deepseek-ai/libreoffice-kit/lib/cli.js`. On macOS, the native helper receives the JIT entitlement required by [LibreOffice’s UNO bridge](https://github.com/LibreOffice/core/blob/master/sysui/desktop/macosx/hardened_runtime.xcent.in). The Computer Use helper applications under `@deepseek-ai/dsh-experimental-cua-macos/native/bin` are also unpacked whole under `resources/app.asar.unpacked/dsh/node_modules/`, and the provider opens that copy.
The [Office conversion provider](../../../packages/document/office-to-pdf/README.md) carries the target’s declared native engine, or WASM when the kit declares no matching native target. Preparation rejects a missing target engine before packaging. The complete Office dependencies, including CLI, JavaScript libraries, and the selected engine’s executables, data, licenses, and notices, are unpacked under `resources/app.asar.unpacked/dsh/node_modules/`. The Desktop Host resolves engine manifests to these physical directories. The Desktop shell resolves the primary runtime and unpacked CLI paths in [office-cli.ts](../src/office-cli.ts) and [main.ts](../src/main.ts), then passes them into the Host environment through `DSH_BUNDLED_PRIMARY_RUNTIME` and `DSH_BUNDLED_OFFICE_CLI`. The profile row in web-app's [cordis.patch.yml](../../../packages/bundle/web-app/cordis.patch.yml) uses these entries to configure skill-office's absolute standalone Node and CLI paths. Node lives in `resources/runtime/primary-runtime/dependencies/node/bin/`; the CLI is the unpacked `@deepseek-ai/libreoffice-kit/lib/cli.js`. On macOS, the native helper receives the JIT entitlement required by [LibreOffice’s UNO bridge](https://github.com/LibreOffice/core/blob/master/sysui/desktop/macosx/hardened_runtime.xcent.in). The Computer Use helper applications under `@deepseek-ai/dsh-experimental-cua-macos/native/bin` are also unpacked whole under `resources/app.asar.unpacked/dsh/node_modules/`, and the provider opens that copy.
The packaged application runs compiled JavaScript and pre-generated Typert metadata; it does not compile TypeScript plugins. Source-level debugger navigation and editor declarations remain available in development packages. [Copy-policy tests](../tests/runtime-file-policy.spec.ts) cover exclusions and retained assets; The [payload smoke](../tests/fixtures/runtime-payload-smoke.mjs) runs under Electron RunAsNode before the Host smoke and final inventory verification. The payload smoke resolves the search tool’s ripgrep executable and verifies text search and file enumeration. Signed Windows builds run these checks after dependency signing; other builds run them during `prepare:dsh`. The [Host smoke](../scripts/smoke-runtime.ts) creates DOCX, XLSX, and PPTX inputs with bundled Python, converts each through the real Office provider, and checks PDF output. Every assembled application, including directory and unsigned builds, repeats the payload and Host checks against ASAR. Archive integrity checks compare the complete archived descriptor with preparation, file bytes and membership in both archive and unpacked trees, recorded executable flags for archived files, and physical permissions for unpacked files. The Host smoke also obtains CLI paths from the loaded skill and runs capabilities and DOCX conversion with an empty PATH. Conversion failures stop packaging before a release record is written; macOS DMG/ZIP builds run these checks before notarization.
+1 -1
View File
@@ -252,7 +252,7 @@ Desktop 在本地打包工作区包,并通过目标捆绑的 Node 和 pnpm 安
[Electron 外壳文件清单](../scripts/electron-builder-config.mjs)从单独收集的 `resources/app.asar/node_modules` 依赖中排除已识别的 JavaScript/CSS/TypeScript source map 和 TypeScript 构建缓存。这些排除规则保留运行时代码、原生模块、WASM、数据文件(包括普通 `.map` 资源)、许可证和 notices;[外壳归档测试](../tests/shell-file-selection.spec.ts)检查保留文件的字节和排除项。
[Office 转换提供方](../../../packages/document/office-to-pdf/README.zh.md)携带目标已声明的原生引擎;kit 未声明匹配原生目标时携带 WASM 引擎。准备阶段在打包前拒绝缺少目标引擎的情况。完整 Office 依赖(CLI、JavaScript 库和选定引擎的可执行文件、数据、许可证及 notices)解包到 `resources/app.asar.unpacked/dsh/node_modules/` 下。Desktop Host 将引擎清单解析到这些物理目录,并向加载的技能提供独立 Node 和解包后 CLI 的绝对路径。Node 位于 `resources/runtime/primary-runtime/dependencies/node/bin/`;CLI 位于解包后的 `@deepseek-ai/libreoffice-kit/lib/cli.js`。macOS 上的原生辅助程序获得 [LibreOffice UNO 桥](https://github.com/LibreOffice/core/blob/master/sysui/desktop/macosx/hardened_runtime.xcent.in)所需的 JIT entitlement。`@deepseek-ai/dsh-experimental-cua-macos/native/bin` 下的 Computer Use helper 应用也整体解包到 `resources/app.asar.unpacked/dsh/node_modules/` 下,提供方打开该副本。
[Office 转换提供方](../../../packages/document/office-to-pdf/README.zh.md)携带目标已声明的原生引擎;kit 未声明匹配原生目标时携带 WASM 引擎。准备阶段在打包前拒绝缺少目标引擎的情况。完整 Office 依赖(CLI、JavaScript 库和选定引擎的可执行文件、数据、许可证及 notices)解包到 `resources/app.asar.unpacked/dsh/node_modules/` 下。Desktop Host 将引擎清单解析到这些物理目录。Desktop 壳在 [office-cli.ts](../src/office-cli.ts) 和 [main.ts](../src/main.ts) 中解析主运行时及解包后 CLI 的路径,再通过 `DSH_BUNDLED_PRIMARY_RUNTIME` 和 `DSH_BUNDLED_OFFICE_CLI` 传入 Host 环境。web-app 的 [cordis.patch.yml](../../../packages/bundle/web-app/cordis.patch.yml) 中的 profile 配置行使用这些环境变量,为 skill-office 配置独立 Node 和 CLI 的绝对路径。Node 位于 `resources/runtime/primary-runtime/dependencies/node/bin/`;CLI 位于解包后的 `@deepseek-ai/libreoffice-kit/lib/cli.js`。macOS 上的原生辅助程序获得 [LibreOffice UNO 桥](https://github.com/LibreOffice/core/blob/master/sysui/desktop/macosx/hardened_runtime.xcent.in)所需的 JIT entitlement。 `@deepseek-ai/dsh-experimental-cua-macos/native/bin` 下的 Computer Use helper 应用也整体解包到 `resources/app.asar.unpacked/dsh/node_modules/` 下,提供方打开该副本。
打包应用运行编译后的 JavaScript 和预生成的 Typert 元数据,不编译 TypeScript 插件。源码级调试导航和编辑器声明仍可从开发包中获取。[复制规则测试](../tests/runtime-file-policy.spec.ts)覆盖排除项和保留资源;[产物 smoke](../tests/fixtures/runtime-payload-smoke.mjs) 在 Host smoke 和最终清单验证之前,使用 Electron RunAsNode 执行。产物 smoke 解析搜索工具使用的 ripgrep 可执行文件,并验证文本搜索和文件枚举。Windows 签名构建在依赖签名后运行这些检查;其他构建在 `prepare:dsh` 中运行。[Host smoke](../scripts/smoke-runtime.ts) 使用捆绑的 Python 创建 DOCX、XLSX 和 PPTX 输入,通过真实 Office 提供方逐一转换并检查 PDF 输出。每个组装后的应用(包括目录包和未签名构建)都会针对 ASAR 重复产物和 Host 检查。归档完整性检查将归档内完整描述符与准备结果比对,并核对归档和解包目录中的文件内容与清单、归档内文件记录的执行标志,以及解包文件的物理权限。转换失败会在写入发布记录前终止打包;macOS DMG/ZIP 构建在公证前执行这些检查。
+1 -1
View File
@@ -76,7 +76,7 @@ export async function signMacOSRuntime(
const identifier = `${appId}.runtime.${createHash('sha256').update(path).digest('hex')}`
const isNode = path === 'dependencies/node/bin/node'
const needsJit = isNode
|| /^node_modules\/@deepseek-ai\/libreoffice-kit-darwin-(?:arm64|x64)\/bin\/libreoffice-kit$/u.test(path)
|| /^node_modules\/@deepseek-ai\/libreoffice-kit-darwin-(?:arm64|x64)\/bin\/[^/]+$/u.test(path)
const entitlementsFile = isNode && arch === 'x64'
? 'node-x64-entitlements.plist' : 'jit-entitlements.plist'
const entitlements = needsJit ? join(import.meta.dirname, entitlementsFile) : undefined
+4 -1
View File
@@ -25,6 +25,9 @@ import type { DesktopRuntimeDescriptor } from '../src/runtime-tree.ts'
export async function smokeDesktopRuntime(
root: string, node: string, runtime: DesktopRuntimeDescriptor, environment: NodeJS.ProcessEnv, resourcesRuntime: string,
): Promise<void> {
const office = desktopOfficeEnvironment(root, join(resourcesRuntime, 'primary-runtime'))
// Desktop starts without a Kit CLI; a release that cannot locate one is still broken.
if (office.kind !== 'ready') throw new Error('desktop runtime: LibreOffice Kit CLI is missing from the runtime', { cause: office.error })
const home = mkdtempSync(join(tmpdir(), 'dsh-desktop-smoke-'))
const profile = join(home, 'profiles', 'desktop')
const hostEnvironment = {
@@ -36,7 +39,7 @@ export async function smokeDesktopRuntime(
npm_config_store_dir: join(home, 'store'), npm_config_enable_global_virtual_store: 'false',
}
const host = new DesktopHostProcess(node, root, profile, undefined,
{ ...hostEnvironment, ...desktopOfficeEnvironment(root, join(resourcesRuntime, 'primary-runtime')) },
{ ...hostEnvironment, ...office.environment },
undefined,
{ pnpm: join(resourcesRuntime, 'primary-runtime', 'dependencies', 'pnpm', 'bin', 'pnpm.mjs'), nodeBin: join(resourcesRuntime, 'bin') })
let timer: ReturnType<typeof setTimeout> | undefined
+10 -4
View File
@@ -28,8 +28,8 @@ import {
} from 'electron'
import { resolveDesktopPaths } from './paths.ts'
import { DesktopProjectManager } from './project-manager.ts'
import { DesktopHostFatalError, DesktopHostProcess, DesktopHostUncleanExitError } from './host-process.ts'
import { desktopOfficeEnvironment } from './office-cli.ts'
import { DesktopHostFatalError, DesktopHostProcess, DesktopHostUncleanExitError, type DesktopHostReady } from './host-process.ts'
import { desktopOfficeEnvironment, writeOfficeKitDiagnostic } from './office-cli.ts'
import { DesktopPlatformView, PLATFORM_IPC, platformBounds } from './platform-view.ts'
import { installDesktopFileDrag } from './file-drag.ts'
import { installDesktopDirectoryPicker } from './directory-picker.ts'
@@ -525,10 +525,11 @@ async function main(): Promise<void> {
owner => isMandatory() || updateOverlays.input(owner).blocked)
const backend = new DesktopBackendController((onFailure) => {
const hostInspectPort = developmentHostInspectPort(development)
const office = desktopOfficeEnvironment(resources.dsh, primaryRuntime)
const host = new DesktopHostProcess(resources.node, resources.dsh, activeProject,
hostInspectPort, {
...hostEnvironment,
...desktopOfficeEnvironment(resources.dsh, primaryRuntime),
...office.environment,
DSH_CLIENT_VERSION: desktopClientVersion(),
}, onFailure,
resources, (next) => { platformView.setSession(next) }, (record) => {
@@ -539,7 +540,12 @@ async function main(): Promise<void> {
})
return {
start: async () => {
const ready = await host.start()
// Spawn in the same turn: awaiting the record first would let stop() finish before the child exists.
const diagnostic = office.kind === 'kit-unavailable'
? writeOfficeKitDiagnostic(app.getPath('logs'), resources.dsh, office.error, new Date()) : undefined
const started = host.start()
let ready: DesktopHostReady
try { ready = await started } finally { await diagnostic }
const cookie = await authenticateWebHost(ready.url)
hostCookie = cookie
hostUrl = ready.url
+75 -9
View File
@@ -1,14 +1,18 @@
/** Bundled Office locations that the Desktop profile's Office rows read from the Host environment. */
import { realpathSync } from 'node:fs'
import { mkdir, writeFile } from 'node:fs/promises'
import { createRequire } from 'node:module'
import { basename, dirname, isAbsolute, join, relative, sep } from 'node:path'
import { inspect } from 'node:util'
/**
* Locate the LibreOffice Kit CLI entry that a standalone Node child process can execute.
* @param runtimeDir - Prepared or ASAR-contained dsh runtime directory that carries the Desktop Host package.
* @param runtimeDir - Prepared or ASAR-contained dsh runtime directory that carries the Office skill package.
* @returns Absolute CLI entry; ASAR runtimes resolve only from their physical unpacked runtime.
* @throws When the unpacked runtime or required Kit package is missing; callers must stop Office startup.
* @throws When the unpacked runtime or Kit is missing; callers must not supply any Kit CLI path.
* Missing Kit errors use `MODULE_NOT_FOUND` with reinstall guidance; failed resolution retains its original error as `cause`.
* Other errors propagate unchanged.
*/
export function officeCliPath(runtimeDir: string): string {
const packed = basename(dirname(runtimeDir)) === 'app.asar'
@@ -16,9 +20,14 @@ export function officeCliPath(runtimeDir: string): string {
const directory = packed
? realpathSync(join(`${realpathSync(dirname(runtimeDir))}.unpacked`, basename(runtimeDir)))
: runtimeDir
// createRequire needs an anchor path, not an existing file: DesktopHost itself can remain in the archive.
const anchor = join(directory, 'node_modules', '@deepseek-ai', 'dsh-desktop-host', 'package.json')
const manifest = createRequire(anchor).resolve('@deepseek-ai/libreoffice-kit/package.json')
// createRequire needs an anchor path, not an existing file: skill-office itself can remain in the archive.
const anchor = join(directory, 'node_modules', '@deepseek-ai', 'dsh-skill-office', 'package.json')
let manifest: string
try { manifest = createRequire(anchor).resolve('@deepseek-ai/libreoffice-kit/package.json') } catch (error) {
if (!(error instanceof Error && 'code' in error && error.code === 'MODULE_NOT_FOUND')) throw error
throw Object.assign(new Error(`LibreOffice Kit is missing from the Desktop runtime: ${directory}. Reinstall Desktop.`,
{ cause: error }), { code: 'MODULE_NOT_FOUND' })
}
if (packed) {
const path = relative(directory, realpathSync(manifest))
if (isAbsolute(path) || path === '..' || path.startsWith(`..${sep}`)) {
@@ -29,13 +38,70 @@ export function officeCliPath(runtimeDir: string): string {
return join(dirname(manifest), 'lib', 'cli.js')
}
/**
* Office carrier entries for the Host environment. The explicit `undefined` CLI entry replaces any inherited value,
* so the Desktop profile's `skill-office` row receives `cli: false` instead of a stale or externally resolved Kit.
*/
export type DesktopOfficeEnvironment =
| {
readonly kind: 'ready'
readonly environment: { readonly DSH_BUNDLED_PRIMARY_RUNTIME: string; readonly DSH_BUNDLED_OFFICE_CLI: string }
}
| {
readonly kind: 'kit-unavailable'
readonly environment: { readonly DSH_BUNDLED_PRIMARY_RUNTIME: string; readonly DSH_BUNDLED_OFFICE_CLI: undefined }
/** The {@link officeCliPath} failure, unchanged. */
readonly error: unknown
}
/**
* Environment entries that point the Desktop profile's Office rows at bundled resources.
* Locating the Kit CLI only reads the filesystem; its failure leaves the primary runtime entry and the Office skills
* usable, so it is returned instead of thrown. Release verification must reject `kit-unavailable`.
* @param runtimeDir - dsh runtime directory passed to the Desktop Host.
* @param primaryRuntime - Bundled Python, Node.js, and pnpm payload whose sibling `office-skills` holds the skills.
* @returns `DSH_BUNDLED_PRIMARY_RUNTIME` and `DSH_BUNDLED_OFFICE_CLI`.
* @throws When the runtime does not carry LibreOffice Kit.
* @returns `ready` with both entries, or `kit-unavailable` with the {@link officeCliPath} error.
*/
export function desktopOfficeEnvironment(runtimeDir: string, primaryRuntime: string): NodeJS.ProcessEnv {
return { DSH_BUNDLED_PRIMARY_RUNTIME: primaryRuntime, DSH_BUNDLED_OFFICE_CLI: officeCliPath(runtimeDir) }
export function desktopOfficeEnvironment(runtimeDir: string, primaryRuntime: string): DesktopOfficeEnvironment {
let cli: string
try { cli = officeCliPath(runtimeDir) } catch (error) {
return { kind: 'kit-unavailable', environment: { DSH_BUNDLED_PRIMARY_RUNTIME: primaryRuntime, DSH_BUNDLED_OFFICE_CLI: undefined }, error }
}
return { kind: 'ready', environment: { DSH_BUNDLED_PRIMARY_RUNTIME: primaryRuntime, DSH_BUNDLED_OFFICE_CLI: cli } }
}
/** Diagnostic file that {@link writeOfficeKitDiagnostic} replaces in the application logs directory. */
export const OFFICE_KIT_DIAGNOSTIC = 'office-kit-unavailable.log'
/**
* Record a Kit CLI location failure in the application logs directory, replacing the previous record.
* A write failure is reported to `console.error` with the original failure; the returned promise never rejects.
* @param directory - Application logs directory.
* @param runtimeDir - dsh runtime directory whose Kit could not be located.
* @param error - The `kit-unavailable` error.
* @param time - Time of the Host start that omitted the CLI.
* @returns The written path, or `undefined` when writing failed.
*/
export async function writeOfficeKitDiagnostic(
directory: string, runtimeDir: string, error: unknown, time: Date,
): Promise<string | undefined> {
const path = join(directory, OFFICE_KIT_DIAGNOSTIC)
try {
const report = [
`time: ${time.toISOString()}`,
`runtime: ${runtimeDir}`,
'impact: the LibreOffice Kit CLI is unavailable to Office skills; Office skills and the Desktop Host continue without CLI paths.',
'action: reinstall Desktop to restore the bundled LibreOffice Kit.',
'',
'--- error ---',
inspect(error, { depth: 6, breakLength: 120 }),
'',
].join('\n')
await mkdir(directory, { recursive: true, mode: 0o700 })
await writeFile(path, report, { mode: 0o600 })
return path
} catch (failure) {
console.error('dsh desktop: LibreOffice Kit diagnostic could not be written; Office skills continue without CLI paths', path, failure, error)
return undefined
}
}
+2 -2
View File
@@ -17,9 +17,9 @@ export class DesktopHostProcess extends Host {
fixture.taskQueries.push({ at: Date.now(), action, active: result })
return result
}
constructor(node, runtime, project, _inspectPort, environment, onFailure, primaryRuntime, packageManager) {
constructor(node, runtime, project, _inspectPort, environment, onFailure, packageManager, onPlatformSession, onPluginOperation) {
// Port zero lets the OS allocate the development inspector; no fixed listener is acquired.
super(node, runtime, project, 0, environment, onFailure, primaryRuntime, packageManager)
super(node, runtime, project, 0, environment, onFailure, packageManager, onPlatformSession, onPluginOperation)
fixture.host = this
}
async start() {
+19 -3
View File
@@ -117,9 +117,10 @@ it.each(['arm64', 'x64'] as const)('selects %s Node entitlements and keeps helpe
mkdirSync(join(path, 'dependencies/node/bin'), { recursive: true })
const node = join(path, 'dependencies/node/bin/node')
const addon = join(path, 'addon.node')
const helpers = ['arm64', 'x64'].map(helperArch => join(path, 'node_modules/@deepseek-ai', `libreoffice-kit-darwin-${helperArch}`, 'bin/libreoffice-kit'))
for (const helper of helpers) mkdirSync(join(helper, '..'), { recursive: true })
for (const file of [node, addon, ...helpers]) writeFileSync(file, Buffer.from('cffaedfe00000000', 'hex'))
const helpers = ['arm64', 'x64'].map(helperArch => join(path, 'node_modules/@deepseek-ai', `libreoffice-kit-darwin-${helperArch}`, 'bin/dsoffice'))
const library = join(path, 'node_modules/@deepseek-ai/libreoffice-kit-darwin-arm64/lib/libmergedlo.dylib')
for (const file of [...helpers, library]) mkdirSync(join(file, '..'), { recursive: true })
for (const file of [node, addon, library, ...helpers]) writeFileSync(file, Buffer.from('cffaedfe00000000', 'hex'))
await signMacOSRuntime(path, 'com.example.app', identity, arch)
const nodePlist = join(import.meta.dirname, '../scripts', arch === 'x64' ? 'node-x64-entitlements.plist' : 'jit-entitlements.plist')
expect(signMacOSRuntimeCode).toHaveBeenCalledWith(node, expect.any(String), identity, nodePlist)
@@ -131,4 +132,19 @@ it.each(['arm64', 'x64'] as const)('selects %s Node entitlements and keeps helpe
join(import.meta.dirname, '../scripts/jit-entitlements.plist'))
}
expect(signMacOSRuntimeCode).toHaveBeenCalledWith(addon, expect.any(String), identity, undefined)
expect(signMacOSRuntimeCode).toHaveBeenCalledWith(library, expect.any(String), identity, undefined)
})
it('grants JIT to every Mach-O executable directly under a darwin Kit engine bin directory regardless of its name', async () => {
const path = root()
const helpers = ['dsoffice', 'renamed-helper'].map(name => join(path, 'node_modules/@deepseek-ai/libreoffice-kit-darwin-arm64/bin', name))
const nested = join(path, 'node_modules/@deepseek-ai/libreoffice-kit-darwin-arm64/bin/nested/tool')
for (const file of [...helpers, nested]) {
mkdirSync(join(file, '..'), { recursive: true })
writeFileSync(file, Buffer.from('cffaedfe00000000', 'hex'))
}
await signMacOSRuntime(path, 'com.example.app', identity, 'arm64')
const jit = join(import.meta.dirname, '../scripts/jit-entitlements.plist')
for (const file of helpers) expect(signMacOSRuntimeCode).toHaveBeenCalledWith(file, expect.any(String), identity, jit)
expect(signMacOSRuntimeCode).toHaveBeenCalledWith(nested, expect.any(String), identity, undefined)
})
+106 -5
View File
@@ -15,6 +15,7 @@ import { en, zh } from '../src/locale.ts'
import { DesktopUpdatePreparationError } from '../src/update-error.ts'
import { writeCrashReport } from '../src/crash-report.ts'
import { runOperationReportJob } from '../src/operation-report-job.ts'
import { OFFICE_KIT_DIAGNOSTIC, desktopOfficeEnvironment, writeOfficeKitDiagnostic, type DesktopOfficeEnvironment } from '../src/office-cli.ts'
type InvokeEvent = { sender?: unknown; senderFrame: { url: string } }
type InvokeHandler = (event: InvokeEvent, ...args: unknown[]) => unknown
@@ -347,11 +348,17 @@ vi.mock('../src/login-shell-environment.ts', async importOriginal => ({
readDesktopLoginShellEnvironment: harness.loginShell,
}))
vi.mock('../src/runtime-tree.ts', () => ({ readDesktopRuntime: () => ({ release: { version: '1.0.0' } }) }))
vi.mock('../src/office-cli.ts', () => ({
desktopOfficeEnvironment: (runtime: string, primaryRuntime: string) => ({
DSH_BUNDLED_PRIMARY_RUNTIME: primaryRuntime, DSH_BUNDLED_OFFICE_CLI: join(runtime, 'office-cli.js'),
}),
}))
// Most cases need a located Kit without a runtime tree; Office cases restore the real locator over fixtures.
vi.mock('../src/office-cli.ts', async (importOriginal) => {
const actual = await importOriginal<typeof import('../src/office-cli.ts')>()
return {
...actual,
desktopOfficeEnvironment: vi.fn((runtime: string, primaryRuntime: string): DesktopOfficeEnvironment => ({ kind: 'ready', environment: {
DSH_BUNDLED_PRIMARY_RUNTIME: primaryRuntime, DSH_BUNDLED_OFFICE_CLI: join(runtime, 'office-cli.js'),
} })),
writeOfficeKitDiagnostic: vi.fn(actual.writeOfficeKitDiagnostic),
}
})
vi.mock('../src/paths.ts', () => ({ resolveDesktopPaths: () => ({ profile: 'desktop-test-profile' }) }))
vi.mock('../src/project-manager.ts', () => ({
DesktopProjectManager: class {
@@ -731,6 +738,100 @@ describe('desktop main startup', () => {
expect(await invoke(DESKTOP_IPC.updatesStatus, 'app')).toMatchObject({ configurationError: 'channel' })
})
/** Restore the real Kit locator over a development runtime path whose ASAR sibling has no unpacked runtime. */
async function missingKitRuntime(): Promise<string> {
const root = mkdtempSync(join(tmpdir(), 'dsh-main-office-'))
onTestFinished(() => { rmSync(root, { recursive: true, force: true }) })
writeFileSync(join(root, 'app.asar'), 'archive fixture')
const actual = await vi.importActual<typeof import('../src/office-cli.ts')>('../src/office-cli.ts')
vi.mocked(desktopOfficeEnvironment).mockImplementationOnce(actual.desktopOfficeEnvironment)
harness.app.isPackaged = false
vi.stubEnv('DSH_DESKTOP_DSH_DIR', join(root, 'app.asar', 'dsh'))
// The login shell environment carries an earlier CLI value that the Office entries must replace.
vi.stubEnv('DSH_BUNDLED_OFFICE_CLI', join(root, 'stale-cli.js'))
return join(root, 'app.asar', 'dsh')
}
const launched = () => harness.analytics.mock.calls.some(([event]) => (event as { eventName: string }).eventName === 'desktop_app_launch')
it('starts the Host without a Kit CLI and records why when the bundled Kit cannot be located', async () => {
const runtime = await missingKitRuntime()
const host = await readyForUpdate()
await vi.waitFor(() => { expect(launched()).toBe(true) })
expect(host.environment).toHaveProperty('DSH_BUNDLED_PRIMARY_RUNTIME', 'test-primary-runtime')
expect(Object.hasOwn(host.environment!, 'DSH_BUNDLED_OFFICE_CLI')).toBe(true)
expect(host.environment!.DSH_BUNDLED_OFFICE_CLI).toBeUndefined()
const report = readFileSync(join(harness.app.getPath('logs'), OFFICE_KIT_DIAGNOSTIC), 'utf8')
expect(report).toContain(`runtime: ${runtime}`)
expect(report).toContain('Office skills and the Desktop Host continue without CLI paths')
expect(report).toContain('reinstall Desktop')
expect(report).toContain('ENOENT')
expect(writeCrashReport).not.toHaveBeenCalled()
expect(harness.dialog.showMessageBox).not.toHaveBeenCalledWith(expect.objectContaining({ title: en.startupFailed }))
})
it('starts the Host when the Kit diagnostic cannot be written', async () => {
await missingKitRuntime()
writeFileSync(harness.app.getPath('logs'), 'not a directory')
await readyForUpdate()
await vi.waitFor(() => { expect(launched()).toBe(true) })
expect(console.error).toHaveBeenCalledWith(expect.stringContaining('LibreOffice Kit diagnostic could not be written'),
join(harness.app.getPath('logs'), OFFICE_KIT_DIAGNOSTIC), expect.any(Error), expect.objectContaining({ code: 'ENOENT' }))
expect(writeCrashReport).not.toHaveBeenCalled()
expect(harness.dialog.showMessageBox).not.toHaveBeenCalledWith(expect.objectContaining({ title: en.startupFailed }))
})
it('settles Host readiness only after the pending Kit diagnostic', async () => {
await missingKitRuntime()
const diagnostic = Promise.withResolvers<undefined>()
vi.mocked(writeOfficeKitDiagnostic).mockImplementationOnce(() => diagnostic.promise)
const host = await readyForUpdate()
await vi.advanceTimersByTimeAsync(0)
expect(launched()).toBe(false)
diagnostic.resolve(undefined)
await vi.waitFor(() => { expect(launched()).toBe(true) })
expect(host.stop).not.toHaveBeenCalled()
})
it('reports a Host startup failure as fatal only after the pending Kit diagnostic', async () => {
await missingKitRuntime()
const diagnostic = Promise.withResolvers<undefined>()
vi.mocked(writeOfficeKitDiagnostic).mockImplementationOnce(() => diagnostic.promise)
await import('../src/main.ts')
await harness.preparing.promise
harness.prepared.resolve()
await harness.hostStarted.promise
const host = harness.hosts[0]!
host.ready.reject(new Error('core services failed'))
await vi.advanceTimersByTimeAsync(0)
expect(host.stop).not.toHaveBeenCalled()
expect(harness.dialog.showMessageBox).not.toHaveBeenCalled()
diagnostic.resolve(undefined)
await host.stopping.promise
host.exited.resolve()
await harness.dialogShown.promise
expect((harness.dialog.showMessageBox.mock.calls[0]![0] as MessageBoxOptions).detail).toContain('core services failed')
})
it('stops the spawned Host when quit arrives during the Kit diagnostic', async () => {
await missingKitRuntime()
const diagnostic = Promise.withResolvers<undefined>()
vi.mocked(writeOfficeKitDiagnostic).mockImplementationOnce(() => diagnostic.promise)
await import('../src/main.ts')
await harness.preparing.promise
harness.prepared.resolve()
await harness.hostStarted.promise
const host = harness.hosts[0]!
harness.app.quit()
await host.stopping.promise
host.exited.resolve()
diagnostic.resolve(undefined)
await harness.quitCompleted.promise
expect(host.stop).toHaveBeenCalledOnce()
expect(harness.hosts).toHaveLength(1)
expect(harness.dialog.showMessageBox).not.toHaveBeenCalled()
})
it('starts the Host with authentication popups disabled when local settings are invalid', async () => {
configureTestAuthPopup(false)
const path = join(harness.app.getPath('userData'), 'desktop', 'settings.json')
+152 -20
View File
@@ -1,12 +1,19 @@
import { mkdirSync, mkdtempSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs'
import { execFileSync } from 'node:child_process'
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { afterEach, expect, it } from 'vitest'
import { desktopOfficeEnvironment, officeCliPath } from '../src/office-cli.ts'
import { afterEach, expect, it, vi } from 'vitest'
import { smokeDesktopRuntime } from '../scripts/smoke-runtime.ts'
import { desktopNodeEnvironment } from '../src/node-environment.ts'
import { OFFICE_KIT_DIAGNOSTIC, desktopOfficeEnvironment, officeCliPath, writeOfficeKitDiagnostic } from '../src/office-cli.ts'
import type { DesktopRuntimeDescriptor } from '../src/runtime-tree.ts'
const roots: string[] = []
afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) })
afterEach(() => {
vi.restoreAllMocks()
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
})
function temporaryRoot(): string {
const root = realpathSync(mkdtempSync(join(tmpdir(), 'desktop-office-cli-')))
@@ -23,10 +30,22 @@ function installPackage(directory: string, name: string): string {
}
function runtime(directory: string): string {
installPackage(directory, 'dsh-desktop-host')
installPackage(directory, 'dsh-skill-office')
return join(installPackage(directory, 'libreoffice-kit'), 'lib', 'cli.js')
}
// pnpm's NODE_PATH exposes workspace packages; missing-package fixtures need plain Node's local search only.
function resolveWithoutGlobalPackages(directory: string): unknown {
return JSON.parse(execFileSync(process.execPath, ['--no-global-search-paths', '--input-type=module', '-e', `
import { officeCliPath } from ${JSON.stringify(new URL('../src/office-cli.ts', import.meta.url).href)}
try {
console.log(JSON.stringify(officeCliPath(process.argv[1])))
} catch (error) {
console.log(JSON.stringify(error, ['message', 'code', 'cause', 'requireStack']))
}
`, directory], { encoding: 'utf8' }))
}
function archiveRuntime(runtimeName = 'dsh') {
const root = temporaryRoot()
const resources = join(root, '应用 Resources')
@@ -43,14 +62,58 @@ it('returns the CLI entry inside a prepared runtime', () => {
expect(officeCliPath(directory)).toBe(cli)
})
it('preserves default DesktopHost dependency resolution outside ASAR', () => {
it('preserves default skill-office dependency resolution outside ASAR', () => {
const directory = join(temporaryRoot(), 'dsh')
runtime(directory)
const host = join(directory, 'node_modules', '@deepseek-ai', 'dsh-desktop-host')
const kit = installPackage(host, 'libreoffice-kit')
const skill = join(directory, 'node_modules', '@deepseek-ai', 'dsh-skill-office')
const kit = installPackage(skill, 'libreoffice-kit')
expect(officeCliPath(directory)).toBe(join(kit, 'lib', 'cli.js'))
})
it('rejects kit available only through the Desktop Host package in a prepared runtime', () => {
const directory = join(temporaryRoot(), 'dsh')
const host = installPackage(directory, 'dsh-desktop-host')
const kit = installPackage(host, 'libreoffice-kit')
expect(createRequire(join(host, 'package.json')).resolve('@deepseek-ai/libreoffice-kit/package.json'))
.toBe(join(kit, 'package.json'))
expect(resolveWithoutGlobalPackages(directory)).toEqual(expect.objectContaining({ code: 'MODULE_NOT_FOUND' }))
})
it('reports a missing kit with reinstall guidance and the original resolution error', () => {
const directory = join(temporaryRoot(), 'dsh')
mkdirSync(directory)
const error = resolveWithoutGlobalPackages(directory)
expect(error).toMatchObject({
message: `LibreOffice Kit is missing from the Desktop runtime: ${directory}. Reinstall Desktop.`,
code: 'MODULE_NOT_FOUND',
cause: {
code: 'MODULE_NOT_FOUND',
requireStack: [join(directory, 'node_modules', '@deepseek-ai', 'dsh-skill-office', 'package.json')],
},
})
expect(error).toHaveProperty('cause.message', expect.stringContaining("Cannot find module '@deepseek-ai/libreoffice-kit/package.json'"))
})
it('preserves resolution errors other than a missing module', () => {
const directory = join(temporaryRoot(), 'dsh')
const kit = installPackage(directory, 'libreoffice-kit')
writeFileSync(join(kit, 'package.json'), JSON.stringify({
name: '@deepseek-ai/libreoffice-kit', exports: { '.': './lib/cli.js' },
}))
const error = resolveWithoutGlobalPackages(directory)
expect(error).toEqual(expect.objectContaining({ code: 'ERR_PACKAGE_PATH_NOT_EXPORTED' }))
expect(error).not.toHaveProperty('cause')
})
it('rejects kit available only through the Desktop Host package', () => {
const f = archiveRuntime()
const host = installPackage(f.unpacked, 'dsh-desktop-host')
const kit = installPackage(host, 'libreoffice-kit')
expect(createRequire(join(host, 'package.json')).resolve('@deepseek-ai/libreoffice-kit/package.json'))
.toBe(join(kit, 'package.json'))
expect(() => officeCliPath(f.directory)).toThrow(expect.objectContaining({ code: 'MODULE_NOT_FOUND' }))
})
it('accepts a prepared runtime directory alias', () => {
const root = temporaryRoot()
const directory = join(root, '准备 runtime')
@@ -66,10 +129,10 @@ it('resolves an unpacked CLI beside an ASAR file under plain Node', () => {
// An ancestor package must not substitute for the shipped unpacked copy.
installPackage(f.root, 'libreoffice-kit')
expect(statSync(f.archive).isFile()).toBe(true)
expect(desktopOfficeEnvironment(f.directory, '/resources/runtime/primary-runtime')).toEqual({
expect(desktopOfficeEnvironment(f.directory, '/resources/runtime/primary-runtime')).toEqual({ kind: 'ready', environment: {
DSH_BUNDLED_PRIMARY_RUNTIME: '/resources/runtime/primary-runtime',
DSH_BUNDLED_OFFICE_CLI: cli,
})
} })
expect(statSync(cli).isFile()).toBe(true)
})
@@ -96,13 +159,13 @@ it('accepts an unpacked runtime directory alias', () => {
expect(officeCliPath(f.directory)).toBe(cli)
})
it('resolves kit from the physical DesktopHost package through a directory alias', () => {
it('resolves kit from the physical skill-office package through a directory alias', () => {
const f = archiveRuntime()
const host = installPackage(join(f.unpacked, 'packages'), 'dsh-desktop-host')
const kit = installPackage(host, 'libreoffice-kit')
const alias = join(f.unpacked, 'node_modules', '@deepseek-ai', 'dsh-desktop-host')
const skill = installPackage(join(f.unpacked, 'packages'), 'dsh-skill-office')
const kit = installPackage(skill, 'libreoffice-kit')
const alias = join(f.unpacked, 'node_modules', '@deepseek-ai', 'dsh-skill-office')
mkdirSync(dirname(alias), { recursive: true })
symlinkSync(host, alias, process.platform === 'win32' ? 'junction' : 'dir')
symlinkSync(skill, alias, process.platform === 'win32' ? 'junction' : 'dir')
expect(officeCliPath(f.directory)).toBe(join(kit, 'lib', 'cli.js'))
})
@@ -112,7 +175,7 @@ it('fails when the unpacked runtime is missing', () => {
expect(() => officeCliPath(f.directory)).toThrow(expect.objectContaining({ code: 'ENOENT' }))
})
it('resolves the unpacked kit when DesktopHost itself remains in the archive', () => {
it('resolves the unpacked kit when skill-office itself remains in the archive', () => {
const f = archiveRuntime()
const kit = installPackage(f.unpacked, 'libreoffice-kit')
expect(officeCliPath(f.directory)).toBe(join(kit, 'lib', 'cli.js'))
@@ -120,24 +183,93 @@ it('resolves the unpacked kit when DesktopHost itself remains in the archive', (
it('fails when the unpacked kit package is missing', () => {
const f = archiveRuntime()
installPackage(f.unpacked, 'dsh-desktop-host')
installPackage(f.unpacked, 'dsh-skill-office')
expect(() => officeCliPath(f.directory)).toThrow(expect.objectContaining({ code: 'MODULE_NOT_FOUND' }))
})
it('rejects an ancestor kit package when the unpacked kit is missing', () => {
const f = archiveRuntime()
const host = installPackage(f.unpacked, 'dsh-desktop-host')
const skill = installPackage(f.unpacked, 'dsh-skill-office')
const external = installPackage(f.root, 'libreoffice-kit')
expect(createRequire(join(host, 'package.json')).resolve('@deepseek-ai/libreoffice-kit/package.json'))
expect(createRequire(join(skill, 'package.json')).resolve('@deepseek-ai/libreoffice-kit/package.json'))
.toBe(join(external, 'package.json'))
expect(() => officeCliPath(f.directory)).toThrow(expect.objectContaining({ code: 'MODULE_NOT_FOUND' }))
})
it('rejects a kit directory alias outside the unpacked runtime', () => {
const f = archiveRuntime()
installPackage(f.unpacked, 'dsh-desktop-host')
installPackage(f.unpacked, 'dsh-skill-office')
const external = installPackage(`${f.unpacked}-external`, 'libreoffice-kit')
const alias = join(f.unpacked, 'node_modules', '@deepseek-ai', 'libreoffice-kit')
symlinkSync(external, alias, process.platform === 'win32' ? 'junction' : 'dir')
expect(() => officeCliPath(f.directory)).toThrow(expect.objectContaining({ code: 'MODULE_NOT_FOUND' }))
})
it.each([
['the unpacked runtime is missing', 'ENOENT', () => archiveRuntime()],
['the unpacked Kit is missing', 'MODULE_NOT_FOUND', () => {
const f = archiveRuntime()
installPackage(f.unpacked, 'dsh-skill-office')
return f
}],
['the Kit resolves outside the unpacked runtime', 'MODULE_NOT_FOUND', () => {
const f = archiveRuntime()
installPackage(f.unpacked, 'dsh-skill-office')
const alias = join(f.unpacked, 'node_modules', '@deepseek-ai', 'libreoffice-kit')
symlinkSync(installPackage(`${f.unpacked}-external`, 'libreoffice-kit'), alias, process.platform === 'win32' ? 'junction' : 'dir')
return f
}],
])('keeps the primary runtime and replaces an inherited CLI when %s', (_label, code, fixture) => {
const f = fixture()
const office = desktopOfficeEnvironment(f.directory, '/resources/runtime/primary-runtime')
expect(office).toMatchObject({ kind: 'kit-unavailable', error: { code } })
expect(office.environment).toStrictEqual({ DSH_BUNDLED_PRIMARY_RUNTIME: '/resources/runtime/primary-runtime', DSH_BUNDLED_OFFICE_CLI: undefined })
// The Host child receives its environment through the same Node-mode projection as Desktop.
const inherited: NodeJS.ProcessEnv = { ...process.env, DSH_BUNDLED_OFFICE_CLI: join(f.root, 'stale-cli.js') }
const child = execFileSync(process.execPath, ['-e', 'console.log(JSON.stringify([process.env.DSH_BUNDLED_PRIMARY_RUNTIME, "DSH_BUNDLED_OFFICE_CLI" in process.env]))'], {
encoding: 'utf8',
env: desktopNodeEnvironment(process.execPath, undefined, { ...inherited, ...office.environment }),
})
expect(JSON.parse(child)).toEqual(['/resources/runtime/primary-runtime', false])
})
it('records the Kit failure, its impact, and the reinstall action', async () => {
const f = archiveRuntime()
const office = desktopOfficeEnvironment(f.directory, '/primary-runtime')
if (office.kind !== 'kit-unavailable') throw new Error('fixture located a Kit')
const logs = join(f.root, 'logs')
const path = await writeOfficeKitDiagnostic(logs, f.directory, office.error, new Date('2026-01-02T03:04:05.006Z'))
expect(path).toBe(join(logs, OFFICE_KIT_DIAGNOSTIC))
const report = readFileSync(path!, 'utf8')
expect(report).toContain('time: 2026-01-02T03:04:05.006Z')
expect(report).toContain(`runtime: ${f.directory}`)
expect(report).toContain('impact: the LibreOffice Kit CLI is unavailable to Office skills; Office skills and the Desktop Host continue without CLI paths.')
expect(report).toContain('action: reinstall Desktop to restore the bundled LibreOffice Kit.')
expect(report).toContain('ENOENT')
})
it('reports a failed Kit diagnostic write to stderr without rejecting', async () => {
const root = temporaryRoot()
const logs = join(root, 'logs')
writeFileSync(logs, 'not a directory')
const error = vi.spyOn(console, 'error').mockImplementation(() => {})
const original = new Error('kit missing')
await expect(writeOfficeKitDiagnostic(logs, join(root, 'dsh'), original, new Date())).resolves.toBeUndefined()
expect(error).toHaveBeenCalledOnce()
const call: readonly unknown[] = error.mock.calls[0]!
const [summary, path, failure, reported] = call
expect(summary).toContain('LibreOffice Kit diagnostic could not be written')
expect(path).toBe(join(logs, OFFICE_KIT_DIAGNOSTIC))
expect(failure).toBeInstanceOf(Error)
expect(typeof (failure as NodeJS.ErrnoException).code).toBe('string')
expect(reported).toBe(original)
})
it('rejects a runtime smoke without a Kit', async () => {
const f = archiveRuntime()
const smoke = smokeDesktopRuntime(f.directory, process.execPath, {} as DesktopRuntimeDescriptor, {}, join(f.root, 'resources'))
const error = await smoke.then(() => undefined, (reason: unknown) => reason)
expect(error).toBeInstanceOf(Error)
expect((error as Error).message).toBe('desktop runtime: LibreOffice Kit CLI is missing from the runtime')
expect(((error as Error).cause as NodeJS.ErrnoException).code).toBe('ENOENT')
})
+56 -2
View File
@@ -1,6 +1,6 @@
/** Built Desktop Host startup with incomplete or unusable Office resources; run after the repository build. */
import { cpSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
@@ -10,11 +10,47 @@ import { builtHost, prepareDesktopHome, repository } from './harness.ts'
afterEach(() => { vi.unstubAllEnvs() })
const SKILL_PROBE = 'desktop-office-skill-probe'
const OFFICE_SKILLS = ['office', 'office-docx', 'office-export-pdf', 'office-pptx', 'office-recalculate', 'office-render', 'office-xlsx']
/** Add a test-only profile bundle whose route reports the Host registry's Office skills through the real Loader. */
function installSkillProbe(root: string, profile: string): void {
const plugin = join(profile, 'node_modules', SKILL_PROBE)
mkdirSync(plugin, { recursive: true })
writeFileSync(join(plugin, 'package.json'), JSON.stringify({
name: SKILL_PROBE, version: '1.0.0', type: 'module', exports: './index.js', dsh: { bundle: { patch: './bundle.yml' } },
}))
writeFileSync(join(plugin, 'index.js'), `
export function apply(ctx) {
ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: '/${SKILL_PROBE}', async handler(_request, response) {
try {
const names = (await ctx.skills.list()).map(skill => skill.name).filter(name => name.startsWith('office'))
const content = {}
for (const name of ['office-docx', 'office-export-pdf']) content[name] = (await ctx.skills.get(name))?.content ?? null
response.end(JSON.stringify({ names, content }))
} catch (error) {
response.statusCode = 500
response.end(String(error?.stack ?? error))
}
} }))
}
`)
writeFileSync(join(plugin, 'bundle.yml'), `- insert:\n - id: ${SKILL_PROBE}\n name: ${SKILL_PROBE}\n inject: [webServer, skills]\n`)
const manifest = JSON.parse(readFileSync(join(profile, 'package.json'), 'utf8')) as {
dependencies?: Record<string, string>
dsh: { profile: { bundles: string[] } }
}
manifest.dependencies = { ...manifest.dependencies, [SKILL_PROBE]: `file:${join(root, SKILL_PROBE)}` }
manifest.dsh.profile.bundles.push(SKILL_PROBE)
writeFileSync(join(profile, 'package.json'), JSON.stringify(manifest))
}
describe.skipIf(!existsSync(builtHost))('built Desktop Host Office resources', () => {
it.each([
['one missing Office skill file', 'missing-skill'],
['an empty runtime directory', 'empty-runtime'],
['an unusable primary runtime path', 'relative-source'],
['no locatable LibreOffice Kit', 'missing-kit'],
] as const)('starts the Host with %s', async (_label, mode) => {
const root = mkdtempSync(join(tmpdir(), 'dsh-desktop-office-'))
let host: DesktopHostProcess | undefined
@@ -28,14 +64,32 @@ describe.skipIf(!existsSync(builtHost))('built Desktop Host Office resources', (
if (mode === 'missing-skill') rmSync(join(runtime, 'office-skills', 'office-docx', 'SKILL.md'))
}
writeFileSync(join(paths.profile, 'cordis.patch.yml'), '- id: webserver\n config:\n host: 127.0.0.1\n port: 0\n')
if (mode === 'missing-kit') installSkillProbe(root, paths.profile)
const primaryRuntime = mode === 'relative-source' ? 'runtime/primary-runtime' : join(runtime, 'primary-runtime')
// An ASAR runtime path without its unpacked sibling locates no Kit; the Host itself still runs from the project.
writeFileSync(join(root, 'app.asar'), 'archive fixture')
const office = desktopOfficeEnvironment(mode === 'missing-kit' ? join(root, 'app.asar', 'dsh') : project, primaryRuntime)
expect(office.kind).toBe(mode === 'missing-kit' ? 'kit-unavailable' : 'ready')
const inherited: NodeJS.ProcessEnv = { ...process.env, DSH_BUNDLED_OFFICE_CLI: join(root, 'stale-cli.js') }
host = new DesktopHostProcess(process.execPath, project, paths.profile, undefined,
{ ...process.env, ...desktopOfficeEnvironment(project, primaryRuntime) }, (error) => { failures.push(error) })
{ ...inherited, ...office.environment }, (error) => { failures.push(error) })
const { url } = await host.start()
// The authenticated URL exchanges its token for a session cookie by redirecting.
const response = await fetch(url, { redirect: 'manual' })
await response.body?.cancel()
expect(response.status).toBe(303)
if (mode === 'missing-kit') {
const cookie = response.headers.getSetCookie().map(value => value.split(';')[0]).join('; ')
const probe = await fetch(new URL(`/${SKILL_PROBE}`, url), { headers: { cookie } })
const body = await probe.text()
expect(probe.status, body).toBe(200)
const skills = JSON.parse(body) as { names: string[]; content: Record<string, string | null> }
expect(skills.names.toSorted()).toEqual(OFFICE_SKILLS)
expect(skills.content['office-docx']).toEqual(expect.any(String))
expect(skills.content['office-docx']).not.toContain('LibreOffice Kit is disabled')
expect(skills.content['office-export-pdf']).toMatch(/\n\nLibreOffice Kit is disabled in this deployment\.$/u)
expect(skills.content['office-export-pdf']).not.toContain('libreofficeKit')
}
expect(failures).toEqual([])
} finally {
await host?.stop()
@@ -30,7 +30,7 @@ it('omits development artifacts while preserving executable modules, assets and
'@mixmark-io/domino/.yarn/plugins/@yarnpkg/plugin-version.cjs',
'@mixmark-io/domino/.yarn/versions/release.yml',
'@deepseek-ai/libreoffice-kit-wasm/assets/soffice.data',
'@deepseek-ai/libreoffice-kit-darwin-arm64/bin/libreoffice-kit',
'@deepseek-ai/libreoffice-kit-darwin-arm64/bin/dsoffice',
'.modules.yaml', '.pnpm-workspace-state-v1.json', '.bin/tool', '.pnpm/cache',
]
const retained = [
@@ -50,7 +50,7 @@ it('omits development artifacts while preserving executable modules, assets and
'@mixmark-io/domino/LICENSE', '@mixmark-io/domino/package.json',
'example/.yarn/runtime.cjs', '@mixmark-io/domino/.yarn-data.json',
'@img/sharp-win32-x64/lib/libvips-42.dll',
'@deepseek-ai/libreoffice-kit-win32-x64/bin/libreoffice-kit.exe',
'@deepseek-ai/libreoffice-kit-win32-x64/bin/dsoffice.exe',
'@deepseek-ai/libreoffice-kit-win32-x64/sources/core-source.json',
'@deepseek-ai/libreoffice-kit-win32-x64/licenses/MPL-2.0.txt',
]
+1 -1
View File
@@ -91,7 +91,7 @@ describe.skipIf(!existsSync(builtHost))('built Desktop welcome flow', () => {
const restart = async (): Promise<void> => {
await host?.stop()
host = new DesktopHostProcess(process.execPath, project, paths.profile, undefined,
{ ...process.env, ...desktopOfficeEnvironment(project, join(root, 'runtime', 'primary-runtime')) })
{ ...process.env, ...desktopOfficeEnvironment(project, join(root, 'runtime', 'primary-runtime')).environment })
const { url } = await host.start()
hostOrigin = new URL(url).origin
backend = connectDesktopWelcome(url, await authenticateWebHost(url))
@@ -266,7 +266,7 @@ it.each([false, true])('unpacks platform ripgrep executables with external sourc
it.each([false, true])('keeps the complete Office engine outside ASAR with external source=%s', async (external) => {
const input = await fixture(external)
const engine = join('node_modules', '@deepseek-ai', 'libreoffice-kit-win32-x64')
const files = ['package.json', 'prebuilds.json', 'bin/libreoffice-kit', 'program/registry/main.xcd']
const files = ['package.json', 'prebuilds.json', 'bin/dsoffice', 'program/registry/main.xcd']
for (const file of files) {
const path = join(input.source, engine, file)
await mkdir(dirname(path), { recursive: true })
+1
View File
@@ -5086,6 +5086,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them.
| `@deepseek-ai/dsh-llm-mock-server` | — | [`packages/test-support/llm-mock-server/src/index.ts`](../packages/test-support/llm-mock-server/src/index.ts) |
| `@deepseek-ai/dsh-loader-smoke` | — | [`packages/test-support/loader-smoke/src/index.ts`](../packages/test-support/loader-smoke/src/index.ts) |
| `@deepseek-ai/dsh-native-command` | — | [`packages/util/native-command/src/index.ts`](../packages/util/native-command/src/index.ts) |
| `@deepseek-ai/dsh-office-engine-resolver` | — | [`packages/document/office-engine-resolver/src/index.ts`](../packages/document/office-engine-resolver/src/index.ts) |
| `@deepseek-ai/dsh-official-bundles` | — | [`packages/util/official-bundles/src/index.ts`](../packages/util/official-bundles/src/index.ts) |
| `@deepseek-ai/dsh-output-retention` | — | [`packages/util/output-retention/src/index.ts`](../packages/util/output-retention/src/index.ts) |
| `@deepseek-ai/dsh-package-manifest` | — | [`packages/util/package-manifest/src/index.ts`](../packages/util/package-manifest/src/index.ts) |
+1
View File
@@ -5088,6 +5088,7 @@ export interface Config {
| `@deepseek-ai/dsh-llm-mock-server` | — | [`packages/test-support/llm-mock-server/src/index.ts`](../packages/test-support/llm-mock-server/src/index.ts) |
| `@deepseek-ai/dsh-loader-smoke` | — | [`packages/test-support/loader-smoke/src/index.ts`](../packages/test-support/loader-smoke/src/index.ts) |
| `@deepseek-ai/dsh-native-command` | — | [`packages/util/native-command/src/index.ts`](../packages/util/native-command/src/index.ts) |
| `@deepseek-ai/dsh-office-engine-resolver` | — | [`packages/document/office-engine-resolver/src/index.ts`](../packages/document/office-engine-resolver/src/index.ts) |
| `@deepseek-ai/dsh-official-bundles` | — | [`packages/util/official-bundles/src/index.ts`](../packages/util/official-bundles/src/index.ts) |
| `@deepseek-ai/dsh-output-retention` | — | [`packages/util/output-retention/src/index.ts`](../packages/util/output-retention/src/index.ts) |
| `@deepseek-ai/dsh-package-manifest` | — | [`packages/util/package-manifest/src/index.ts`](../packages/util/package-manifest/src/index.ts) |
@@ -18,8 +18,8 @@
en: e68bfcde70f845b4
zh: 73e5c4fb067c4fa1
/package-surface-rules/internal-dependency-syntax-follows-the-package-family:
en: c39d3e2db0aee3c9
zh: 9c728bcb3a76bb61
en: 9e69af33363f4231
zh: 2deaf03d7856f92d
/package-surface-rules/a-new-resolve-hook-is-refused-outright:
en: b420ae3e228432d5
zh: 0c49c4a9c3a5262c
+2 -2
View File
@@ -151,7 +151,7 @@ Restating a required-devDependency package list or the field rules on this page
Internal dependency syntax follows the package family: `@deepseek-ai/dsh` and `@deepseek-ai/dsh-*` use `workspace:*`, every other internal package uses `workspace:~`, and `workspace:^` has retired. The same dependency name is written the same way in every workspace, and a mismatch is a hard scan failure rather than a warning a reviewer can waive. The uniform syntax keeps one resolution path and prevents duplicate instances of a dependency and the resolution differences that follow them.
- Judged by `workspaceRange()` in [verify-package-dependencies.ts](../../scripts/verify-package-dependencies.ts): the `@deepseek-ai/dsh` and `@deepseek-ai/dsh-*` families get `workspace:*`, every other internal package gets `workspace:~`; the scan walks the managed manifests and compares every declaration of the same dependency name.
- Boundary: a same-namespace package that is not a workspace member declares an ordinary third-party range and is outside the family rule — `@deepseek-ai/libreoffice-kit` is declared `0.2.3-rc.4` in `apps/desktop-host/package.json` (dependencies), `packages/bundle/web-app/package.json` (devDependencies), `packages/document/office-to-pdf/package.json` (dependencies), and `packages/skill/skill-office/package.json` (dependencies).
- Boundary: a same-namespace package that is not a workspace member declares an ordinary third-party range and is outside the family rule — `@deepseek-ai/libreoffice-kit` is declared `0.2.3` in `packages/bundle/web-app/package.json` (devDependencies), `packages/document/office-to-pdf/package.json` (dependencies), and `packages/skill/skill-office/package.json` (dependencies).
- Distribution is not restated here; the recompute command below prints the current counts of `workspace:*`, `workspace:~`, `workspace:^`, and any exact-range declaration outside the family rule.
- Exception: the scan decides mechanically; a genuine exception means changing the scan and the policy file with the domain owner's confirmation and the reason on the change.
- Recompute:
@@ -167,7 +167,7 @@ Each family in its form, with the non-member exception left as an ordinary versi
"dependencies": {
"@deepseek-ai/dsh-base": "workspace:*",
"@deepseek-ai/cordis": "workspace:~",
"@deepseek-ai/libreoffice-kit": "0.2.3-rc.4"
"@deepseek-ai/libreoffice-kit": "0.2.3"
},
"devDependencies": {
"@deepseek-ai/dsh-typert-registry": "workspace:*"
@@ -151,7 +151,7 @@ Restating a required-devDependency package list or the field rules on this page
内部依赖写法按包族决定:`@deepseek-ai/dsh` 与 `@deepseek-ai/dsh-*` 用 `workspace:*`,其余内部包用 `workspace:~`,`workspace:^` 已退出。同一个依赖名在所有 workspace 内写法一致,不一致是扫描硬失败,不是评审可以放行的警告。写法统一才能保持一条解析路径,避免同一个依赖出现重复实例以及随之而来的解析差异。
- 判定在 [verify-package-dependencies.ts](../../scripts/verify-package-dependencies.ts) 的 `workspaceRange()`:`@deepseek-ai/dsh` 与 `@deepseek-ai/dsh-*` 包族取 `workspace:*`,其余内部包取 `workspace:~`;扫描遍历受管清单,比对同一依赖名的每一处声明。
- 边界:同命名空间但不是 workspace 成员的包按普通第三方版本范围声明,不受包族规则约束——`@deepseek-ai/libreoffice-kit` 在 `apps/desktop-host/package.json`(dependencies)、`packages/bundle/web-app/package.json`(devDependencies)、`packages/document/office-to-pdf/package.json`(dependencies)、`packages/skill/skill-office/package.json`(dependencies)中声明为 `0.2.3-rc.4`。
- 边界:同命名空间但不是 workspace 成员的包按普通第三方版本范围声明,不受包族规则约束——`@deepseek-ai/libreoffice-kit` 在 `packages/bundle/web-app/package.json`(devDependencies)、`packages/document/office-to-pdf/package.json`(dependencies)、`packages/skill/skill-office/package.json`(dependencies)中声明为 `0.2.3`。
- 分布不在此复述;下面的复算命令会打印 `workspace:*`、`workspace:~`、`workspace:^` 的当前数量,以及族规则之外的精确版本声明。
- 例外:门禁机械判定;确需例外意味着修改扫描与策略文件,并须经对应领域专家确认、在改动上说明理由。
- 复算:
@@ -167,7 +167,7 @@ python3 -c "import json,glob,collections; c=collections.Counter(); [c.update([r]
"dependencies": {
"@deepseek-ai/dsh-base": "workspace:*",
"@deepseek-ai/cordis": "workspace:~",
"@deepseek-ai/libreoffice-kit": "0.2.3-rc.4"
"@deepseek-ai/libreoffice-kit": "0.2.3"
},
"devDependencies": {
"@deepseek-ai/dsh-typert-registry": "workspace:*"
+2
View File
@@ -252,6 +252,7 @@ flowchart TD
pkg_workspace_changes["workspace-changes"]
end
subgraph group_document["packages/document"]
pkg_office_engine_resolver["office-engine-resolver"]
pkg_office_to_pdf["office-to-pdf"]
end
subgraph group_experimental["packages/experimental"]
@@ -1619,6 +1620,7 @@ flowchart TD
| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | — |
| [`client-web`](../packages/client/web) | `client` | — |
| [`credentials`](../packages/credentials/credentials) | `credentials` | — |
| [`office-engine-resolver`](../packages/document/office-engine-resolver) | `document` | — |
| [`office-to-pdf`](../packages/document/office-to-pdf) | `document` | — |
| [`client-ui-action-cards`](../packages/experimental/client-ui-action-cards) | `experimental` | — |
| [`client-ui-creation-recommendations`](../packages/experimental/client-ui-creation-recommendations) | `experimental` | — |
+2
View File
@@ -254,6 +254,7 @@ flowchart TD
pkg_workspace_changes["workspace-changes"]
end
subgraph group_document["packages/document"]
pkg_office_engine_resolver["office-engine-resolver"]
pkg_office_to_pdf["office-to-pdf"]
end
subgraph group_experimental["packages/experimental"]
@@ -1621,6 +1622,7 @@ flowchart TD
| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | — |
| [`client-web`](../packages/client/web) | `client` | — |
| [`credentials`](../packages/credentials/credentials) | `credentials` | — |
| [`office-engine-resolver`](../packages/document/office-engine-resolver) | `document` | — |
| [`office-to-pdf`](../packages/document/office-to-pdf) | `document` | — |
| [`client-ui-action-cards`](../packages/experimental/client-ui-action-cards) | `experimental` | — |
| [`client-ui-creation-recommendations`](../packages/experimental/client-ui-creation-recommendations) | `experimental` | — |
+2 -2
View File
@@ -6,8 +6,8 @@
en: 9a24c6158f9fb214
zh: 773155d0e59fffe2
/office-to-pdf/ownership:
en: b21c1a3345590b3a
zh: d402b06930599e07
en: e8ec782434854dfe
zh: efda9390e8b8e3a0
/office-to-pdf/requests-and-results:
en: 8a1fa9eb6e82ef8f
zh: e263ca49c5c957d6
+1
View File
@@ -8,6 +8,7 @@ The [document package family](../../packages/document/README.md) converts Office
| Owner | Responsibility |
|---|---|
| [office-engine-resolver](../../packages/document/office-engine-resolver/README.md) | Resolve engine manifests from the caller's Kit installation and map native paths to ASAR unpacked directories |
| [office-to-pdf](../../packages/document/office-to-pdf/README.md) | `ctx.officeToPdf`: shared LibreOffice conversion, bounded admission, and PDF caching |
| [Web bundle](../../packages/bundle/web-app/README.md) | One configurable conversion provider shared by Host consumers |
| [Office preview Client](../../packages/client/ui-sidebar-documentpreview/README.md#office-preview) | Office extension selection, PDF reuse, and missing-font notices |
+1
View File
@@ -8,6 +8,7 @@
| 所有者 | 职责 |
|---|---|
| [office-engine-resolver](../../packages/document/office-engine-resolver/README.zh.md) | 从调用方的 Kit 安装定位引擎清单,并将原生路径映射到 ASAR 解包目录 |
| [office-to-pdf](../../packages/document/office-to-pdf/README.zh.md) | `ctx.officeToPdf`:共享 LibreOffice 转换、有界准入和 PDF 缓存 |
| [Web bundle](../../packages/bundle/web-app/README.zh.md) | 由宿主消费者共享的单个可配置转换提供方 |
| [Office 预览 Client](../../packages/client/ui-sidebar-documentpreview/README.zh.md#office-preview) | Office 扩展名选择、PDF 复用和缺失字体提示 |
+2 -2
View File
@@ -15,8 +15,8 @@
en: ffc55792d6e922e1
zh: 83bd759638857e2d
/packages/package-groups:
en: 60a6056460b8d4c1
zh: fce319504ae9b6ef
en: c9fa38b16c65ef31
zh: aba42d4b0b70c9b7
/packages/release-expectations:
en: 236081c1659b25ab
zh: 809110adf003802e
+1 -1
View File
@@ -57,7 +57,7 @@ Every package lives in exactly one group; new packages join existing groups, and
| [`experimental/`](experimental/README.md) | Capabilities under evaluation; [status and support policy](experimental/README.md#status) |
| [`workflow/`](workflow/README.md) | Workflow seam, PTC process engine, and the model-facing `workflow` tool |
| [`web/`](web/README.md) | Web capability family: seam, search/fetch providers, model-facing web tools |
| [`document/`](document/README.md) | Shared Host Office-to-PDF conversion |
| [`document/`](document/README.md) | Shared Host Office-to-PDF conversion and Kit engine resolution |
| [`attachment/`](attachment/README.md) | Durable attachment identity, validation, local content-addressed storage |
| [`spill/`](spill/README.md) | Spill capability family: storage seam, local impl, tool-result spill policy |
| [`todo/`](todo/README.md) | The model-facing `todo_write` tool |
+1 -1
View File
@@ -57,7 +57,7 @@ harness 由 `packages/` 下的 npm 包组装而成,按能力系列分组:会
| [`experimental/`](experimental/README.zh.md) | 仍在评估中的能力;[状态与支持策略](experimental/README.zh.md#status) |
| [`workflow/`](workflow/README.zh.md) | 工作流 seam、PTC 进程引擎、面向模型的 `workflow` 工具 |
| [`web/`](web/README.zh.md) | Web 能力系列:seam、搜索/获取提供方、面向模型的 Web 工具 |
| [`document/`](document/README.zh.md) | 共享宿主 Office 到 PDF 转换 |
| [`document/`](document/README.zh.md) | 共享宿主 Office 到 PDF 转换与 Kit 引擎解析 |
| [`attachment/`](attachment/README.zh.md) | 持久附件标识、校验、本地内容寻址存储 |
| [`spill/`](spill/README.zh.md) | spill 能力系列:存储 seam、本地实现、工具结果 spill 策略 |
| [`todo/`](todo/README.zh.md) | 面向模型的 `todo_write` 工具 |
+2 -2
View File
@@ -33,8 +33,8 @@
en: fdf38cb6e1b24464
zh: c14a8bcac9d4e5cd
/deepseek-ai-dsh-web-app/understand-the-implementation:
en: 323742a00cfe67b2
zh: eff5245a82fc16e7
en: 6838ccb8e33a22b7
zh: a9c6503bf0c9a259
/deepseek-ai-dsh-web-app/understand-the-implementation/patch-semantics:
en: a6d126f30b1594d7
zh: 7f0ccc8a55d6f743
+2
View File
@@ -102,6 +102,8 @@ Each browser session selects a shipped preset (`standard` by default). The Agent
The bundle is one patch layer of five files plus one runtime glue plugin: `cordis.patch.yml` carries the host rows and the preset registry, and each `presets/<id>.patch.yml` inserts one shipped preset declaration, applied in the order `dsh.bundle.patch` lists them. The storage stack and projection cache come from `dsh-base`; the web overlay's workspace and message-feedback rows consume that shared `storageDomain` service. The patch restates the surface-specific values the base deliberately omits, inserts the web-only host rows and browser roster, then moves the agent plane behind presets. The glue plugin owns dist serving, the advertised application URL, the plain-HTTP exposure warning, the bash variable, and the readiness announcements. The `office-to-pdf` row mounts one lazy [Office conversion provider](../../document/office-to-pdf/README.md) for Host consumers, including Desktop compositions using this bundle. The conversion service's Remote methods authorize preview reads, while Document Preview owns the Office viewer and Client cache.
Desktop also mounts the Office skills and bundled dependency-query rows; other profiles leave them disabled. An absent `DSH_BUNDLED_OFFICE_CLI` selects `cli: false`, retaining readable skills without automatically resolving another Kit installation. The [Desktop application](../../../apps/desktop/README.md#bundled-workspace-dependencies) owns resource resolution and its diagnostics.
### Patch semantics
A patch replaces the targeted row's whole `config`, so each web row restates every key it owns: the persona prefix template and the `session-query-sqlite` values on the base rows, then `insert` adds the web host rows, transport, and browser roster. The `webserver`, `web-runtime`, and `connection` rows inject the `webStartup` provider and read their invocation values directly; Connection composes its accepted authorities from `ctx.webStartup.trustedHosts`, and the Host/Origin fence accepts the listener's bind IP independently of that list. The bundle inherits native tool execution; a [PTC overlay](../../../apps/cli/config/examples/ptc.yml) changes the deployment executor. The per-agent tool rows the base mounts process-wide are disabled here and the preset roster takes over; the reasoning for each host-plane versus preset-plane decision is inline in the patch.
+2
View File
@@ -102,6 +102,8 @@ dsh --profile web --no-open --host "$(hostname -i | awk '{print $1}')" --public-
此 bundle 由一层五个文件的补丁和一个运行时胶水插件组成:`cordis.patch.yml` 承载宿主行和 preset 注册表,每个 `presets/<id>.patch.yml` 插入一条随发行版交付的 preset 声明,按 `dsh.bundle.patch` 列出的顺序应用。存储栈与投影缓存来自 `dsh-base`;Web 叠加层的工作区和消息反馈条目消费共享的 `storageDomain` 服务。补丁重述 base 有意省略的界面专用值,插入 Web 专用宿主条目和浏览器插件列表,再将 Agent 层移到预设后面。胶水插件负责 dist 服务、公告应用 URL、明文 HTTP 暴露警告、bash 变量和就绪通知。`office-to-pdf` 条目为宿主消费者挂载一个延迟创建引擎的 [Office 转换提供方](../../document/office-to-pdf/README.zh.md),使用此 bundle 的 Desktop 组合也共享该提供方。 转换服务的 Remote 方法负责预览读取授权,Document Preview 负责 Office 查看器和客户端缓存。
Desktop 还挂载 Office 技能与内置依赖查询行;其他 profile 保持这两行禁用。未提供 `DSH_BUNDLED_OFFICE_CLI` 时使用 `cli: false`,保留可读技能,不自动解析其他 Kit 安装。[Desktop 应用](../../../apps/desktop/README.zh.md#bundled-workspace-dependencies)负责资源定位及其诊断。
### patch 语义
patch 会替换目标行的整个 `config`,因此每个 Web 行都重述自己拥有的每个键:基础行上的 persona 前缀模板与 `session-query-sqlite` 值,随后 `insert` 添加 Web 宿主行、传输层与浏览器名录。`webserver`、`web-runtime` 与 `connection` 行都注入 `webStartup` 提供方并直接读取本次调用的取值;Connection 的被接受 authority 全部来自 `ctx.webStartup.trustedHosts`,Host/Origin 栅栏则独立于该列表接受监听器的绑定 IP。bundle 继承原生工具执行;[PTC overlay](../../../apps/cli/config/examples/ptc.yml) 可切换部署执行器。base 以进程级挂载的按 agent 工具行在这里被禁用,由 preset 名录接管;每项宿主层与 preset 层归属决策的理由以行内注释写在 patch 里。
+4 -2
View File
@@ -293,7 +293,9 @@
# Desktop Office skills and bundled authoring dependencies. The Desktop
# Host sets both carrier paths before boot; a row that fails to load leaves
# the rest of the Host running.
# the rest of the Host running. When the Desktop shell cannot locate its
# bundled Kit CLI, `cli: false` keeps the skills without CLI paths instead
# of resolving another Kit.
- id: workspace-dependencies
name: '@deepseek-ai/dsh-tool-workspace-dependencies'
disabled: !!js "ctx.get('profileContext')?.name !== 'desktop'"
@@ -307,7 +309,7 @@
config:
assetRoot: !!js "process.getBuiltinModule('node:path').join(process.env.DSH_BUNDLED_PRIMARY_RUNTIME, '..', 'office-skills')"
node: !!js "process.getBuiltinModule('node:path').join(process.env.DSH_BUNDLED_PRIMARY_RUNTIME, 'dependencies', 'node', 'bin', process.platform === 'win32' ? 'node.exe' : 'node')"
cli: !!js process.env.DSH_BUNDLED_OFFICE_CLI
cli: !!js "process.env.DSH_BUNDLED_OFFICE_CLI ?? false"
# The right Sidebar's document tab: bounded file reads with selectable
# Markdown, code, HTML, PDF, Office, and plain-text renderers.
+1 -1
View File
@@ -224,7 +224,7 @@
"@deepseek-ai/dsh-session-projection": "workspace:*",
"@deepseek-ai/dsh-shell-env": "workspace:*",
"@deepseek-ai/dsh-typert-registry": "workspace:*",
"@deepseek-ai/libreoffice-kit": "0.2.3-rc.4",
"@deepseek-ai/libreoffice-kit": "0.2.3",
"@deepseek-ai/dsh-otel": "workspace:*",
"@deepseek-ai/dsh-home-paths": "workspace:*",
"@deepseek-ai/dsh-skill": "workspace:*",
@@ -20,12 +20,13 @@ const names = ['office', 'office-docx', 'office-export-pdf', 'office-pptx', 'off
afterEach(() => { vi.unstubAllEnvs() })
async function boot(profile: string, primaryRuntime: (root: string) => string | undefined) {
async function boot(profile: string, primaryRuntime: (root: string) => string | undefined,
officeCli: (root: string) => string | undefined = root => join(root, 'office-cli.js')) {
const root = await mkdtemp(join(tmpdir(), 'dsh-desktop-office-rows-'))
onTestFinished(() => rm(root, { recursive: true, force: true }))
vi.stubEnv('DSH_HOME', join(root, 'home'))
vi.stubEnv('DSH_BUNDLED_PRIMARY_RUNTIME', primaryRuntime(root))
vi.stubEnv('DSH_BUNDLED_OFFICE_CLI', join(root, 'office-cli.js'))
vi.stubEnv('DSH_BUNDLED_OFFICE_CLI', officeCli(root))
await cp(assets, join(root, 'office-skills'), { recursive: true })
const ctx = new Context()
onTestFinished(() => ctx.fiber.dispose())
@@ -63,6 +64,21 @@ it('mounts Office skills and workspace dependencies from the Desktop carrier pat
expect(ctx.tools.schemas().map(tool => tool.name)).toEqual(['load_workspace_dependencies'])
})
it('keeps Office skills without CLI paths when the Desktop shell supplies no Kit CLI', async () => {
const { ctx, root } = await boot('desktop', root => join(root, 'primary-runtime'), () => undefined)
expect(ctx.loader.resolve('skill-office').fiber?.config).toMatchObject({ assetRoot: join(root, 'office-skills'), cli: false })
expect(ctx.loader.resolve('workspace-dependencies').fiber?.config).toEqual({
source: join(root, 'primary-runtime'), root: join(root, 'home', 'dsh-runtimes', 'dsh-primary-runtime'),
})
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(names)
for (const name of ['office-render', 'office-recalculate', 'office-export-pdf']) {
const content = (await ctx.skills.get(name))?.content
expect(content).toMatch(/\n\nLibreOffice Kit is disabled in this deployment\.$/u)
expect(content).not.toContain('libreofficeKit')
}
expect(ctx.tools.schemas().map(tool => tool.name)).toEqual(['load_workspace_dependencies'])
})
it('keeps both rows disabled outside the Desktop profile', async () => {
const { ctx } = await boot('web', () => undefined)
expect(ctx.loader.resolve('skill-office').disabled).toBe(true)
+2 -2
View File
@@ -15,8 +15,8 @@
en: 1eb35b015a6c423f
zh: a8a3dceee2c9e943
/document-office-conversion/packages:
en: 4e4b5cffba75c10f
zh: b202aeb2a915ce08
en: 96f047dcfd9f6480
zh: 7e6cf139c4c72c49
/document-office-conversion/related-documentation:
en: 17eed9809381620e
zh: 3d3f045cec85e4f4
+1
View File
@@ -26,6 +26,7 @@ Each package owns its configuration and lifetime rules; the subsystem reference
| Package | Role | ctx key |
|---|---|---|
| [office-engine-resolver](office-engine-resolver/README.md) | Consumer-anchored Kit engine manifests with native ASAR paths | None |
| [office-to-pdf](office-to-pdf/README.md) | Authorized Office bytes to complete PDFs with bounded queues and caching | `ctx.officeToPdf` |
-----
+1
View File
@@ -26,6 +26,7 @@ kind: "package-group"
| 包 | 职责 | ctx 键 |
|---|---|---|
| [office-engine-resolver](office-engine-resolver/README.zh.md) | 以消费方 Kit 为起点解析引擎清单,并映射原生 ASAR 路径 | 无 |
| [office-to-pdf](office-to-pdf/README.zh.md) | 将已授权 Office 字节转换为完整 PDF,并提供有界队列和缓存 | `ctx.officeToPdf` |
-----
@@ -0,0 +1,37 @@
# Bilingual-pair consistency record for README.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/document/office-engine-resolver/README.md
/:
en: 24c13bf062183cfd
zh: 4d52f88ba115c589
/deepseek-ai-dsh-office-engine-resolver:
en: b2018923928c252b
zh: 87626e8a94552a49
/deepseek-ai-dsh-office-engine-resolver/summary:
en: bee6c2600f3d2f49
zh: 83685056df577a44
/deepseek-ai-dsh-office-engine-resolver/table-of-contents:
en: d152484eb41ac6b4
zh: 75f067b4c4218355
/deepseek-ai-dsh-office-engine-resolver/use-this-package:
en: 0bffd7780cc4ab51
zh: aacb0f9a76ff6ba1
/deepseek-ai-dsh-office-engine-resolver/understand-the-implementation:
en: ecc1cc389f0d50d8
zh: ca929a3d20886236
/deepseek-ai-dsh-office-engine-resolver/further-exploration:
en: 6acb078ef7f64b5d
zh: 9889b115126dbf62
/deepseek-ai-dsh-office-engine-resolver/model-experience:
en: 875a75cdbbb9fbd5
zh: f169a1ef9cf0cd95
/deepseek-ai-dsh-office-engine-resolver/model-experience/kv-cache-effect:
en: 801cdc2300902a94
zh: 3ee0cee2bc34847a
/deepseek-ai-dsh-office-engine-resolver/known-limitations-and-deferred-work:
en: 38dd52010491fe88
zh: a992d39a5804e54a
/deepseek-ai-dsh-office-engine-resolver/known-limitations-and-deferred-work/dev-note:
en: e67f633dc6fe7773
zh: 8ec3e73528ddb1a6
@@ -0,0 +1,90 @@
---
description: "Resolve LibreOffice Kit engine manifests from the consumer's installation, including native ASAR paths."
kind: "package-library"
---
# @deepseek-ai/dsh-office-engine-resolver
English | [中文](README.zh.md)
## Summary
Resolve engine packages for the LibreOffice Kit installation your application actually uses. Native engines inside an ASAR archive resolve to their adjacent unpacked paths; WASM retains its logical path. The [Office-to-PDF provider](../office-to-pdf/README.md) uses this callback without installing module hooks or loading Electron.
## 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)
-----
<a id="use-this-package"></a>
## Use this package
Use this library as a dependency of a Node consumer that supplies Kit's `resolveEnginePackage` option. The consumer owns its Kit dependency; this package does not depend on Kit and has no `cordis.yml` configuration.
```ts
import { createEnginePackageResolver } from '@deepseek-ai/dsh-office-engine-resolver'
const options = {
resolveEnginePackage: createEnginePackageResolver(import.meta.resolve('@deepseek-ai/libreoffice-kit')),
}
```
Pass `options` to Kit's `createConverter` or `discoverRuntime`, or as `options` in the editor's `openDocument` request. Resolve the Kit entry in the consuming module: anchoring at this library could select a different installation. The factory also accepts an absolute entry path or a file `URL`; each callback retains its own resolver.
A successful callback returns an absolute engine `package.json` path. An absent package preserves Node's original `MODULE_NOT_FOUND`, allowing Kit's native-to-WASM fallback. If a package directory exists, including a dangling symlink, but its manifest cannot resolve, the callback throws an incomplete-installation error with the Node error as `cause`; reinstall that package. Other resolution and filesystem inspection errors propagate unchanged. Kit owns validation of the returned manifest and required assets.
-----
<a id="understand-the-implementation"></a>
## Understand the implementation
<details>
<summary>Implementation internals — click to expand</summary>
The [factory](src/index.ts) creates a Node resolver from the supplied Kit entry and resolves engine manifests relative to it. Native paths replace only the first `.asar` archive segment with `.asar.unpacked`, regardless of whether the process runs in Electron. Already-unpacked paths and paths outside an archive remain unchanged. `@deepseek-ai/libreoffice-kit-wasm` always keeps Node's logical path for archive-aware filesystem access.
The callback only resolves paths and inspects failed installations. It does not load engine code, register services, mutate module resolution, or retain module-level state. [Desktop packaging](../../../apps/desktop/packaging/README.md#runtime-file-selection) owns the unpacked files.
</details>
-----
<a id="further-exploration"></a>
## Further Exploration
- [Office-to-PDF provider](../office-to-pdf/README.md) — converter configuration and request ownership.
- [Document conversion](../../../docs/subsystems/office-to-pdf.md) — shared Host conversion operation.
-----
<a id="model-experience"></a>
## Model Experience
None, as this package resolves engine manifests without constructing model input or registering tools.
#### KV Cache effect
None; resolution does not construct or modify model requests.
## Known Limitations and Deferred Work
<a id="known-limitations-and-deferred-work"></a>
- The caller must supply the entry of the Kit installation it uses; the resolver does not discover or install Kit or engines.
- Mapping a native path does not verify that the unpacked copy exists. Kit rejects missing manifests or assets instead of treating them as an absent native package.
<a id="dev-note"></a>
### Dev Note
<details>
<summary>Working context for maintainers — click to expand</summary>
None.
</details>
@@ -0,0 +1,90 @@
---
description: "从消费方的安装位置解析 LibreOffice Kit 引擎清单,包括原生引擎的 ASAR 路径。"
kind: "package-library"
---
# @deepseek-ai/dsh-office-engine-resolver
[English](README.md) | 中文
## 概述
为应用实际使用的 LibreOffice Kit 安装解析引擎包。ASAR 归档内的原生引擎解析为相邻的解包路径;WASM 保留逻辑路径。[Office-to-PDF 提供方](../office-to-pdf/README.zh.md)使用此回调,无需安装模块钩子或加载 Electron。
## 目录
- [使用此包](#use-this-package)
- [理解实现](#understand-the-implementation)
- [进一步探索](#further-exploration)
- [模型体验](#model-experience)
- [已知限制与延后工作](#known-limitations-and-deferred-work)
- [开发备注](#dev-note)
-----
<a id="use-this-package"></a>
## 使用此包
将此库作为提供 Kit `resolveEnginePackage` 选项的 Node 消费方的依赖。消费方负责声明自己的 Kit 依赖;此包不依赖 Kit,也没有 `cordis.yml` 配置。
```ts
import { createEnginePackageResolver } from '@deepseek-ai/dsh-office-engine-resolver'
const options = {
resolveEnginePackage: createEnginePackageResolver(import.meta.resolve('@deepseek-ai/libreoffice-kit')),
}
```
将 `options` 传给 Kit 的 `createConverter` 或 `discoverRuntime`,或作为编辑器 `openDocument` 请求的 `options` 字段。在消费模块中解析 Kit 入口:以此库为起点可能选中不同的安装。工厂也接受绝对入口路径或文件 `URL`;每个回调持有独立的解析器。
回调成功时返回引擎 `package.json` 的绝对路径。包不存在时保留 Node 原始的 `MODULE_NOT_FOUND`,允许 Kit 从原生引擎回退到 WASM。如果包目录存在(包括断开的符号链接),但清单无法解析,回调抛出安装不完整错误,并将 Node 错误保存在 `cause` 中;此时应重新安装该包。其他解析错误和文件系统检查错误原样传播。Kit 负责验证返回的清单和必需资源。
-----
<a id="understand-the-implementation"></a>
## 理解实现
<details>
<summary>实现内部细节 — 点击展开</summary>
[工厂](src/index.ts)从传入的 Kit 入口创建 Node 解析器,并相对该入口解析引擎清单。原生路径只将第一个 `.asar` 归档段替换为 `.asar.unpacked`,不论进程是否运行在 Electron 中。已解包路径及归档外路径保持不变。`@deepseek-ai/libreoffice-kit-wasm` 始终保留 Node 逻辑路径,供支持归档的文件系统访问。
回调只解析路径并检查失败的安装。它不加载引擎代码、不注册服务、不修改模块解析,也不保留模块级状态。[Desktop 打包](../../../apps/desktop/packaging/README.zh.md#runtime-file-selection)负责解包文件。
</details>
-----
<a id="further-exploration"></a>
## 进一步探索
- [Office-to-PDF 提供方](../office-to-pdf/README.zh.md) — 转换器配置和请求所有权。
- [文档转换](../../../docs/subsystems/office-to-pdf.zh.md) — 共享宿主转换操作。
-----
<a id="model-experience"></a>
## 模型体验
无,因为此包只解析引擎清单,不构造模型输入或注册工具。
#### KV Cache 影响
无;解析不会构造或修改模型请求。
## 已知限制与延后工作
<a id="known-limitations-and-deferred-work"></a>
- 调用方必须提供其实际使用的 Kit 安装入口;解析器不发现或安装 Kit 或引擎。
- 映射原生路径不验证解包副本是否存在。Kit 会拒绝缺失的清单或资源,而不是将其视为原生包未安装。
<a id="dev-note"></a>
### 开发备注
<details>
<summary>维护者工作上下文 — 点击展开</summary>
无。
</details>
@@ -0,0 +1,35 @@
{
"name": "@deepseek-ai/dsh-office-engine-resolver",
"description": "Consumer-anchored LibreOffice Kit engine resolution with native ASAR paths",
"version": "0.2.2-alpha.2",
"publishConfig": {
"access": "public"
},
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/document/office-engine-resolver"
},
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/types/**/*.d.ts"
],
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:~"
},
"devDependencies": {
"@deepseek-ai/cordis": "workspace:~"
}
}
@@ -0,0 +1,47 @@
/** Locate LibreOffice Kit engine packages from the consumer's Kit installation without module hooks. */
import { lstatSync } from 'node:fs'
import { createRequire } from 'node:module'
import { join } from 'node:path'
/**
* Locate an engine package manifest for Kit.
* @param name - Engine package name requested by Kit.
* @returns Absolute path of the package's `package.json`.
* @throws An error with code `MODULE_NOT_FOUND` when the package is absent.
*/
export type EnginePackageResolver = (name: string) => string
/**
* Anchor engine resolution to the Kit entry actually used by the consumer, not this package's installation.
* Each returned callback retains its own Node resolver and installs no module hooks.
* @param kitEntry - Absolute Kit entry path or file URL, normally the consumer's `import.meta.resolve('@deepseek-ai/libreoffice-kit')`.
* @returns A callback for Kit's `resolveEnginePackage` option. Native paths map the first ASAR segment to its unpacked copy;
* WASM and paths outside archives stay unchanged. Kit validates the returned manifest and required engine assets.
* @throws Node's error if `kitEntry` cannot be used by `createRequire`.
* On invocation, the callback preserves Node errors for absent packages and other resolution failures.
* An installed package with an unresolvable manifest throws an incomplete-installation Error with the Node error as its cause,
* preventing engine fallback. Filesystem inspection errors propagate unchanged.
*/
export function createEnginePackageResolver(kitEntry: string | URL): EnginePackageResolver {
const fromKit = createRequire(kitEntry)
return (name) => {
let manifest: string
try {
manifest = fromKit.resolve(`${name}/package.json`)
} catch (error) {
if (error instanceof Error && 'code' in error && error.code === 'MODULE_NOT_FOUND') {
const installed = (fromKit.resolve.paths(name) ?? []).some(directory =>
lstatSync(join(directory, name), { throwIfNoEntry: false }) !== undefined,
)
if (installed) throw new Error(`Installed LibreOfficeKit package is incomplete: ${name}. Reinstall the package.`, { cause: error })
}
throw error
}
return name === '@deepseek-ai/libreoffice-kit-wasm' ? manifest : unpackedPath(manifest)
}
}
/** Map only the first ASAR archive segment; already-unpacked paths pass through unchanged. */
function unpackedPath(path: string): string {
return path.replace(/\.asar(?=[\\/])/, '.asar.unpacked')
}
@@ -0,0 +1,34 @@
/** Independent Kit installations keep their own engine resolution paths. */
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { basename, dirname, join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { expect, it, onTestFinished } from 'vitest'
import { createEnginePackageResolver } from '../src/index.ts'
it.each(['URL object', 'file URL string'])('resolves engines independently from two Kit installation anchors with %s', (input) => {
const root = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-engine-anchors-')))
onTestFinished(() => { rmSync(root, { recursive: true, force: true, maxRetries: 3, retryDelay: 10 }) })
const name = `@deepseek-ai/libreoffice-kit-${basename(root)}`
const installs = ['first', 'second'].map((installation) => {
const consumer = join(root, installation, 'consumer.js')
const kitRoot = join(dirname(consumer), 'node_modules', '@deepseek-ai', 'libreoffice-kit')
const kitEntry = join(kitRoot, 'lib', 'index.js')
const manifest = join(kitRoot, 'node_modules', name, 'package.json')
mkdirSync(dirname(kitEntry), { recursive: true })
mkdirSync(dirname(manifest), { recursive: true })
writeFileSync(join(kitRoot, 'package.json'), JSON.stringify({ main: 'lib/index.js' }))
writeFileSync(kitEntry, '')
writeFileSync(manifest, JSON.stringify({ name }))
return { kitEntry: createRequire(consumer).resolve('@deepseek-ai/libreoffice-kit'), manifest }
})
const [first, second] = installs
if (!first || !second) throw new Error('Expected two Kit fixtures')
const resolveFirst = createEnginePackageResolver(first.kitEntry)
const secondUrl = pathToFileURL(second.kitEntry)
const resolveSecond = createEnginePackageResolver(input === 'file URL string' ? secondUrl.href : secondUrl)
expect(resolveFirst(name)).toBe(first.manifest)
expect(resolveSecond(name)).toBe(second.manifest)
expect(resolveFirst(name)).toBe(first.manifest)
})
@@ -1,10 +1,10 @@
/** Engine resolution distinguishes missing installations from incomplete ones and preserves ASAR asset paths. */
import { lstatSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
import { lstatSync, mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { basename, dirname, join } from 'node:path'
import { afterEach, expect, it, onTestFinished, vi } from 'vitest'
import { resolveEnginePackage, unpackedPath } from '../src/engine-package.ts'
import { createEnginePackageResolver } from '../src/index.ts'
vi.mock('node:module', async (original) => {
const actual = await original<typeof import('node:module')>()
@@ -15,12 +15,11 @@ vi.mock('node:fs', async (original) => {
return { ...actual, lstatSync: vi.fn(actual.lstatSync) }
})
const { createRequire: nodeCreateRequire } = await vi.importActual<typeof import('node:module')>('node:module')
const resolverUrl = new URL('../src/engine-package.ts', import.meta.url).href
afterEach(() => { vi.resetAllMocks() })
function engineTree() {
const root = mkdtempSync(join(tmpdir(), 'dsh-engine-package-'))
const root = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-engine-package-')))
onTestFinished(() => { rmSync(root, { recursive: true, force: true, maxRetries: 3, retryDelay: 10 }) })
const kitRoot = join(root, 'node_modules', '@deepseek-ai', 'libreoffice-kit')
const kitSource = join(kitRoot, 'lib', 'index.js')
@@ -33,14 +32,13 @@ function engineTree() {
const resolve = vi.fn(kit.resolve)
kit.resolve = Object.assign(resolve, { paths: kit.resolve.paths.bind(kit.resolve) })
vi.mocked(createRequire).mockImplementation((from) => {
if (from === resolverUrl) return host
if (from === kitEntry) return kit
return nodeCreateRequire(from)
})
// A private name prevents globally installed packages from satisfying an absent fixture.
const name = `@deepseek-ai/libreoffice-kit-${basename(root)}`
const directory = join(kitRoot, 'node_modules', name)
return { root, name, directory, resolve, kit }
return { root, name, directory, resolve, kit, resolveEnginePackage: createEnginePackageResolver(kitEntry) }
}
function thrownBy(action: () => void): unknown {
@@ -56,41 +54,23 @@ it.each([
['/App/resources/app.asar.unpacked/dsh/node_modules/x/package.json', '/App/resources/app.asar.unpacked/dsh/node_modules/x/package.json'],
['/workspace/node_modules/x/package.json', '/workspace/node_modules/x/package.json'],
['/workspace/notes.asar.txt/package.json', '/workspace/notes.asar.txt/package.json'],
['/app.asar/nested.asar/package.json', '/app.asar.unpacked/nested.asar/package.json'],
])('maps %s', (path, expected) => {
expect(unpackedPath(path)).toBe(expected)
})
it('resolves outside an archive exactly as the kit installation does', () => {
const kit = createRequire(createRequire(import.meta.url).resolve('@deepseek-ai/libreoffice-kit'))
const outcome = (resolve: (name: string) => string, name: string): string => {
try { return resolve(name) } catch (error) {
if (error instanceof Error && 'code' in error) return String(error.code)
throw error
}
}
for (const name of ['@deepseek-ai/libreoffice-kit-wasm', `@deepseek-ai/libreoffice-kit-${process.platform}-${process.arch}`]) {
const expected = outcome(name => kit.resolve(`${name}/package.json`), name)
expect(outcome(resolveEnginePackage, name)).toBe(expected)
expect(outcome(resolveEnginePackage, name)).toBe(expected)
}
})
it('reports an absent engine package with Node resolution MODULE_NOT_FOUND', () => {
expect(() => resolveEnginePackage('@deepseek-ai/libreoffice-kit-absent')).toThrow(expect.objectContaining({ code: 'MODULE_NOT_FOUND' }))
const fixture = engineTree()
fixture.resolve.mockReturnValue(path)
expect(fixture.resolveEnginePackage(fixture.name)).toBe(expected)
})
it('uses physical native paths and preserves the WASM logical path in an archive', () => {
const root = '/App/resources/app.asar/dsh/node_modules'
const kitEntry = `${root}/@deepseek-ai/libreoffice-kit/lib/index.js`
const host = nodeCreateRequire(import.meta.url)
const kit = nodeCreateRequire(import.meta.url)
host.resolve = Object.assign(() => kitEntry, { paths: host.resolve.paths.bind(host.resolve) })
kit.resolve = Object.assign((id: string) => `${root}/${id}`, { paths: kit.resolve.paths.bind(kit.resolve) })
vi.mocked(createRequire).mockImplementation((from) => {
if (from === resolverUrl) return host
if (from === kitEntry) return kit
return nodeCreateRequire(from)
})
const resolveEnginePackage = createEnginePackageResolver(kitEntry)
expect(resolveEnginePackage('@deepseek-ai/libreoffice-kit-darwin-arm64'))
.toBe('/App/resources/app.asar.unpacked/dsh/node_modules/@deepseek-ai/libreoffice-kit-darwin-arm64/package.json')
expect(resolveEnginePackage('@deepseek-ai/libreoffice-kit-wasm'))
@@ -105,7 +85,7 @@ it.each(['missing manifest', 'dangling symlink', 'absent'] as const)(
mkdirSync(dirname(fixture.directory), { recursive: true })
symlinkSync(join(fixture.root, 'missing-target'), fixture.directory, 'junction')
}
const error = thrownBy(() => resolveEnginePackage(fixture.name))
const error = thrownBy(() => fixture.resolveEnginePackage(fixture.name))
const nodeError: unknown = fixture.resolve.mock.results[0]?.value
expect(nodeError).toMatchObject({ code: 'MODULE_NOT_FOUND' })
if (state === 'absent') {
@@ -125,7 +105,7 @@ it('keeps a Node resolution error other than MODULE_NOT_FOUND unchanged', () =>
const fixture = engineTree()
mkdirSync(fixture.directory, { recursive: true })
writeFileSync(join(fixture.directory, 'package.json'), '{invalid')
const error = thrownBy(() => resolveEnginePackage(fixture.name))
const error = thrownBy(() => fixture.resolveEnginePackage(fixture.name))
expect(error).toMatchObject({ code: 'ERR_INVALID_PACKAGE_CONFIG' })
expect(error).toBe(fixture.resolve.mock.results[0]?.value)
expect(lstatSync).not.toHaveBeenCalled()
@@ -133,7 +113,7 @@ it('keeps a Node resolution error other than MODULE_NOT_FOUND unchanged', () =>
it('preserves Node missing errors when the name has no package search paths', () => {
const fixture = engineTree()
const error = thrownBy(() => resolveEnginePackage('node:fs'))
const error = thrownBy(() => fixture.resolveEnginePackage('node:fs'))
expect(error).toMatchObject({ code: 'MODULE_NOT_FOUND' })
expect(error).toBe(fixture.resolve.mock.results[0]?.value)
expect(lstatSync).not.toHaveBeenCalled()
@@ -149,5 +129,5 @@ it('propagates permission errors while checking the kit installation', () => {
expect(path).toBe(firstDirectory)
throw denied
})
expect(thrownBy(() => resolveEnginePackage(fixture.name))).toBe(denied)
expect(thrownBy(() => fixture.resolveEnginePackage(fixture.name))).toBe(denied)
})
@@ -0,0 +1,11 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": []
}
@@ -15,11 +15,11 @@
en: d152484eb41ac6b4
zh: a134331a31395608
/deepseek-ai-dsh-office-to-pdf/use-this-package:
en: c3f323de206bd0f5
zh: c5008ff6adf54412
en: 05386849cd5f2d3c
zh: 6482b1ea679b2604
/deepseek-ai-dsh-office-to-pdf/understand-the-implementation:
en: f4b51c65697245d1
zh: 5fb1c293b308a7c7
en: 1b26da70ea8bcc83
zh: dbb7fbf4825a6923
/deepseek-ai-dsh-office-to-pdf/further-exploration:
en: 4d5e635227d14056
zh: 6c72a032786acd55
+2 -2
View File
@@ -29,7 +29,7 @@ The [Web bundle](../../bundle/web-app/README.md) mounts this provider as `office
Callers submit authorized source identity, version, optional byte size, a deferred bounded read, Office extension, and scheduling priority through `ctx.officeToPdf.convert()`. A changed source version rejects conversion. Results contain caller-owned PDF bytes, missing fonts, a cache key, and a conversion generation that changes on configuration replacement. Cancellation rejects with its reason; conversion failures use `OfficeToPdfError`.
The provider depends on the independently published [`@deepseek-ai/libreoffice-kit`](https://github.com/deepseek-ai/dsh-libreoffice-kit) npm API at kit version `0.2.3-rc.4`. Application packaging selects the matching native package declared in the kit’s `optionalDependencies`, or WASM when no native package is declared for that target. A missing declared native engine rejects packaging without selecting WASM. The [release ownership decision](../../../.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.md) defines the independent kit and Harness responsibilities.
The provider depends on the independently published [`@deepseek-ai/libreoffice-kit`](https://github.com/deepseek-ai/dsh-libreoffice-kit) npm API at kit version `0.2.3`. Application packaging selects the matching native package declared in the kit’s `optionalDependencies`, or WASM when no native package is declared for that target. A missing declared native engine rejects packaging without selecting WASM. The [release ownership decision](../../../.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.md) defines the independent kit and Harness responsibilities.
Browsers request PDFs through the `officeToPdf.render` Remote method with a Session identity, Office path, and priority. This entry uses `workspaceFiles` for authorization and source versions, then reads raw bytes through `fs.readBytes` within the conversion reservation. In-process `convert()` does not require those services. Responses retain the source path and version and carry native PDF bytes through the binary Remote multipart transport, plus missing fonts and conversion generation. The `officeToPdf.generation` Remote method returns the current provider generation; `api/remotes` mounts the generated Client descriptor.
@@ -60,7 +60,7 @@ The defaults retain 8 PDFs/128 MiB and 64 source aliases, admit 32 readers and 8
<details>
<summary>Implementation internals — click to expand</summary>
The provider supplies a kit engine-package resolver anchored at the kit's own installation. Native package paths inside an ASAR archive map to the adjacent `.asar.unpacked` copy so child processes can access the engine. WASM retains Node's logical path for Electron's archive-aware filesystem. [Desktop packaging](../../../apps/desktop/packaging/README.md#runtime-file-selection) owns which packages are unpacked.
The provider creates the shared [engine-package resolver](../office-engine-resolver/README.md) with its own `import.meta.resolve('@deepseek-ai/libreoffice-kit')` entry. This anchors engine lookup to the Kit installation used by the provider, not the shared library. The resolver owns native ASAR path mapping and preserves WASM's logical paths; [Desktop packaging](../../../apps/desktop/packaging/README.md#runtime-file-selection) owns which packages are unpacked.
Invalid engine metadata, missing required assets, and conversion errors reject the request instead of switching engines. The shared WASM engine uses LibreOffice's CPU image filter. Native conversion uses its separate platform engine.
+2 -2
View File
@@ -29,7 +29,7 @@ kind: "package-reference"
调用方通过 `ctx.officeToPdf.convert()` 提交已授权源的标识、版本、可选字节数、延迟的有界读取、Office 扩展名和调度优先级。源版本变化会拒绝转换。结果包含调用方拥有的 PDF 字节、缺失字体、缓存键和转换 generation;配置替换后 generation 随之改变。取消以原因为拒绝值,转换失败使用 `OfficeToPdfError`。
此 provider 依赖独立发布的 [`@deepseek-ai/libreoffice-kit`](https://github.com/deepseek-ai/dsh-libreoffice-kit) npm API,kit 版本为 `0.2.3-rc.4`。应用打包选择 kit 的 `optionalDependencies` 中声明的匹配原生包;目标没有声明原生包时选择 WASM。已声明的原生引擎缺失时拒绝打包,不会选择 WASM。[发布归属决策](../../../.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.zh.md)定义独立 kit 与 Harness 各自的职责。
此 provider 依赖独立发布的 [`@deepseek-ai/libreoffice-kit`](https://github.com/deepseek-ai/dsh-libreoffice-kit) npm API,kit 版本为 `0.2.3`。应用打包选择 kit 的 `optionalDependencies` 中声明的匹配原生包;目标没有声明原生包时选择 WASM。已声明的原生引擎缺失时拒绝打包,不会选择 WASM。[发布归属决策](../../../.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.zh.md)定义独立 kit 与 Harness 各自的职责。
浏览器通过 `officeToPdf.render` Remote 方法请求 PDF,参数为 Session 标识、Office 路径和优先级。此入口使用 `workspaceFiles` 完成授权和源版本检查,再通过 `fs.readBytes` 在转换预留容量内读取原始字节。进程内 `convert()` 不要求这些服务。响应保留源文件路径与版本,通过二进制 Remote 的 multipart 传输携带原生 PDF 字节,并携带缺失字体和转换 generation。`officeToPdf.generation` Remote 方法返回当前提供方 generation;`api/remotes` 负责挂载生成的 Client 描述符。
@@ -60,7 +60,7 @@ kind: "package-reference"
<details>
<summary>实现内部细节 — 点击展开</summary>
提供方为 kit 传入以 kit 自身安装位置为起点的引擎包解析器。ASAR 归档内的原生包路径映射到相邻的 `.asar.unpacked` 副本,使子进程能够访问引擎。WASM 保留 Node 逻辑路径,由 Electron 支持归档的文件系统读取。[Desktop 打包](../../../apps/desktop/packaging/README.zh.md#runtime-file-selection)负责决定解包哪些包。
提供方使用自身的 `import.meta.resolve('@deepseek-ai/libreoffice-kit')` 入口创建共享[引擎包解析器](../office-engine-resolver/README.zh.md)。这使引擎查找以提供方实际使用的 Kit 安装为起点,而不是共享库。解析器负责原生 ASAR 路径映射并保留 WASM 逻辑路径;[Desktop 打包](../../../apps/desktop/packaging/README.zh.md#runtime-file-selection)负责决定解包哪些包。
引擎元数据无效、必需资源缺失和转换错误会拒绝请求,不会切换引擎。共享的 WASM 引擎使用 LibreOffice 的 CPU 图像过滤器。原生转换使用独立的平台引擎。
+2 -1
View File
@@ -57,9 +57,10 @@
"@deepseek-ai/dsh-session": "workspace:*"
},
"dependencies": {
"@deepseek-ai/libreoffice-kit": "0.2.3-rc.4",
"@deepseek-ai/libreoffice-kit": "0.2.3",
"@deepseek-ai/schemastery": "workspace:~",
"@deepseek-ai/dsh-brand": "workspace:*",
"@deepseek-ai/dsh-office-engine-resolver": "workspace:*",
"@deepseek-ai/dsh-typert-protocol": "workspace:*",
"zod": "4.4.3"
}
@@ -1,42 +0,0 @@
/** Locate LibreOffice Kit engine packages where their native helpers can execute. */
import { lstatSync } from 'node:fs'
import { createRequire } from 'node:module'
import { join } from 'node:path'
const WASM_ENGINE = '@deepseek-ai/libreoffice-kit-wasm'
/**
* Resolve from the kit's installation, mapping native engines to physical `.asar.unpacked` paths.
* WASM keeps Node's logical path for Electron's archive-aware filesystem, whether or not its assets are unpacked.
* Paths outside an archive pass through unchanged. The kit rejects a missing unpacked copy without switching engines.
* @param name - Engine package name requested by the kit.
* @returns Absolute path of the package's `package.json`.
* @throws The original Node error when resolution fails for an absent package or another reason.
* An installed package with an unresolvable manifest throws an Error with the Node error as its cause,
* preventing engine fallback. Filesystem inspection errors propagate unchanged.
*/
export function resolveEnginePackage(name: string): string {
const fromKit = createRequire(createRequire(import.meta.url).resolve('@deepseek-ai/libreoffice-kit'))
let manifest: string
try {
manifest = fromKit.resolve(`${name}/package.json`)
} catch (error) {
if (error instanceof Error && 'code' in error && error.code === 'MODULE_NOT_FOUND') {
const installed = (fromKit.resolve.paths(name) ?? []).some(directory =>
lstatSync(join(directory, name), { throwIfNoEntry: false }) !== undefined,
)
if (installed) throw new Error(`Installed LibreOfficeKit package is incomplete: ${name}. Reinstall the package.`, { cause: error })
}
throw error
}
return name === WASM_ENGINE ? manifest : unpackedPath(manifest)
}
/**
* Map a path inside an ASAR archive to the archive's unpacked resource directory.
* @param path - Absolute resolved path.
* @returns The same path with its first `.asar` archive segment replaced by `.asar.unpacked`.
*/
export function unpackedPath(path: string): string {
return path.replace(/\.asar(?=[\\/])/, '.asar.unpacked')
}
+3 -2
View File
@@ -9,12 +9,12 @@ import z from '@deepseek-ai/schemastery'
import type { WorkspaceFileScope, WorkspaceFileStat } from '@deepseek-ai/dsh-api-workspace-files'
import type {} from '@deepseek-ai/dsh-fs'
import { brandString } from '@deepseek-ai/dsh-brand'
import { createEnginePackageResolver } from '@deepseek-ai/dsh-office-engine-resolver'
import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
import { OfficeToPdfError } from './errors.ts'
import { OfficeToPdfGeneration, type OfficeSourceKey } from './identity.ts'
import type { OfficeExtension, OfficeToPdfRequest, OfficeToPdfResult, OfficeToPdfPriority, RenderedDocumentBytes } from './types.ts'
import { readPdf } from './output.ts'
import { resolveEnginePackage } from './engine-package.ts'
import { ConversionQueue } from './queue.ts'
export * from './errors.ts'
@@ -119,7 +119,8 @@ export class OfficeToPdf extends TypertRemoteService {
if (config.maxSourceBytes < config.maxInputBytes) throw new Error('maxSourceBytes must be at least maxInputBytes.')
const { fontDirectories, fontFallbacks, timeoutMs, maxInputBytes, maxOutputBytes, maxImageResolution,
maxArchiveEntries, maxUncompressedBytes, maxFontFiles, maxFontFileBytes, maxLoadedFontBytes } = config
this.options = { resolveEnginePackage, timeoutMs, maxInputBytes, maxOutputBytes, maxImageResolution,
this.options = { resolveEnginePackage: createEnginePackageResolver(import.meta.resolve('@deepseek-ai/libreoffice-kit')),
timeoutMs, maxInputBytes, maxOutputBytes, maxImageResolution,
maxArchiveEntries, maxUncompressedBytes, maxFontFiles, maxFontFileBytes, maxLoadedFontBytes,
...(fontDirectories === undefined ? {} : { fontDirectories }),
...(fontFallbacks === undefined ? {} : { fontFallbacks }),
@@ -0,0 +1,21 @@
/** The provider's actual Kit installation is the reference for engine package resolution. */
import { createRequire } from 'node:module'
import { createEnginePackageResolver } from '@deepseek-ai/dsh-office-engine-resolver'
import { expect, it } from 'vitest'
it('matches the consumer Kit installation for native and WASM engine manifests', () => {
const kitEntry = import.meta.resolve('@deepseek-ai/libreoffice-kit')
const fromKit = createRequire(kitEntry)
const resolveEnginePackage = createEnginePackageResolver(kitEntry)
const outcome = (resolve: (name: string) => string, name: string): string => {
try { return resolve(name) } catch (error) {
if (error instanceof Error && 'code' in error) return String(error.code)
throw error
}
}
for (const name of ['@deepseek-ai/libreoffice-kit-wasm', `@deepseek-ai/libreoffice-kit-${process.platform}-${process.arch}`]) {
const expected = outcome(name => fromKit.resolve(`${name}/package.json`), name)
expect(outcome(resolveEnginePackage, name)).toBe(expected)
expect(outcome(resolveEnginePackage, name)).toBe(expected)
}
})
@@ -5,7 +5,12 @@ import { Context } from '@deepseek-ai/cordis'
import type { Converter, ConverterOptions } from '@deepseek-ai/libreoffice-kit'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import OfficeToPdf, { Config, OfficeSourceKey, type OfficeToPdfRequest } from '../src/index.ts'
import { resolveEnginePackage } from '../src/engine-package.ts'
import { createEnginePackageResolver } from '@deepseek-ai/dsh-office-engine-resolver'
vi.mock('@deepseek-ai/dsh-office-engine-resolver', async (original) => {
const actual = await original<typeof import('@deepseek-ai/dsh-office-engine-resolver')>()
return { ...actual, createEnginePackageResolver: vi.fn(actual.createEnginePackageResolver) }
})
const kit = vi.hoisted(() => ({ create: vi.fn<(options?: ConverterOptions) => Promise<Converter>>() }))
vi.mock('@deepseek-ai/libreoffice-kit', () => ({ createConverter: kit.create }))
@@ -26,6 +31,7 @@ let dispose: ReturnType<typeof vi.fn<Converter['dispose']>>
let converter: Converter
beforeEach(() => {
vi.mocked(createEnginePackageResolver).mockClear()
ctx = new Context()
render = vi.fn<Converter['render']>().mockImplementation(async ({ outputPath }) => {
await writeFile(outputPath, pdf)
@@ -47,6 +53,14 @@ async function mount(config: Partial<Config> = {}): Promise<OfficeToPdf> {
return ctx.officeToPdf
}
it('passes a resolver anchored to its own Kit installation to the converter', async () => {
const provider = await mount()
await provider.convert(request)
expect(createEnginePackageResolver).toHaveBeenCalledExactlyOnceWith(import.meta.resolve('@deepseek-ai/libreoffice-kit'))
expect(kit.create.mock.calls[0]?.[0]?.resolveEnginePackage)
.toBe(vi.mocked(createEnginePackageResolver).mock.results[0]?.value)
})
it('preserves the kit font defaults when font configuration is omitted', async () => {
const provider = await mount()
await provider.convert(request)
@@ -91,7 +105,9 @@ it('passes configured limits to the kit, reuses the converter, and removes all s
const { maxConcurrentConversions: _count, maxQueuedJobs: _queued, maxReaders: _readers, maxSourceBytes: _source,
maxBackgroundConversions: _background, maxCachedEntries: _entries, maxCachedBytes: _cached,
maxSourceEntries: _aliases, ...options } = config
expect(kit.create).toHaveBeenCalledExactlyOnceWith({ ...options, resolveEnginePackage })
const resolver = vi.mocked(createEnginePackageResolver).mock.results[0]
if (resolver?.type !== 'return') throw new Error('Expected the engine resolver factory to return')
expect(kit.create).toHaveBeenCalledExactlyOnceWith({ ...options, resolveEnginePackage: resolver.value })
await ctx.fiber.dispose()
expect(dispose).toHaveBeenCalledOnce()
expect(result.pdf).toEqual(Uint8Array.from(pdf))
@@ -8,6 +8,9 @@
"src"
],
"references": [
{
"path": "../office-engine-resolver"
},
{
"path": "../../../vendor/cordis"
},
+1 -1
View File
@@ -41,7 +41,7 @@
"@deepseek-ai/cordis-plugin-include": "workspace:~"
},
"dependencies": {
"@deepseek-ai/libreoffice-kit": "0.2.3-rc.4",
"@deepseek-ai/libreoffice-kit": "0.2.3",
"@deepseek-ai/schemastery": "workspace:~",
"yaml": "^2.4.2"
}
@@ -12,6 +12,10 @@ import { execa } from 'execa'
import { describe, expect, it, vi } from 'vitest'
vi.mock('node:sea', () => ({ isSea: vi.fn(() => false) }))
vi.mock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs/promises')>()
return { ...actual, readFile: vi.fn(actual.readFile) }
})
const assets = fileURLToPath(new URL('../assets/', import.meta.url))
const names = ['office', 'office-docx', 'office-export-pdf', 'office-pptx', 'office-recalculate', 'office-render', 'office-xlsx']
@@ -213,6 +217,43 @@ describe('bundled Office skills', () => {
}
})
it.each([
['an Error without a code', new Error('temporary read failure')],
['a non-Error rejection', 'temporary read failure'],
])('retries discovery and preserves load failures for %s', async (_label, error) => {
const root = await mkdtemp(join(tmpdir(), 'dsh-office-unknown-read-'))
const ctx = new Context()
try {
await ctx.plugin(SkillRegistry)
await cp(assets, root, { recursive: true })
const docx = join(root, 'office-docx', 'SKILL.md')
const actual = await vi.importActual<typeof import('node:fs/promises')>('node:fs/promises')
const read = vi.mocked(readFile)
let failRead = true
try {
read.mockImplementation((path, options) => path === docx && failRead
// oxlint-disable-next-line typescript/prefer-promise-reject-errors -- Exercise primitive read failures.
? Promise.reject(error) : actual.readFile(path, options))
await ctx.plugin(SkillOffice, { assetRoot: root })
const incomplete = await ctx.skills.snapshot()
expect({ complete: incomplete.complete, names: incomplete.skills.map(skill => skill.name) })
.toEqual({ complete: false, names: names.filter(name => name !== 'office-docx') })
failRead = false
const recovered = await ctx.skills.snapshot()
expect({ complete: recovered.complete, names: recovered.skills.map(skill => skill.name) })
.toEqual({ complete: true, names })
failRead = true
await expect(ctx.skills.get('office-docx')).rejects.toBe(error)
} finally {
read.mockReset()
}
expect((await ctx.skills.get('office-docx'))?.content).toContain('python-docx')
} finally {
await ctx.fiber.dispose()
await rm(root, { recursive: true, force: true })
}
})
it('retries read errors other than a missing path instead of omitting the skill', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-office-retry-'))
const ctx = new Context()
@@ -259,7 +300,7 @@ it('executes capabilities using only paths returned by the loaded skill from a s
expect(result.timedOut, result.stderr).toBe(false)
expect(result.signal, result.stderr).toBeUndefined()
expect(result.exitCode, result.stderr).toBe(0)
expect(JSON.parse(result.stdout)).toMatchObject({ runtime: { version: '0.2.3-rc.4', cliPath: libreofficeKit.cli } })
expect(JSON.parse(result.stdout)).toMatchObject({ runtime: { version: '0.2.3', cliPath: libreofficeKit.cli } })
} finally {
await ctx.fiber.dispose()
await rm(root, { recursive: true, force: true })
+38 -32
View File
@@ -875,9 +875,6 @@ importers:
'@deepseek-ai/dsh-workspace':
specifier: workspace:*
version: link:../../packages/workspace/workspace
'@deepseek-ai/libreoffice-kit':
specifier: 0.2.3-rc.4
version: 0.2.3-rc.4
koffi:
specifier: 3.1.1
version: 3.1.1
@@ -3400,8 +3397,8 @@ importers:
specifier: workspace:*
version: link:../../typert/registry
'@deepseek-ai/libreoffice-kit':
specifier: 0.2.3-rc.4
version: 0.2.3-rc.4
specifier: 0.2.3
version: 0.2.3
packages/client/connection:
dependencies:
@@ -8323,17 +8320,26 @@ importers:
specifier: workspace:*
version: link:../../session/working-directory
packages/document/office-engine-resolver:
devDependencies:
'@deepseek-ai/cordis':
specifier: workspace:~
version: link:../../../vendor/cordis
packages/document/office-to-pdf:
dependencies:
'@deepseek-ai/dsh-brand':
specifier: workspace:*
version: link:../../util/brand
'@deepseek-ai/dsh-office-engine-resolver':
specifier: workspace:*
version: link:../office-engine-resolver
'@deepseek-ai/dsh-typert-protocol':
specifier: workspace:*
version: link:../../typert/protocol
'@deepseek-ai/libreoffice-kit':
specifier: 0.2.3-rc.4
version: 0.2.3-rc.4
specifier: 0.2.3
version: 0.2.3
'@deepseek-ai/schemastery':
specifier: link:../../../vendor/schemastery
version: link:../../../vendor/schemastery
@@ -15668,8 +15674,8 @@ importers:
packages/skill/skill-office:
dependencies:
'@deepseek-ai/libreoffice-kit':
specifier: 0.2.3-rc.4
version: 0.2.3-rc.4
specifier: 0.2.3
version: 0.2.3
'@deepseek-ai/schemastery':
specifier: link:../../../vendor/schemastery
version: link:../../../vendor/schemastery
@@ -19140,36 +19146,36 @@ packages:
resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==}
engines: {node: '>=20.19.0'}
'@deepseek-ai/[email protected]-rc.4':
resolution: {integrity: sha512-niYZDbLW/6HsZppDJjLQ9R0/DXK6fHQ7e1nkxtnRiGAb1g/lkn5Qmg0uRnUT7d2onM6W2+WRCtIinV1R2nIpJA==}
'@deepseek-ai/[email protected]':
resolution: {integrity: sha512-0dfiEQR6SqAQjh9hxyY8x77Sj3r+AGC+Xn2A/zF+bag9g40leYPL/c2UirSqYR6xUwE9a/5YgWFIHjw1eOh+3A==}
engines: {node: '>=22.19.0'}
cpu: [arm64]
os: [darwin]
'@deepseek-ai/[email protected]-rc.4':
resolution: {integrity: sha512-LFWOBm11uDK19+QEctA+9AmeJNHFKYv9NEYrOL/CNs1YYxbkeStmyapUPo1b43WM52B07ahZu41GsENqY82VkQ==}
'@deepseek-ai/[email protected]':
resolution: {integrity: sha512-vq0Fp7MTlZwV/yxLpmvQmx1uiiozLTjjODmgrOa9WC5WhrSQYZ+M1OiQKI0qbPAjpMZVBzcZvxlAGnDiTrG4jA==}
engines: {node: '>=22.19.0'}
cpu: [x64]
os: [darwin]
'@deepseek-ai/[email protected]-rc.4':
resolution: {integrity: sha512-0riXzJG75dIzYoht7RIQhQ21cKSUC5pPbLttkSGqLzcUYqKJtSXo2r3R9pAB6AsJI2OVcgqYeRPtuOCsB47W8g==}
'@deepseek-ai/[email protected]':
resolution: {integrity: sha512-g81zHATxolq/HKRglfrSO2gQgj4RZ//nJo98BcTIs9l8GPU4STNcWwplx5CE6Iy/20lZFbwT9jvGw1Key9xZmg==}
engines: {node: '>=22.19.0'}
'@deepseek-ai/[email protected]-rc.4':
resolution: {integrity: sha512-3aS8ZVSUWXaG1LLjj4NmVEw8hZ2YVXRm0APmef5GNlXAVxLVD++UJAKOK6L+5F/lV4XcyTFkhF0YIHE5wms5Qg==}
'@deepseek-ai/[email protected]':
resolution: {integrity: sha512-cAQ0hRZ/8jt8Dudn7vYdpd5OeWpg0AOHzXqgn6UkkPXmQt1/+HCdc4MZbAM927I+c3lwJyLN1fyKIMW5iXCYpg==}
engines: {node: '>=22.19.0'}
cpu: [arm64]
os: [win32]
'@deepseek-ai/[email protected]-rc.4':
resolution: {integrity: sha512-q7ZUURXzAsndoEle5Cw1CoQKjspdbgDxjIyWBVqvg8OCCibCau76J+5PsBrI2GFDYphCKDef0VFYUxzazwcbow==}
'@deepseek-ai/[email protected]':
resolution: {integrity: sha512-GMW+m/09Ts0m4gRUjIckeRN48OVYgJI7aLG1uoUHcG3LRPDsKnsyeRo+BSQn8rh4CmWtJg5c6qtl0uSW7b9zEg==}
engines: {node: '>=22.19.0'}
cpu: [x64]
os: [win32]
'@deepseek-ai/[email protected]-rc.4':
resolution: {integrity: sha512-WyiEKAVW1Odli/tUPU/ok/XlIsRcLlp1xO1sU1zcrAV8mkAAge0LAA0fFDivTc9o0AdUyATZV3RBalwqBqj+Dg==}
'@deepseek-ai/[email protected]':
resolution: {integrity: sha512-MbpucYPrZpeec/smy7u8Z066JeQU6lMVK6A1oysUv1R/OATDvtlVmrsIlfcIz6bVbfJVpgregIK86dOdat9qdA==}
engines: {node: '>=22.19.0'}
hasBin: true
@@ -27025,32 +27031,32 @@ snapshots:
'@csstools/[email protected]': {}
'@deepseek-ai/[email protected]-rc.4':
'@deepseek-ai/[email protected]':
optional: true
'@deepseek-ai/[email protected]-rc.4':
'@deepseek-ai/[email protected]':
optional: true
'@deepseek-ai/[email protected]-rc.4':
'@deepseek-ai/[email protected]':
optional: true
'@deepseek-ai/[email protected]-rc.4':
'@deepseek-ai/[email protected]':
optional: true
'@deepseek-ai/[email protected]-rc.4':
'@deepseek-ai/[email protected]':
optional: true
'@deepseek-ai/[email protected]-rc.4':
'@deepseek-ai/[email protected]':
dependencies:
fflate: 0.8.2
fontkit: 2.0.4
saxes: 6.0.0
optionalDependencies:
'@deepseek-ai/libreoffice-kit-darwin-arm64': 0.2.3-rc.4
'@deepseek-ai/libreoffice-kit-darwin-x64': 0.2.3-rc.4
'@deepseek-ai/libreoffice-kit-wasm': 0.2.3-rc.4
'@deepseek-ai/libreoffice-kit-win32-arm64': 0.2.3-rc.4
'@deepseek-ai/libreoffice-kit-win32-x64': 0.2.3-rc.4
'@deepseek-ai/libreoffice-kit-darwin-arm64': 0.2.3
'@deepseek-ai/libreoffice-kit-darwin-x64': 0.2.3
'@deepseek-ai/libreoffice-kit-wasm': 0.2.3
'@deepseek-ai/libreoffice-kit-win32-arm64': 0.2.3
'@deepseek-ai/libreoffice-kit-win32-x64': 0.2.3
koffi: 3.1.1
'@docsearch/[email protected]': {}
+6 -6
View File
@@ -58,12 +58,12 @@ minimumReleaseAgeExclude:
# Native Codex and Claude tests qualify this exact release with its local patches.
- '@botiverse/[email protected]'
# Office engines and their API are qualified and released together by the kit repository.
- '@deepseek-ai/[email protected]-rc.4'
- '@deepseek-ai/[email protected]-rc.4'
- '@deepseek-ai/[email protected]-rc.4'
- '@deepseek-ai/[email protected]-rc.4'
- '@deepseek-ai/[email protected]-rc.4'
- '@deepseek-ai/[email protected]-rc.4'
- '@deepseek-ai/[email protected]'
- '@deepseek-ai/[email protected]'
- '@deepseek-ai/[email protected]'
- '@deepseek-ai/[email protected]'
- '@deepseek-ai/[email protected]'
- '@deepseek-ai/[email protected]'
# Fresh pi-ai releases carry the model catalog updates that are the whole
# point of bumping it; waiting out the release age would defeat that.
- '@earendil-works/[email protected]'
+1
View File
@@ -55,6 +55,7 @@ const KIND_TEMPLATES: Readonly<Record<string, string>> = {
* library; the check re-derives the entry shape so a stale entry fails loud.
*/
const PACKAGE_LIBRARIES: Readonly<Record<string, string>> = {
'packages/document/office-engine-resolver': 'Consumer-anchored Kit engine manifest resolver without plugin registration.',
'packages/util/file-drag': 'File naming and native transfer declarations without a plugin entry.',
'packages/experimental/browser-use-runtime': 'Provider-owned browser resource management and MCP integration helpers; no plugin entry.',
'packages/boot/app-boot': 'Boot library the app bins import; plain helper exports.',
+1
View File
@@ -38,6 +38,7 @@ const CONFIGURATION_ONLY_DEV_DEPENDENCIES = {
const DUPLICATE_SAFE_PACKAGES: readonly string[] = [
'@deepseek-ai/dsh-brand',
'@deepseek-ai/dsh-lazy-require',
'@deepseek-ai/dsh-office-engine-resolver',
'@deepseek-ai/dsh-typert-protocol',
'@deepseek-ai/dsh-util-code-language',
'@deepseek-ai/dsh-util-crypto',
@@ -232,6 +232,7 @@ describe('package dependency scope', () => {
expect(PACKAGE_DEPENDENCY_POLICY.duplicateSafePackages).toEqual([
'@deepseek-ai/dsh-brand',
'@deepseek-ai/dsh-lazy-require',
'@deepseek-ai/dsh-office-engine-resolver',
'@deepseek-ai/dsh-typert-protocol',
'@deepseek-ai/dsh-util-code-language',
'@deepseek-ai/dsh-util-crypto',
@@ -85,6 +85,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
'packages/browser-use/browser-use': { kind: 'none', reason: 'The registry only reserves a provider name; providers own browser tools and Session resources.' },
'packages/experimental/browser-use-runtime': { kind: 'indirect', reason: 'Browser providers call the library to own resources and expose upstream MCP tools.' },
'packages/computer-use/computer-use': { kind: 'none', reason: 'The registry only reserves provider names; providers own all model-facing tools and guidance.' },
'packages/document/office-engine-resolver': { kind: 'none', reason: 'Engine manifest resolution constructs no model input and registers no services.' },
'packages/document/office-to-pdf': { kind: 'none', reason: 'The provider returns PDF bytes without constructing model input.' },
'packages/credentials/deepseek-account': { kind: 'none', reason: 'Account authorization registers no model context or tools; credentials affect HTTP authentication only.' },
+6
View File
@@ -1,5 +1,6 @@
/** Recorded-session replay through the shipped headless `dsh` profile. */
import { desktopOfficeEnvironment } from '../../apps/desktop/src/office-cli.ts'
import { startHttpMcpFixture } from '../../packages/mcp/mcp-client/tests/http-fixture.ts'
import { chmod, cp, copyFile, mkdir, mkdtemp, readFile, readdir, realpath, rm, symlink, utimes, writeFile } from 'node:fs/promises'
import { existsSync } from 'node:fs'
@@ -1406,6 +1407,10 @@ describe('headless recorded-session snapshots', () => {
? await startHttpMcpFixture({ rejectToolCalls: scenario.name === 'mcp-template-error' }) : undefined
let result: Awaited<ReturnType<typeof runLoaderSmoke>>
try {
const office = scenario.name === 'office-sidecar-instructions'
? desktopOfficeEnvironment(join(spillRoot, 'app.asar', 'dsh'), join(spillRoot, 'runtime', 'primary-runtime'))
: undefined
if (office !== undefined) expect(office.kind).toBe('kit-unavailable')
result = await runLoaderSmoke({
label: `${scenario.name} headless snapshot`,
tempDirPrefix: 'dsh-log-snap-',
@@ -1445,6 +1450,7 @@ describe('headless recorded-session snapshots', () => {
? {}
: { DSH_PERMISSION_MODE: scenario.manifest.permission }),
...scenario.manifest.environment,
...office?.environment,
...(scenario.name === 'mcp-resources' || scenario.name === 'mcp-resources-ptc' ? {
DSH_MCP_RESOURCES_FIXTURE: join(repoRoot, 'packages/mcp/mcp-client/tests/fixtures/resources-server.ts'),
} : {}),
@@ -52,7 +52,7 @@
disabled: false
config:
assetRoot: !!js process.cwd() + '/office-skills'
cli: false
cli: !!js process.env.DSH_BUNDLED_OFFICE_CLI ?? false
- id: tool-bash
disabled: true
@@ -4,7 +4,7 @@
disabled: false
config:
assetRoot: !!js process.cwd() + '/office-skills'
cli: false
cli: !!js process.env.DSH_BUNDLED_OFFICE_CLI ?? false
- id: tool-bash
disabled: true
+1
View File
@@ -490,6 +490,7 @@
"@deepseek-ai/dsh-message-feedback": ["./packages/feedback/message-feedback/src"],
"@deepseek-ai/dsh-message-quotes": ["./packages/session/message-quotes/src"],
"@deepseek-ai/dsh-native-command": ["./packages/util/native-command/src"],
"@deepseek-ai/dsh-office-engine-resolver": ["./packages/document/office-engine-resolver/src"],
"@deepseek-ai/dsh-office-to-pdf": ["./packages/document/office-to-pdf/src"],
"@deepseek-ai/dsh-official-bundles": ["./packages/util/official-bundles/src"],
"@deepseek-ai/dsh-output-retention": ["./packages/util/output-retention/src"],
+1
View File
@@ -323,6 +323,7 @@
{ "path": "./packages/client/ui-sidebar-documentpreview/tsconfig.host.json" },
{ "path": "./packages/client/ui-sidebar-browser/tsconfig.host.json" },
{ "path": "./packages/client/ui-settings-general/tsconfig.host.json" },
{ "path": "./packages/document/office-engine-resolver" },
{ "path": "./packages/document/office-to-pdf" },
{ "path": "./packages/api/account-controller" },