Merge master and keep current coding view settings copy
This commit is contained in:
commit
7f7225ea7c
283 files changed
+6383
-1462
No files matched your search
+6
-6
@@ -9,11 +9,11 @@
|
||||
en: 67e580ac6d9298d9
|
||||
zh: bb2bd7486090bde4
|
||||
/agent-note-use-electron-as-the-desktop-node-runtime/decision:
|
||||
en: 3cac5d877b18179d
|
||||
zh: 0376bce7b099e0a6
|
||||
en: 625ed4349cc70849
|
||||
zh: b0aa30ab993e858d
|
||||
/agent-note-use-electron-as-the-desktop-node-runtime/consequences:
|
||||
en: b407d0ce648beea5
|
||||
zh: 7ab329aa1d7a237c
|
||||
en: 3de90a3734d0f405
|
||||
zh: 4d26d953e05e0337
|
||||
/agent-note-use-electron-as-the-desktop-node-runtime/alternatives-considered:
|
||||
en: 880e255d56b45345
|
||||
zh: 5952d9685c564284
|
||||
en: 402978396810dca3
|
||||
zh: 5146ec7ad3cabdc1
|
||||
@@ -10,18 +10,18 @@ Shipping an upstream Node executable alongside Electron duplicates the JavaScrip
|
||||
|
||||
## Decision
|
||||
|
||||
Desktop runs the shared Web Host and bundled pnpm through its own Electron executable with `ELECTRON_RUN_AS_NODE=1`. It ships no separate upstream Node executable. The target Electron distribution supplies both the packaging input and the runtime used to prepare and verify production dependencies; release metadata records its actual Node version. Development uses the installed Electron distribution.
|
||||
Desktop runs the shared Web Host and bundled pnpm through its own Electron executable with `ELECTRON_RUN_AS_NODE=1`. The [primary runtime](../feature/2026-09-14-desktop-primary-runtime.md) separately supplies Node for Office authoring. The target Electron distribution supplies both the packaging input and the runtime used to prepare and verify production dependencies; release metadata records its actual Node version. Development uses the installed Electron distribution. The [installed CLI](../feature/2026-09-27-desktop-cli-runtime.md) reuses this execution environment.
|
||||
|
||||
This supersedes the separate-Node choice in the [packaging decision](2026-08-25-electron-desktop-packaging-and-updates.md) and [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md). Their independent plugin storage and ordinary resource-directory layout remain applicable. The [Web wrapper](2026-09-10-desktop-web-wrapper.md) retains the shared profile runner and HTTP transport.
|
||||
This supersedes the separate-Node choice in the [packaging decision](2026-08-25-electron-desktop-packaging-and-updates.md) and [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md). Their independent plugin storage remains applicable. The [Web wrapper](2026-09-10-desktop-web-wrapper.md) retains the shared profile runner and HTTP transport.
|
||||
|
||||
## Consequences
|
||||
|
||||
Host and pnpm launches pass `--expose-internals`: the bundled Cordis loader uses Node's internal ESM loader, while its native builtin accessor cannot locate the required symbol in Electron 44. The explicit flag makes that loader available without modifying Cordis. The RunAsNode fuse remains enabled.
|
||||
|
||||
The Host inherits the caller PATH without Desktop’s private `bin` directory, so PTC and agent shells cannot resolve internal launchers through that directory. `DSH_DESKTOP_NODE_EXECUTABLE` is injected only for package installation. Package-script environments prepend a small `node` shell launcher that forwards arguments to the current Electron executable. This supports shell lifecycle scripts without a system Node installation. On Windows it is `node.cmd`, not a replacement `node.exe`; third-party code that directly spawns the literal `node` executable without a shell must use `process.execPath` or provide its own runtime. Child processes inherit RunAsNode; worker threads inherit the Host's arguments. Desktop does not emulate upstream OpenSSL behavior or rebuild arbitrary third-party native addons automatically.
|
||||
The Host inherits Desktop’s PATH, merged with the [login-shell environment](../feature/2026-09-28-desktop-login-shell-environment.md) on macOS and Linux, without Desktop’s private `bin` directory, so PTC and agent shells cannot resolve internal launchers through that directory. `DSH_DESKTOP_NODE_EXECUTABLE` is injected only for package installation. Package-script environments prepend a small `node` shell launcher that forwards arguments to the current Electron executable. This supports shell lifecycle scripts without a system Node installation. On Windows it is `node.cmd`, not a replacement `node.exe`; third-party code that directly spawns the literal `node` executable without a shell must use `process.execPath` or provide its own runtime. Child processes inherit RunAsNode; worker threads inherit the Host's arguments. Desktop does not emulate upstream OpenSSL behavior or rebuild arbitrary third-party native addons automatically.
|
||||
|
||||
Electron's Node patches and native ABI are release compatibility obligations. The packaged native smoke exercises pnpm shell scripts without system Node on PATH, terminal output through the Windows shell, Koffi, Sharp, and HTML conversion. The Host smoke loads an external plugin sharing Cordis and serves its route through the real Web application. Platform signing and installed-application qualification remain required; Windows results do not establish macOS compatibility. Windows token signing runs serially and retains the first failure, preventing queued tasks from repeating a rejected PIN.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
A separate Node executable decouples the Host from Electron's runtime but adds another binary, download, signature, and version selection. Electron RunAsNode removes that duplication. Moving production packages into ASAR is a separate change involving native modules, package resolution, and subprocess paths; the Host continues to load ordinary resource files.
|
||||
A separate Node executable decouples the Host from Electron's runtime but adds another binary, download, signature, and version selection for that role. Electron RunAsNode reuses the GUI executable. Production packages reside in ASAR with native resources unpacked; packaging and physical-resource resolution remain separate from the execution-engine choice.
|
||||
+4
-4
@@ -10,18 +10,18 @@ Status: implemented
|
||||
|
||||
## 决策
|
||||
|
||||
Desktop 通过自己的 Electron 可执行文件运行共享 Web Host 和内置 pnpm,并设置 `ELECTRON_RUN_AS_NODE=1`。应用不携带独立的上游 Node 可执行文件。目标 Electron 分发包同时作为打包输入,以及准备和验证生产依赖的运行时;发布元数据记录其实际 Node 版本。开发模式使用已安装的 Electron 分发包。
|
||||
Desktop 通过自己的 Electron 可执行文件运行共享 Web Host 和内置 pnpm,并设置 `ELECTRON_RUN_AS_NODE=1`。[主运行时](../feature/2026-09-14-desktop-primary-runtime.zh.md)另行提供 Office 创作使用的 Node。目标 Electron 分发包同时作为打包输入,以及准备和验证生产依赖的运行时;发布元数据记录其实际 Node 版本。开发模式使用已安装的 Electron 分发包。[安装包中的 CLI](../feature/2026-09-27-desktop-cli-runtime.zh.md)复用此执行环境。
|
||||
|
||||
此决策替代[打包决策](2026-08-25-electron-desktop-packaging-and-updates.zh.md)和[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)中的独立 Node 选择。独立插件存储和普通资源目录布局仍然适用。[Web 薄壳](2026-09-10-desktop-web-wrapper.zh.md)保留共享 profile runner 和 HTTP 传输。
|
||||
此决策替代[打包决策](2026-08-25-electron-desktop-packaging-and-updates.zh.md)和[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)中的独立 Node 选择。独立插件存储仍然适用。[Web 薄壳](2026-09-10-desktop-web-wrapper.zh.md)保留共享 profile runner 和 HTTP 传输。
|
||||
|
||||
## 后果
|
||||
|
||||
Host 和 pnpm 启动时传入 `--expose-internals`:内置 Cordis 加载器使用 Node 内部 ESM 加载器,而其原生 builtin 访问器无法在 Electron 44 中找到所需符号。显式参数使加载器可用,无需修改 Cordis。RunAsNode fuse 保持启用。
|
||||
|
||||
Host 继承调用者的 PATH,不加入 Desktop 私有的 `bin` 目录,因此 PTC 和 agent shell 不会通过该目录解析内部启动器。`DSH_DESKTOP_NODE_EXECUTABLE` 仅为包安装注入。包脚本环境在 PATH 前添加一个小型 `node` shell 启动器,把参数转发给当前 Electron 可执行文件。这支持没有系统 Node 的 shell 生命周期脚本。Windows 上它是 `node.cmd`,并非替代的 `node.exe`;第三方代码若绕过 shell 直接启动名为 `node` 的可执行文件,必须使用 `process.execPath` 或提供自己的运行时。子进程继承 RunAsNode;worker 线程继承 Host 参数。Desktop 不模拟上游 OpenSSL 行为,也不自动重编译任意第三方原生扩展。
|
||||
Host 继承 Desktop 的 PATH(macOS 和 Linux 上合并了[登录 shell 环境](../feature/2026-09-28-desktop-login-shell-environment.zh.md)),不加入 Desktop 私有的 `bin` 目录,因此 PTC 和 agent shell 不会通过该目录解析内部启动器。`DSH_DESKTOP_NODE_EXECUTABLE` 仅为包安装注入。包脚本环境在 PATH 前添加一个小型 `node` shell 启动器,把参数转发给当前 Electron 可执行文件。这支持没有系统 Node 的 shell 生命周期脚本。Windows 上它是 `node.cmd`,并非替代的 `node.exe`;第三方代码若绕过 shell 直接启动名为 `node` 的可执行文件,必须使用 `process.execPath` 或提供自己的运行时。子进程继承 RunAsNode;worker 线程继承 Host 参数。Desktop 不模拟上游 OpenSSL 行为,也不自动重编译任意第三方原生扩展。
|
||||
|
||||
Electron 的 Node 补丁和原生 ABI 属于发布兼容性责任。打包原生 smoke 在 PATH 不含系统 Node 的情况下验证 pnpm shell 脚本,并验证 Windows shell 终端输出、Koffi、Sharp 和 HTML 转换。Host smoke 加载共享 Cordis 的外部插件,通过真实 Web 应用提供其路由。各平台仍需完成签名和已安装应用验收;Windows 结果不能证明 macOS 兼容性。Windows Token 签名串行执行并保留首次失败,阻止排队任务重复提交被拒绝的 PIN。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
独立 Node 可执行文件可以让 Host 与 Electron 运行时分离,但会增加另一份二进制文件、下载、签名和版本选择。Electron RunAsNode 消除这一重复。将生产包移入 ASAR 是另一项涉及原生模块、包解析和子进程路径的改动;Host 继续加载普通资源文件。
|
||||
独立 Node 可执行文件可以让 Host 与 Electron 运行时分离,但会为该职责增加另一份二进制文件、下载、签名和版本选择。Electron RunAsNode 复用 GUI 可执行文件。生产包位于 ASAR 中,原生资源解包存放;打包与物理资源解析仍独立于执行引擎选择。
|
||||
@@ -9,8 +9,8 @@
|
||||
en: 2c774d05ae75784f
|
||||
zh: 619ba7f281b13233
|
||||
/agent-note-host-owned-scheduled-messages/decision:
|
||||
en: 4a38d9bd933241b0
|
||||
zh: b246c74f68e92a25
|
||||
en: de81925284f13c12
|
||||
zh: d0ef35c8b58813f2
|
||||
/agent-note-host-owned-scheduled-messages/alternatives-considered:
|
||||
en: 21d7ec9a9ff3cbf2
|
||||
zh: 93d03b8b3012aa7e
|
||||
|
||||
@@ -20,7 +20,7 @@ A plugin-sourced `followup()` synchronously appends the message to the Session i
|
||||
|
||||
New tasks initialize an empty `deliveryHistory`. A task without that optional field exposes its existing latest receipt, if any, and marks earlier records unavailable. Reading never rewrites the old task or invents the lost history or instruction snapshot. Its next acknowledged delivery preserves that available receipt and appends the new one. Strict decoding rejects duplicate message ids and disagreement between the latest receipt and the last history entry. History follows append order, not wall-clock sorting, so clock rollback does not reverse acknowledged deliveries.
|
||||
|
||||
A recurring task contributes its latest due occurrence once, including after Host restart. Every remains creation-aligned until an interval edit establishes a new anchor; Daily retains a local time and explicit IANA zone, and Weekly adds a non-empty ISO weekday set stored as unique ascending numbers. Every, Daily, Weekly, and Cron use `renderRecurringReminderBatchFraming` and share one follow-up message and its real message id when due for the same Session. A recurring task with no representable next target remains inactive with its last receipt. A stopped Host does not send messages.
|
||||
A recurring task contributes its latest due occurrence once, including after Host restart. Every remains creation-aligned until an interval edit establishes a new anchor; Daily retains a local time and explicit IANA zone, and Weekly adds a non-empty ISO weekday set stored as unique ascending numbers. Every, Daily, Weekly, and Cron use `renderRecurringReminderBatchFraming` and share one follow-up message and its real message id when due for the same Session. The fixed text both renderers emit is the [delivered-reminder origin line](2026-09-24-scheduled-message-framing.md) decision. A recurring task with no representable next target remains inactive with its last receipt. A stopped Host does not send messages.
|
||||
|
||||
Daily is a fourth mutually exclusive creation selector, Weekly a fifth, and Cron a sixth, none a conversion of `every_seconds` or `at`. Weekly selects every local date whose ISO weekday belongs to its stored set; the decoder rejects an unnormalized stored set, and the selector rejects an empty, repeated, out-of-range, or non-integer one. Their first targets are strictly future. Missing local times and dates are skipped; overlaps select the earlier instant once per date. The next target is after the delivery decision and on a local date later than the selected occurrence's date. Only UTC targets are restricted to years 0001–9999; local dates may cross into year 0 or 10000 at the edges. After Session restoration, the runtime rechecks due members against the current clock so a rollback does not admit future tasks.
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ Status: implemented
|
||||
|
||||
新任务初始化空的 `deliveryHistory`。缺少该可选字段的任务会展示已有的最近一次回执(如果存在),并标明更早记录不可用。读取不会重写旧任务,也不会虚构已丢失的历史或指令快照。下一次确认投递时,保留已有回执并追加新记录。严格解码拒绝重复消息 id,以及最近一次回执与历史末项不一致的情况。历史按追加顺序排列,不按墙钟排序,因此时钟回拨不会颠倒已确认投递的顺序。
|
||||
|
||||
重复任务只投递一次最新到期发生时点,Host 重启后也一样。Every 保持与创建时间对齐,直到修改间隔后建立新的起算时点;Daily 保留本地时间与显式 IANA 时区,Weekly 另存非空 ISO 星期集合(唯一且升序)。Every、Daily、Weekly 与 Cron 均使用 `renderRecurringReminderBatchFraming`,同一 Session 的到期任务共用一条 follow-up 消息及其真实消息 id。没有可表示的下一目标时,重复任务保留未运行状态及最近一次回执。停止的 Host 不会发送消息。
|
||||
重复任务只投递一次最新到期发生时点,Host 重启后也一样。Every 保持与创建时间对齐,直到修改间隔后建立新的起算时点;Daily 保留本地时间与显式 IANA 时区,Weekly 另存非空 ISO 星期集合(唯一且升序)。Every、Daily、Weekly 与 Cron 均使用 `renderRecurringReminderBatchFraming`,同一 Session 的到期任务共用一条 follow-up 消息及其真实消息 id。两个渲染器输出的固定文本见[投递提醒的来源行](2026-09-24-scheduled-message-framing.zh.md)决策。没有可表示的下一目标时,重复任务保留未运行状态及最近一次回执。停止的 Host 不会发送消息。
|
||||
|
||||
Daily 是互斥的第四个创建选择器,Weekly 是第五个,Cron 是第六个,三者都不是对 `every_seconds` 或 `at` 的转换。Weekly 选择本地日期 ISO 星期属于其所存集合的每个日期;解码器拒绝未规范化的已存集合,选择器拒绝空、重复、超出范围或非整数的集合。它们的首个目标严格晚于当前时间。缺失的本地时间或日期被跳过;重叠时间选择较早时点,每个日期一次。下一目标晚于投递判断时刻,且其本地日期晚于所选发生时点的日期。仅 UTC 目标限制在 0001–9999 年;边缘处的本地日期可以进入第 0 年或第 10000 年。Session 恢复后,运行时依据当前时钟重新核对到期成员,避免时钟回拨时接纳未来任务。
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# Bilingual-pair consistency record for 2026-09-24-scheduled-message-framing.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 .agents/notes/implemented/architecture/2026-09-24-scheduled-message-framing.md
|
||||
/agent-note-delivered-reminders-are-framed-as-scheduled-messages-from-the-user:
|
||||
en: f524c3b0ee74c586
|
||||
zh: ec4e85f0fd853532
|
||||
/agent-note-delivered-reminders-are-framed-as-scheduled-messages-from-the-user/problem:
|
||||
en: c4156e2a8a3ad379
|
||||
zh: 992b76509f83d61f
|
||||
/agent-note-delivered-reminders-are-framed-as-scheduled-messages-from-the-user/decision:
|
||||
en: b9b203da394f58e5
|
||||
zh: ac511458d60dab49
|
||||
/agent-note-delivered-reminders-are-framed-as-scheduled-messages-from-the-user/alternatives-considered:
|
||||
en: 8074a2b9b9f5d782
|
||||
zh: 1604b7ee5d34375e
|
||||
/agent-note-delivered-reminders-are-framed-as-scheduled-messages-from-the-user/consequences:
|
||||
en: 85e03b541bfce57c
|
||||
zh: 77decbb804f6a0b9
|
||||
@@ -0,0 +1,29 @@
|
||||
# Agent Note: Delivered reminders are framed as scheduled messages from the user
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-24-scheduled-message-framing.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Due reminders reach their original Session as user-role messages with producer kind `schedule`. Before this decision, both renderers opened that message with an instruction about how to treat its payload: one-shot delivery asked the model to present `reminder_prompt_json` as untrusted reminder content and not as new user instructions, and recurring batch delivery asked the same for every `reminder_prompt` in `reminders_json`. The message therefore asserted that its own content was untrusted instead of naming where it came from.
|
||||
|
||||
## Decision
|
||||
|
||||
`renderReminderFraming` and `renderRecurringReminderBatchFraming` in [`packages/schedule/schedule/src/domain.ts`](../../../../packages/schedule/schedule/src/domain.ts) both emit one shared fixed line, `SCHEDULED_MESSAGE_FRAMING`: `This is a scheduled message from the user`. It is the block's second line, after the bracketed marker (`[SCHEDULE REMINDER]`, `[SCHEDULE REMINDER BATCH]`), and the dynamic field lines are unchanged: a one-shot message still appends `schedule_id_json`, `occurrence_at`, and `reminder_prompt_json`, and a recurring batch still appends `reminders_json`.
|
||||
|
||||
The fixed line states the origin of the message: a schedule bound to that Session. A model reading a user-role message whose fixed block carries it reads the following reminder payload (`reminder_prompt_json` in a one-shot message, `reminders_json` in a batch) as content the user scheduled, and the Host cannot attest that the user typed it: `schedule_create` and `schedule_update` let the model write the prompt, and a model-written prompt can carry text the model read from the web, files, or command output. The previous line marked that payload untrusted, and this decision drops that instruction, so the change accepts the residual risk that model-authored text arrives under a user-origin line. Reintroduction condition: a demonstrated injection through a model-written prompt restores a clause that scopes the payload as reminder text rather than a new instruction, and the five pinned framing assertions in the package specs plus `expectReminderFraming` in the Web e2e are the check that such a change is deliberate. Framing-line forgery is closed by encoding: the schedule id and prompt are JSON strings, the occurrence instant is a validated canonical RFC 3339 value, and each occupies one line, so a prompt carrying embedded newlines or an `occurrence_at:`-style line cannot add framing lines to the block. The [Host-owned scheduled messages](2026-09-16-host-schedule-storage.md) note keeps the rest of its decision, including delivery through the Session controller and the saved delivery records.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep the untrusted-content instruction.** It asserted that the prompt was untrusted input, which the delivered message does not need: the prompt is written in the Session the schedule is bound to — by the model through `schedule_create` and `schedule_update`, or by the user in the Web task detail — and the delivery is an ordinary user-role message there.
|
||||
|
||||
**Change only the recurring batch line.** One-shot and recurring delivery would then disagree about the origin of the same kind of message, and each later wording change would need both renderers edited separately. Both paths now read one constant.
|
||||
|
||||
## Consequences
|
||||
|
||||
Model-visible reminder text changes for both delivery paths, so a Session log recorded before this decision reconstructs the previous framing, and the Web notice that renders a delivered message shows the new line when expanded. Nothing else moves: no session event, storage field, client code, protocol, or durable format changes, and the delivered text remains reconstructable from the log.
|
||||
|
||||
The prompt text carries no authorship guarantee. It is written in the Session the schedule is bound to, by the model through `schedule_create` and `schedule_update` or by the user in the Web task detail, and a model-written reminder can quote text the model read from the web, files, or command output. The task's Session binding scopes which Session's model reaches the task; it is not caller authorization and says nothing about where the prompt content came from. The Decision holds the accepted risk and its reintroduction condition.
|
||||
|
||||
No recorded-session snapshot covers a delivered reminder, and this change adds none: no committed fixture contains a `schedule` message, and `snapshots/web/schedule-catalog` records catalog queries only. Two harness capabilities are missing for such a fixture: no scenario patch controls the wall clock that `packages/schedule/schedule/src/runtime.ts` reads for delivery, and the snapshot harness seeds Sessions rather than the schedule storage domain that holds Host tasks, so those rows exist only where a Web test writes them by hand. This change records the missing harness support instead of a fixture. The delivered text is pinned by the five full-string assertions in `packages/schedule/schedule/tests/{domain,daily,weekly,cron}.spec.ts` and by `expectReminderFraming` in `apps/web/tests/schedule-after.e2e.ts`, which asserts the line in the assembled request for the one-shot and recurring paths.
|
||||
@@ -0,0 +1,29 @@
|
||||
# Agent Note: 投递的提醒以「用户安排的定时消息」为固定文本
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-24-scheduled-message-framing.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
到期的提醒以生产者 kind 为 `schedule` 的 user-role 消息进入其原 Session。在本决策之前,两个渲染器都用一句关于如何处理其载荷的固定指令开篇:单次投递要求模型把 `reminder_prompt_json` 作为不可信的提醒内容呈现、而不是新的用户指令;周期批次投递对 `reminders_json` 中的每个 `reminder_prompt` 提出同样的要求。于是这条消息断言自身内容不可信,而没有说明它来自哪里。
|
||||
|
||||
## Decision
|
||||
|
||||
[`packages/schedule/schedule/src/domain.ts`](../../../../packages/schedule/schedule/src/domain.ts) 中的 `renderReminderFraming` 与 `renderRecurringReminderBatchFraming` 都输出同一条固定行,即模块常量 `SCHEDULED_MESSAGE_FRAMING`:`This is a scheduled message from the user`;它是该块的第二行,位于方括号标记(`[SCHEDULE REMINDER]`、`[SCHEDULE REMINDER BATCH]`)之后。动态字段行不变:单次消息仍追加 `schedule_id_json`、`occurrence_at`、`reminder_prompt_json`,周期批次仍追加 `reminders_json`。
|
||||
|
||||
这条固定行说明消息的来源:绑定到该 Session 的一条定时任务。模型读到固定块中带有它的 user-role 消息时,会把随后的提醒载荷(单次消息的 `reminder_prompt_json`、批次的 `reminders_json`)当作用户安排的内容,而 Host 无法证明该文本由用户键入:`schedule_create` 与 `schedule_update` 允许模型写入提示词,模型写入的提示词可以带上模型从网页、文件或命令输出中读到的文本。旧固定行把该载荷标记为不可信,本决策有意去掉这一指令,因此接受「模型生成的文本以用户来源行投递」这一残留风险。再引入条件:一旦出现经由模型写入提示词完成注入的实例,就恢复一句把载荷限定为提醒文本、而非新指令的说明;包内五处固定断言与 Web e2e 的 `expectReminderFraming` 是「此类改动必须是有意为之」的检查。固定行无法被动态字段伪造,这由编码保证:schedule id 与提示词是 JSON 字符串,发生时点是经校验的规范 RFC 3339 值,且各自只占一行,因此带有换行或形如 `occurrence_at:` 的提示词无法向该块增加固定行。[Host 拥有的定时消息](2026-09-16-host-schedule-storage.zh.md) 记下的其余决策不变,包括通过 Session controller 投递以及保存的投递记录。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**保留「不可信内容」指令。** 它断言提示词是不可信输入,而投递的消息并不需要这一断言:提示词写在绑定该定时任务的 Session 内——由模型通过 `schedule_create` 与 `schedule_update` 写入,或由用户在 Web 任务详情中写入——投递本身就是该 Session 中的普通 user-role 消息。
|
||||
|
||||
**只改周期批次那一行。** 单次与周期投递会对同类消息的来源给出不同说法,此后每次改措辞都要分别改两个渲染器。现在两条路径读同一个常量。
|
||||
|
||||
## Consequences
|
||||
|
||||
两个投递路径的模型可见提醒文本都改变,因此在该决策之前记录的 Session 日志重建出的是旧的固定文本;Web 中渲染该投递消息的通知在展开时显示新的一行。其余不动:没有 session 事件、存储字段、客户端代码、协议或持久化格式变化,投递文本仍可从日志重建。
|
||||
|
||||
提示词文本不携带作者保证。它写在绑定该定时任务的 Session 内——由模型通过 `schedule_create` 与 `schedule_update` 写入,或由用户在 Web 任务详情中写入——模型创建的提醒可以把模型从网页、文件或命令输出中读到的文本原样写入。任务的 Session 绑定限定的是哪个 Session 的模型能访问该任务;它不是调用方鉴权,也不说明提示词内容的来源。本决策中记下该残留风险与再引入条件。
|
||||
|
||||
没有录制会话快照覆盖提醒投递,本次也没有新增:已提交夹具中没有 `schedule` 消息,`snapshots/web/schedule-catalog` 只记录任务查询。构成该夹具还缺两项 harness 能力:没有场景补丁能控制 `packages/schedule/schedule/src/runtime.ts` 读取的墙上时钟;快照 harness 播种的是 Session,而不是承载 Host 任务的 schedule storage domain,那些任务行只在 Web 测试里手工写入。因此本次记录缺失的 harness 支持,而不新增夹具。投递文本改由 `packages/schedule/schedule/tests/{domain,daily,weekly,cron}.spec.ts` 中五处整串断言,以及 `apps/web/tests/schedule-after.e2e.ts` 中在装配后请求上断言该行、覆盖单次与周期两条路径的 `expectReminderFraming` 固定。
|
||||
+2
-2
@@ -12,8 +12,8 @@
|
||||
en: 125dea7427047966
|
||||
zh: 1e459b1106597695
|
||||
/agent-note-windows-sandbox-rung-raw-acl-restricted-tokens-over-mxc-and-appcontainer/acl-diagnosis-and-recovery:
|
||||
en: 8ea2f91a914d540b
|
||||
zh: cafada631044e43a
|
||||
en: a5bcdf2a6133abfe
|
||||
zh: 477d4915d61945e3
|
||||
/agent-note-windows-sandbox-rung-raw-acl-restricted-tokens-over-mxc-and-appcontainer/how-the-restriction-works-why-no-new-identity:
|
||||
en: ac9b3d800889ea39
|
||||
zh: 9db1065b6027f65f
|
||||
|
||||
+2
-2
@@ -16,9 +16,9 @@ Implement the rung directly on the raw ACL mechanism: duplicate the caller's tok
|
||||
|
||||
The bundled diagnosis skill runs its preserving ACL repairs outside the confined token because that token cannot inspect the conflicting objects. External PowerShell requires physical script files, so registration extracts resources from ASAR or SEA instead of exposing archive paths. The fiber owns that temporary copy. Recovery files live in the caller's persistent output directory so disposal of the skill does not remove the means to restore a DACL.
|
||||
|
||||
A repair backs up before mutation and verifies effective access or the resulting ACEs. Failed invocations restore attempted changes in reverse order and remain failures: continuing after a failed grant would remove access without establishing the prerequisite. Inherited package entries are repaired at their explicit sources, preserving inheritance; incomplete or out-of-scope sources prevent mutation. Labels and denies remain untouched because removing them could defeat the backend's confinement or an intentional user policy.
|
||||
A repair backs up before mutation and verifies effective access or the resulting ACEs. One invocation performs every repair it can prove, so a single sandbox failure needs one approval instead of a diagnosis followed by a repair: it adds the caller's full-control ACE to the chain directories that lack effective `WRITE_DAC` or `WRITE_OWNER`, and removes explicit package allow ACEs at their sources. The authorized root itself may be modified and restored, so a workspace root can repair and roll itself back, and recovery artifacts are one DACL record plus one copy of the restoring script per modified object. When the requested directory is one the caller cannot provision — the object a provisioning error names — that same invocation also examines its subtree for explicit package allow ACEs through a bounded walk that reports truncation, because the conflicting entry often sits below the reported root. Failed invocations restore attempted changes in reverse order and remain failures: continuing after a failed change would remove access without establishing the prerequisite. Inherited package entries are repaired at their explicit sources, preserving inheritance; incomplete or out-of-scope sources prevent mutation. Labels and denies remain untouched because removing them could defeat the backend's confinement or an intentional user policy.
|
||||
|
||||
Both extracted and recovery scripts are user-writable, so this workflow never recommends elevating them. Missing unconfined WRITE_DAC stops the workflow for permission-policy review. Automatic recovery can itself fail and cannot survive process termination; reports retain pending recovery commands rather than promising an atomic transaction. The operator keeps those artifacts until verification of the original confined operation succeeds.
|
||||
Both extracted and recovery scripts are user-writable, so this workflow never recommends elevating them. Missing unconfined WRITE_DAC stops the workflow for permission-policy review. Automatic recovery can itself fail and cannot survive process termination; reports retain pending recovery commands rather than promising an atomic transaction. Because tool output keeps only its tail, every record is also written to a report file under the output directory and the run ends with a recap line, so the decisions survive truncation. The operator keeps those artifacts until verification of the original confined operation succeeds.
|
||||
|
||||
## How the restriction works (why no new identity)
|
||||
|
||||
|
||||
+2
-2
@@ -17,9 +17,9 @@ Status: implemented
|
||||
|
||||
内置诊断技能在受限令牌之外执行保留原有权限的 ACL 修复,因为受限令牌无法检查发生冲突的对象。外部 PowerShell 需要实际脚本文件,因此注册时从 ASAR 或 SEA 提取资源,而不提供归档内路径。fiber 拥有该临时副本。恢复文件保存在调用者指定的持久输出目录中,确保技能释放后仍能恢复 DACL。
|
||||
|
||||
修复在修改前备份,并验证有效权限或生成的 ACE。失败调用按相反顺序恢复尝试修改的内容,且仍视为失败:授权失败后继续操作,会在前置条件尚未满足时移除访问权限。继承的包条目从显式来源处修复,保持继承不变;来源信息不完整或超出范围时不做修改。标签与拒绝条目保持不变,因为移除它们可能破坏后端隔离或用户有意设置的策略。
|
||||
修复在修改前备份,并验证有效权限或生成的 ACE。一次调用会完成它能证明的全部修复,因此单个沙箱失败只需一次批准,而不必先诊断再修复:为链路上缺少有效 `WRITE_DAC` 或 `WRITE_OWNER` 的目录添加调用者自身的完全控制 ACE,并在来源处移除显式的包允许 ACE。授权根目录本身也可以被修改和恢复,因此工作区根目录能自我修复并回滚;恢复产物为每个被修改对象一份 DACL 记录和一份恢复脚本副本。当请求的目录正是调用者无法授权的那一个——即授权失败错误所指向的对象——同一次调用还会通过受上限约束、并在截断时如实报告的遍历,检查其子树中的显式包允许 ACE,因为冲突条目常常位于被报告的根目录之下。失败调用按相反顺序恢复尝试修改的内容,且仍视为失败:某次修改失败后继续操作,会在前置条件尚未满足时移除访问权限。继承的包条目从显式来源处修复,保持继承不变;来源信息不完整或超出范围时不做修改。标签与拒绝条目保持不变,因为移除它们可能破坏后端隔离或用户有意设置的策略。
|
||||
|
||||
提取的脚本和恢复脚本均可由用户写入,因此此流程绝不建议提权运行它们。不受限调用者缺少 WRITE_DAC 时,流程停止并交由权限策略审查处理。自动恢复本身也可能失败,且无法跨进程终止继续执行;报告保留待执行的恢复命令,不承诺原子事务。操作者应保留这些文件,直到原始受限操作验证成功。
|
||||
提取的脚本和恢复脚本均可由用户写入,因此此流程绝不建议提权运行它们。不受限调用者缺少 WRITE_DAC 时,流程停止并交由权限策略审查处理。自动恢复本身也可能失败,且无法跨进程终止继续执行;报告保留待执行的恢复命令,不承诺原子事务。由于工具输出只保留末尾部分,全部记录还会写入输出目录下的报告文件,运行结束时给出一行摘要,使判定在截断后仍然可见。操作者应保留这些文件,直到原始受限操作验证成功。
|
||||
|
||||
## How the restriction works (why no new identity)
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# Bilingual-pair consistency record for 2026-09-27-desktop-cli-runtime.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 .agents/notes/implemented/feature/2026-09-27-desktop-cli-runtime.md
|
||||
/agent-note-desktop-installed-cli-runtime:
|
||||
en: 0527f32e8a3c13eb
|
||||
zh: 77dde6e91fc1a636
|
||||
/agent-note-desktop-installed-cli-runtime/problem:
|
||||
en: fb07307392d14fd9
|
||||
zh: a334f905b26eef98
|
||||
/agent-note-desktop-installed-cli-runtime/decision:
|
||||
en: 163c098af7ce6b7b
|
||||
zh: 0c142db0f173f761
|
||||
/agent-note-desktop-installed-cli-runtime/consequences:
|
||||
en: 1dfe287934c9f4bc
|
||||
zh: 138f226ec7409a1f
|
||||
/agent-note-desktop-installed-cli-runtime/command-registration:
|
||||
en: 75ff35db64138e9a
|
||||
zh: a8f799c2d568fa8b
|
||||
/agent-note-desktop-installed-cli-runtime/alternatives-considered:
|
||||
en: 91974c10416cf0fd
|
||||
zh: fe26c0b4f5f37e98
|
||||
@@ -0,0 +1,41 @@
|
||||
# Agent Note: Desktop-installed CLI runtime
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-27-desktop-cli-runtime.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Desktop contains dsh and its production dependencies. Users need terminal access to plugin management and other CLI functions without maintaining another installation.
|
||||
|
||||
## Decision
|
||||
|
||||
A shell script on macOS and a command script on Windows run the private Desktop CLI entry through the installed Electron executable in Node mode. The entry delegates to the ordinary CLI dispatcher and supplies bundled pnpm through its package-operation options. Desktop Host startup and ASAR packaging retain their existing behavior.
|
||||
|
||||
The Desktop-installed command can manage the initialized `desktop` profile while the application is quit. It uses the same profile write lock, package compatibility checks and reconciliation as existing plugin operations. Desktop owns profile initialization; the CLI refuses to initialize a missing Desktop profile as an ordinary CLI profile. npm-installed dsh cannot manage that reserved profile, and neither CLI boots it.
|
||||
|
||||
Windows registers a console callback through Koffi to deliver SIGINT and SIGBREAK to the CLI's JavaScript listeners for asynchronous shutdown. Electron's native default exits before those listeners run. The existing Office engine resolver supplies physical paths for Web attachment conversion; CLI profile startup does not inject Office authoring-skill configuration.
|
||||
|
||||
## Consequences
|
||||
|
||||
Users finish CLI commands before updating or uninstalling Desktop. Installers do not coordinate with CLI processes, and no runtime versions are retained for running commands. An overlapping update can interrupt commands or make their runtime files unavailable.
|
||||
|
||||
The command's runtime version follows Desktop. The [Electron runtime decision](../architecture/2026-09-11-desktop-electron-node-runtime.md) owns Electron/OpenSSL and third-party native-addon limitations. Desktop's existing Office resources are unchanged; the CLI does not automatically supply their authoring paths to user-enabled plugins.
|
||||
|
||||
## Command registration
|
||||
|
||||
The permanent **Manage dsh Command…** menu displays status and installation, repair, and removal actions. macOS uses a fixed `/usr/local/bin/dsh` link, with administrator authentication when needed, instead of editing shell startup files. Windows registers a dedicated directory containing only the public launcher in the current user's PATH. Registration and removal are optional menu actions after application installation; the installer and uninstaller do not perform them. Private installation helpers are outside that PATH directory.
|
||||
|
||||
Switching an existing command requires confirmation. The executor rechecks the displayed fingerprint and carries that exact command entry through withdrawal; a concurrent replacement is preserved. A receipt retains the previous macOS launcher. If another installation replaces the Desktop link before reinstallation, any older backup is preserved and reported; Remove restores only the current receipt’s backup. Windows inserts one owned PATH occurrence and records the count of identical pre-existing occurrences so removal preserves them. Other command locations are reported without being modified. Concurrent dialogs share one operation, and update preparation waits for it to finish.
|
||||
|
||||
The macOS worker reports operation failures in a JSON response with a successful process exit so AppleScript retains its error code and message. Cancelling administrator authentication ends the operation without an error dialog. Windows treats invalid PATH entries as unavailable candidates and preserves their text; environment-change notifications do not change the result of a committed registry write.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Another npm installation.** It duplicates dependencies and can drift from Desktop's runtime.
|
||||
|
||||
**Standalone Node for the CLI.** Ordinary Node cannot load the existing ASAR dependency tree. Moving or duplicating that tree expands the packaging change.
|
||||
|
||||
**Forward commands to the running Desktop Host.** This requires the GUI to be running and adds live-plugin lifecycle integration. Direct package operations support the documented quit-and-reopen workflow.
|
||||
|
||||
**Coordinate updates with every CLI process.** Native admission locks and installer handoff records add lifecycle behavior beyond the initial command integration. The CLI uses the existing application update policy.
|
||||
@@ -0,0 +1,41 @@
|
||||
# Agent Note: Desktop 安装的 CLI 运行时
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-27-desktop-cli-runtime.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
Desktop 已包含 dsh 及其生产依赖。用户需要在终端中管理插件并使用其他 CLI(命令行界面)功能,无需维护另一份安装。
|
||||
|
||||
## 决策
|
||||
|
||||
macOS 的 shell 脚本与 Windows 的命令脚本使用已安装 Electron 的 Node 模式运行私有 Desktop CLI 入口。该入口委托给普通 CLI 分派器,通过包操作选项提供内置 pnpm。Desktop Host 启动与 ASAR 打包保留现有行为。
|
||||
|
||||
Desktop 内置命令可在应用退出时管理已初始化的 `desktop` profile。它与现有插件操作使用相同的 profile 写锁、包兼容性检查和协调逻辑。Desktop 拥有 profile 初始化流程;CLI 拒绝将缺失的 Desktop profile 初始化为普通 CLI profile。npm 安装的 dsh 不能管理此保留 profile,两种 CLI 都不会启动它。
|
||||
|
||||
Windows 通过 Koffi 注册控制台回调,将 SIGINT 和 SIGBREAK 送到 CLI 的 JavaScript 监听器以完成异步退出。Electron 原生默认行为会在这些监听器运行前退出。现有 Office 引擎解析器为 Web 附件转换提供物理路径;CLI profile 启动不注入 Office 创作 skill(技能)的配置。
|
||||
|
||||
## 影响
|
||||
|
||||
用户须在更新或卸载 Desktop 前结束 CLI 命令。安装程序不与 CLI 进程协调,也不为运行中的命令保留多个运行时版本。重叠的更新可能中断命令,或使其运行时文件不可用。
|
||||
|
||||
命令的运行时版本随 Desktop 变化。[Electron 运行时决策](../architecture/2026-09-11-desktop-electron-node-runtime.zh.md)说明 Electron/OpenSSL 与第三方原生 addon 的限制。Desktop 现有 Office 资源保持不变;CLI 不会自动向用户启用的插件提供创作资源路径。
|
||||
|
||||
## 命令注册
|
||||
|
||||
固定的**管理 dsh 命令…**菜单显示状态,并提供安装、修复和移除操作。macOS 使用固定的 `/usr/local/bin/dsh` 链接,必要时请求管理员认证,不修改 shell 启动文件。Windows 将专门存放公开启动器的目录注册到当前用户的 PATH。注册与移除是应用安装后的可选菜单操作,安装程序和卸载程序不执行这些操作。私有安装辅助程序不在这个 PATH 目录中。
|
||||
|
||||
切换已有命令需要确认。执行器重新核对展示过的指纹,并在移走命令时一直使用该次观察到的条目;并发替换的命令会被保留。所有权记录保存之前的 macOS 启动器。若其他安装在重新安装前替换了 Desktop 链接,旧备份会保留并报告;移除操作只恢复当前所有权记录中的备份。Windows 插入一个自有 PATH 条目,并记录此前完全相同条目的数量,移除时保留它们。其他命令位置只展示,不修改。并发对话框共享一个操作,更新准备会等待该操作完成。
|
||||
|
||||
macOS worker 以 JSON 响应报告操作失败,同时以成功状态退出进程,使 AppleScript 保留错误码和消息。取消管理员认证会结束操作,不显示错误对话框。Windows 将无效 PATH 条目视为不可用候选并保留其文本;环境变更通知不会改变已经提交的注册表写入结果。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**再次通过 npm 安装。** 这会重复依赖,并可能偏离 Desktop 的运行时。
|
||||
|
||||
**CLI 使用独立 Node。** 普通 Node 无法加载现有 ASAR 依赖树。移动或复制该树会扩大打包改动。
|
||||
|
||||
**将命令转发给运行中的 Desktop Host。** 这要求 GUI 保持运行,并增加运行中插件的生命周期集成。直接包操作支持文档约定的退出后重新打开流程。
|
||||
|
||||
**协调每个 CLI 进程与更新。** 原生启动锁和安装程序交接记录增加了初始命令集成以外的生命周期行为。CLI 使用应用现有的更新策略。
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
# Bilingual-pair consistency record for 2026-09-28-desktop-login-shell-environment.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 .agents/notes/implemented/feature/2026-09-28-desktop-login-shell-environment.md
|
||||
/agent-note-desktop-reads-the-login-shell-environment-once:
|
||||
en: f3d08cb5b03f7032
|
||||
zh: 2f117f42aed85bc3
|
||||
/agent-note-desktop-reads-the-login-shell-environment-once/problem:
|
||||
en: 3a4b92c977e42f45
|
||||
zh: 575d92a41e29ae7a
|
||||
/agent-note-desktop-reads-the-login-shell-environment-once/decision:
|
||||
en: d2af1761d96c498a
|
||||
zh: 8419dea042446494
|
||||
/agent-note-desktop-reads-the-login-shell-environment-once/alternatives-considered:
|
||||
en: ed9ed8bb06a19d7f
|
||||
zh: 52ceb0d70f0d05a7
|
||||
/agent-note-desktop-reads-the-login-shell-environment-once/consequences:
|
||||
en: f3cd204ab6691f1d
|
||||
zh: 00c9ae619065b4c3
|
||||
@@ -0,0 +1,27 @@
|
||||
# Agent Note: Desktop reads the login-shell environment once
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-28-desktop-login-shell-environment.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
macOS Dock, Finder, and Linux desktop launches start Desktop with only the session manager's environment. Variables that users export from `~/.zprofile` or `~/.zshrc`, including PATH entries for Homebrew, version managers, and API keys, are absent from the Host, agent shells, terminals, and profile configuration. The same Host launched from a terminal sees them.
|
||||
|
||||
## Decision
|
||||
|
||||
Before creating the first Host, [Desktop](../../../../apps/desktop/README.md) runs one delimited `env -0` dump through `<shell> -ilc`, where `<shell>` is the account record's login shell, followed by `/bin/zsh`, `/bin/bash`, and `/bin/sh`. Every Host in the application process uses the merged result. Shell values replace inherited values except probe-session variables and the `DSH_*` and `ELECTRON_*` namespaces. The read completes at the closing delimiter. A timeout or quit kills the probe's process group, and a failed read falls back to the inherited environment without blocking startup. Windows skips the read.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Read `$SHELL` instead of the account record.** A Dock launch inherits `$SHELL` from launchd, not from the user's current choice, and a terminal launch can carry a temporary shell. The account record names the shell the user selected with `chsh`.
|
||||
|
||||
**Non-interactive `-lc` only.** Most users export PATH additions from `~/.zshrc`, which only interactive shells read. Interactive mode requires the probe variables that stop oh-my-zsh update prompts and tmux autostart from blocking.
|
||||
|
||||
**Let shell values replace `DSH_*` variables.** Desktop resolves `DSH_HOME` and its profile paths before the read, so a `DSH_HOME` exported from `~/.zshrc` would give the Host a different home from the one Desktop prepared.
|
||||
|
||||
**Wait for stdout to close.** Background processes that startup files start inherit stdout and can hold it open indefinitely, which turns every read into a timeout.
|
||||
|
||||
## Consequences
|
||||
|
||||
Dock and Finder launches give the Host the environment a terminal launch provides. Changes to startup files apply only after Desktop restarts. Startup waits for the read, up to `DSH_DESKTOP_LOGIN_SHELL_TIMEOUT_MS` per candidate. Account shells that cannot run the POSIX dump, such as csh, tcsh, and nushell, receive the first system shell's environment instead. `DSH_*` settings must come from the launcher's environment rather than startup files. Unit tests run stand-in shells for the early completion, timeout, abort, and fallback paths, and the Desktop startup test verifies that the Host waits for the read.
|
||||
@@ -0,0 +1,27 @@
|
||||
# Agent Note: Desktop 一次性读取登录 shell 环境
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-28-desktop-login-shell-environment.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
从 macOS 的 Dock、Finder 或 Linux 桌面启动 Desktop 时,它只获得会话管理器提供的环境。用户在 `~/.zprofile` 或 `~/.zshrc` 中导出的变量,包括 Homebrew、版本管理器的 PATH 条目和 API key,在 Host、agent shell、终端和 profile 配置中都不存在。从终端启动同一个 Host 时这些变量都在。
|
||||
|
||||
## 决策
|
||||
|
||||
创建第一个 Host 之前,[Desktop](../../../../apps/desktop/README.zh.md) 通过 `<shell> -ilc` 运行一次带定界符的 `env -0` 输出,`<shell>` 依次为账户记录中的登录 shell、`/bin/zsh`、`/bin/bash` 和 `/bin/sh`。同一应用进程中的所有 Host 使用合并后的结果。shell 的值覆盖继承的值,但读取进程自身的会话变量以及 `DSH_*` 和 `ELECTRON_*` 命名空间除外。读取在结束定界符出现时完成。超时或退出会结束读取进程组;读取失败时回退到继承的环境,不阻止启动。Windows 跳过读取。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**读取 `$SHELL` 而不是账户记录。** 从 Dock 启动时,`$SHELL` 继承自 launchd,而不是用户当前的选择;从终端启动时它可能是临时 shell。账户记录给出用户用 `chsh` 选择的 shell。
|
||||
|
||||
**只使用非交互的 `-lc`。** 多数用户在 `~/.zshrc` 中添加 PATH,而只有交互式 shell 会读取它。交互模式需要设置读取变量,避免 oh-my-zsh 更新提示和 tmux 自动启动阻塞。
|
||||
|
||||
**允许 shell 的值覆盖 `DSH_*` 变量。** Desktop 在读取之前已解析 `DSH_HOME` 及其 profile 路径,因此 `~/.zshrc` 导出的 `DSH_HOME` 会让 Host 使用与 Desktop 已准备的不同的 home。
|
||||
|
||||
**等待 stdout 关闭。** 启动文件启动的后台进程继承 stdout,可能无限期保持它打开,使每次读取都超时。
|
||||
|
||||
## 影响
|
||||
|
||||
从 Dock 和 Finder 启动时,Host 获得与从终端启动相同的环境。修改启动文件后需要重启 Desktop 才生效。启动需要等待读取,每个候选最多 `DSH_DESKTOP_LOGIN_SHELL_TIMEOUT_MS`。无法运行 POSIX 输出命令的账户 shell(如 csh、tcsh 和 nushell)改为获得第一个系统 shell 的环境。`DSH_*` 设置必须来自启动方的环境,而不是启动文件。单元测试用替身 shell 覆盖提前完成、超时、中止和回退路径,Desktop 启动测试验证 Host 等待读取完成。
|
||||
@@ -6,8 +6,8 @@
|
||||
en: ebdc75fa0da18801
|
||||
zh: 5c6cd2554b8f8a98
|
||||
/deepseek-ai-dsh/entry-modes:
|
||||
en: 093936b9e1a59bea
|
||||
zh: 12be7d2d931d9671
|
||||
en: c4811e2012a5e919
|
||||
zh: e6d20ec8238c2638
|
||||
/deepseek-ai-dsh/app-arguments:
|
||||
en: cc03e6b75206661b
|
||||
zh: 99e41f4143198352
|
||||
@@ -18,5 +18,5 @@
|
||||
en: 3a40ddc44e55f79c
|
||||
zh: 8d3949132e1ef93c
|
||||
/deepseek-ai-dsh/development:
|
||||
en: 99a36e4f7ea3f9c7
|
||||
zh: 07ca2934de700caa
|
||||
en: 0a289c1235869927
|
||||
zh: a1f473f70036ad37
|
||||
+3
-1
@@ -17,7 +17,7 @@ The `dsh` command is the sole supported Node application launcher: profiles are
|
||||
| `dsh web` | Boot the Web profile. |
|
||||
| `dsh plugin --profile <name> <pnpm args>` | Manage a profile's plugins by forwarding to pnpm in the profile directory. |
|
||||
|
||||
The invoking directory is the default workspace root. The `web`, `headless`, `sdk`, `sdk-minimal`, and `acp` profiles auto-initialize on first use from shipped templates. Create another profile at an unused, non-shipped name with `--from-default-profile`, or initialize a base-backed profile through `dsh plugin`. The `desktop` name is reserved for the Electron-owned profile, so the CLI rejects boot, config-dump, and plugin-management requests for it.
|
||||
The invoking directory is the default workspace root. The `web`, `headless`, `sdk`, `sdk-minimal`, and `acp` profiles auto-initialize on first use from shipped templates. Create another profile at an unused, non-shipped name with `--from-default-profile`, or initialize a base-backed profile through `dsh plugin`. The `desktop` name is reserved for the Electron-owned profile, so the CLI rejects boot and config-dump requests for it. The npm CLI also rejects its plugin-management requests; the [Desktop-installed command](../desktop/README.md#bundled-command-runtime) can manage the initialized Desktop profile using that installation’s runtime.
|
||||
|
||||
## App arguments
|
||||
|
||||
@@ -59,4 +59,6 @@ Production runs require built package and frontend artifacts. From the repositor
|
||||
|
||||
The `@deepseek-ai/dsh/profile-boot` export provides the shared profile lifecycle to the Desktop host. A resolved application profile supplies its own installation anchor for runtime package resolution while retaining the Harness home patch, proxy environment, telemetry switch, patch reload, and bounded shutdown.
|
||||
|
||||
Packaged installations call the same `runCli()` entry with their package-manager executable. The Desktop carrier also enables plugin operations for its initialized profile; npm launches omit these options. Installation-owned package environments apply only to plugin package operations; the invoking directory, ordinary profile selection, and agent-shell PATH retain their CLI meanings.
|
||||
|
||||
The [Web failure matrix](tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts) runs the built CLI through startup failures and native configuration HMR with `awaitWriteFinish` enabled in `test:expected`. It verifies authenticated HTTP responses, diagnostics, recovery, process exits, and disposal without model API calls; the [startup acceptance](tests/profiles/web/tests/web-best-effort-startup.expected.e2e.ts) also covers the shipped required Web dependencies and port conflicts.
|
||||
@@ -17,7 +17,7 @@
|
||||
| `dsh web` | 启动 Web profile。 |
|
||||
| `dsh plugin --profile <name> <pnpm args>` | 通过在 profile 目录中转发给 pnpm 来管理该 profile 的插件。 |
|
||||
|
||||
运行命令时所在的目录将作为默认 workspace 根目录。`web`、`headless`、`sdk`、`sdk-minimal` 和 `acp` profile 在首次使用时会从随附模板自动初始化。使用 `--from-default-profile` 可以基于这些模板之一,在尚未使用的非内置名称处创建其他 profile;通过 `dsh plugin` 则可以初始化一个以 base 为基础的 profile。`desktop` 名称保留给 Electron 持有的 profile,因此 CLI(命令行界面)会拒绝针对它的启动、配置 dump 和插件管理请求。
|
||||
运行命令时所在的目录将作为默认 workspace 根目录。`web`、`headless`、`sdk`、`sdk-minimal` 和 `acp` profile 在首次使用时会从随附模板自动初始化。使用 `--from-default-profile` 可以基于这些模板之一,在尚未使用的非内置名称处创建其他 profile;通过 `dsh plugin` 则可以初始化一个以 base 为基础的 profile。`desktop` 名称保留给 Electron 持有的 profile,因此 CLI(命令行界面)会拒绝针对它的启动和配置 dump 请求。npm CLI 也会拒绝其插件管理请求;[Desktop 内置命令](../desktop/README.zh.md#bundled-command-runtime)可以使用该安装的运行时管理已初始化的 Desktop profile。
|
||||
|
||||
## 应用参数
|
||||
|
||||
@@ -59,4 +59,6 @@ profile 目录包含一个 `package.json`,其中记录树外插件依赖,以
|
||||
|
||||
`@deepseek-ai/dsh/profile-boot` 导出向 Desktop Host 提供共享 profile 生命周期。已解析的应用 profile 为运行时包解析指定自己的安装锚点,同时沿用 Harness home patch、代理环境、遥测开关、patch 热重载和有界关闭。
|
||||
|
||||
打包安装通过同一个 `runCli()` 入口传入包管理器可执行文件。Desktop 载体还会启用其已初始化 profile 的插件操作;npm 启动不传入这些选项。安装包提供的包管理环境仅用于插件包操作;调用目录、普通 profile 选择与 agent shell 的 PATH 保留 CLI 语义。
|
||||
|
||||
[Web 失败矩阵](tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts)在 `test:expected` 中通过构建后的 CLI 验证启动失败与启用 `awaitWriteFinish` 的原生配置 HMR。它不调用模型 API,而是检查经过认证的 HTTP 响应、诊断、恢复、进程退出与 dispose;[启动验收测试](tests/profiles/web/tests/web-best-effort-startup.expected.e2e.ts)还覆盖随附 Web 的必需依赖与端口冲突。
|
||||
@@ -140,9 +140,10 @@ function resolveBoot(program: Command, profile: string, options: BootOptions, ar
|
||||
* error.
|
||||
* @param argv - arguments after the Node binary and script.
|
||||
* @param version - version string printed by `--version`.
|
||||
* @param manageDesktopProfile - permit Desktop's installed carrier to manage its reserved profile's plugins.
|
||||
* @returns the resolved invocation.
|
||||
*/
|
||||
export function parseDshArgs(argv: readonly string[], version: string): DshInvocation {
|
||||
export function parseDshArgs(argv: readonly string[], version: string, manageDesktopProfile = false): DshInvocation {
|
||||
const first = argv[0]
|
||||
let resolved: DshInvocation | undefined
|
||||
// Annotated, not inferred: the actions below call back into `program`, and an
|
||||
@@ -191,9 +192,9 @@ export function parseDshArgs(argv: readonly string[], version: string): DshInvoc
|
||||
.argument('[args...]', 'pnpm arguments, forwarded verbatim (add <pkg>, remove <pkg>, why <pkg>, ...)')
|
||||
.action((args: string[], options: { profile: string }) => {
|
||||
if (options.profile === '') program.error('error: --profile needs a name')
|
||||
rejectElectronProfile(plugin, options.profile)
|
||||
if (!manageDesktopProfile) rejectElectronProfile(plugin, options.profile)
|
||||
if (args.length === 0) program.error('error: plugin needs pnpm arguments to forward (e.g. add <package>)')
|
||||
resolved = { mode: 'plugin', profile: options.profile, args }
|
||||
resolved = { mode: 'plugin', profile: options.profile.toLowerCase() === 'desktop' ? 'desktop' : options.profile, args }
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+13
-3
@@ -10,14 +10,23 @@ import { getDshRuntimeVersion, loadLayeredEnv, StartupError } from '@deepseek-ai
|
||||
import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
|
||||
import { parseDshArgs } from './args.ts'
|
||||
import { reportStartupFailure } from './startup-diagnostics.ts'
|
||||
import type { RunProfileOptions } from './profile-boot.ts'
|
||||
|
||||
/** Installation-owned dependencies supplied by a packaged CLI launcher. */
|
||||
export type RunCliOptions = Pick<RunProfileOptions, 'packageManager'> & {
|
||||
/** Permit plugin commands for Desktop's existing profile; reserved for its installed carrier. */
|
||||
manageDesktopProfile?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the public dsh command-line interface.
|
||||
* @param options - Package runtime and Desktop profile access supplied by the installation.
|
||||
* @returns a promise that settles when the selected command mode finishes.
|
||||
*/
|
||||
export async function runCli(): Promise<void> {
|
||||
export async function runCli(options: RunCliOptions = {}): Promise<void> {
|
||||
const version = getDshRuntimeVersion()
|
||||
const invocation = parseDshArgs(process.argv.slice(2), version)
|
||||
const { manageDesktopProfile, ...profileOptions } = options
|
||||
const invocation = parseDshArgs(process.argv.slice(2), version, manageDesktopProfile)
|
||||
|
||||
switch (invocation.mode) {
|
||||
case 'profile': {
|
||||
@@ -29,6 +38,7 @@ export async function runCli(): Promise<void> {
|
||||
fromDefaultProfile: invocation.fromDefaultProfile,
|
||||
patchFiles: invocation.patches,
|
||||
args: invocation.args,
|
||||
...profileOptions,
|
||||
})
|
||||
} catch (error) {
|
||||
if (!(error instanceof StartupError)) throw error
|
||||
@@ -39,7 +49,7 @@ export async function runCli(): Promise<void> {
|
||||
}
|
||||
case 'plugin': {
|
||||
const { runPlugin } = await import('./plugin.ts')
|
||||
process.exit(await runPlugin(invocation.profile, invocation.args))
|
||||
process.exit(await runPlugin(invocation.profile, invocation.args, options.packageManager))
|
||||
break
|
||||
}
|
||||
case 'dump-config': {
|
||||
|
||||
+30
-8
@@ -1,12 +1,18 @@
|
||||
/** Profile package management and explicit, exact-version compatibility approvals. */
|
||||
import { runPluginCommand, setProfileVersionExemption } from '@deepseek-ai/dsh-plugin-manager/operations'
|
||||
import { runPluginCommand, runProfilePnpm, setProfileVersionExemption, type PackageOperationOptions } from '@deepseek-ai/dsh-plugin-manager/operations'
|
||||
import { INSTALL_ANCHOR } from './profile-boot.ts'
|
||||
import { DEFAULT_PROFILE_BUNDLES, initProfile, PROFILE_TEMPLATES, readProfileCompatibility, resolveProfileDir } from '@deepseek-ai/dsh-app-boot'
|
||||
import { DEFAULT_PROFILE_BUNDLES, initProfile, PROFILE_TEMPLATES, readProfileCompatibility, resolveProfileDir, type ProfileContext } from '@deepseek-ai/dsh-app-boot'
|
||||
import { withFileLock } from '@deepseek-ai/dsh-atomic-write'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdir } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
|
||||
function requireDesktopProfile(dir: string): void {
|
||||
if (!existsSync(join(dir, 'package.json'))) {
|
||||
throw new Error('Open DeepSeek Harness Desktop once to initialize its profile, then fully quit it before running dsh plugin --profile desktop.')
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse only DSH-owned commands; all other arguments remain pnpm's responsibility. */
|
||||
async function versionCommand(profile: string, args: readonly string[]): Promise<number | undefined> {
|
||||
const [command, ...rest] = args
|
||||
@@ -35,9 +41,10 @@ async function versionCommand(profile: string, args: readonly string[]): Promise
|
||||
process.stderr.write('dsh: warning: allowing incompatible plugin versions can break the application or corrupt data. Approval applies only to the exact package and DSH versions.\n')
|
||||
}
|
||||
const dir = resolveProfileDir(profile)
|
||||
await mkdir(dir, { recursive: true })
|
||||
if (profile !== 'desktop') await mkdir(dir, { recursive: true })
|
||||
await withFileLock(join(dir, 'package.json'), async () => {
|
||||
if (!existsSync(join(dir, 'package.json'))) initProfile(dir, PROFILE_TEMPLATES[profile]?.bundles ?? DEFAULT_PROFILE_BUNDLES)
|
||||
if (profile === 'desktop') requireDesktopProfile(dir)
|
||||
else if (!existsSync(join(dir, 'package.json'))) initProfile(dir, PROFILE_TEMPLATES[profile]?.bundles ?? DEFAULT_PROFILE_BUNDLES)
|
||||
if (request === undefined) {
|
||||
const { exemptions, warnings } = readProfileCompatibility(dir)
|
||||
for (const warning of warnings) process.stderr.write(`dsh: warning: ${warning}\n`)
|
||||
@@ -55,24 +62,39 @@ async function versionCommand(profile: string, args: readonly string[]): Promise
|
||||
}
|
||||
|
||||
/** Run package management for a profile.
|
||||
* @param profile Profile name.
|
||||
* @param profile Profile name; Desktop's reserved profile must already be initialized by the application.
|
||||
* @param args DSH exemption command or pnpm arguments relative to the invoking directory.
|
||||
* @param packageManager Installation-owned executable and environment for pnpm operations.
|
||||
* @returns Zero on success; nonzero on invalid approval or package-manager failure.
|
||||
*/
|
||||
export async function runPlugin(profile: string, args: readonly string[]): Promise<number> {
|
||||
export async function runPlugin(profile: string, args: readonly string[], packageManager?: ProfileContext['packageManager']): Promise<number> {
|
||||
if (profile === 'desktop') {
|
||||
try { requireDesktopProfile(resolveProfileDir(profile)) } catch (error) {
|
||||
process.stderr.write(`dsh: ${String(error)}\n`)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
const versionResult = await versionCommand(profile, args)
|
||||
if (versionResult !== undefined) return versionResult
|
||||
const dir = resolveProfileDir(profile)
|
||||
if (existsSync(join(dir, 'package.json'))) {
|
||||
for (const warning of readProfileCompatibility(dir).warnings) process.stderr.write(`dsh: warning: ${warning}\n`)
|
||||
}
|
||||
const result = await runPluginCommand({ profile, installAnchor: INSTALL_ANCHOR, cwd: process.cwd() }, args, {
|
||||
const context = { profile, dir, installAnchor: INSTALL_ANCHOR, cwd: process.cwd() }
|
||||
const options: PackageOperationOptions = {
|
||||
...packageManager,
|
||||
execution: 'cli',
|
||||
outputBytes: 16384,
|
||||
lockWaitMs: 120000,
|
||||
lookupTimeoutMs: 120000,
|
||||
onOutput: (text, stream) => { process[stream].write(text) },
|
||||
})
|
||||
}
|
||||
const result = profile === 'desktop'
|
||||
? await withFileLock(join(dir, 'package.json'), async () => {
|
||||
requireDesktopProfile(dir)
|
||||
return runProfilePnpm(context, args, options)
|
||||
}, { waitMs: 120000 })
|
||||
: await runPluginCommand(context, args, options)
|
||||
if (result.exitCode === 127) process.stderr.write('dsh: pnpm was not found; install pnpm and make it available on PATH.\n')
|
||||
for (const { name, version, runtimeVersion } of result.incompatible ?? []) {
|
||||
process.stderr.write(`dsh: to accept the risk, run: dsh plugin --profile ${profile} allow-version ${name}@${version} --dsh-version ${runtimeVersion} --accept-risk\n`)
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { parseDshArgs } from '../src/args.ts'
|
||||
|
||||
const parse = (argv: string[]) => parseDshArgs(argv, '1.2.3')
|
||||
const parse = (argv: string[], manageDesktopProfile = false) => parseDshArgs(argv, '1.2.3', manageDesktopProfile)
|
||||
|
||||
/** Capture the process exit code while muting Commander's output. */
|
||||
function exitCode(argv: string[]): number {
|
||||
function exitCode(argv: string[], manageDesktopProfile = false): number {
|
||||
const exit = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('exit') })
|
||||
vi.spyOn(process.stdout, 'write').mockReturnValue(true)
|
||||
vi.spyOn(process.stderr, 'write').mockReturnValue(true)
|
||||
try {
|
||||
parse(argv)
|
||||
parse(argv, manageDesktopProfile)
|
||||
throw new Error(`expected ${JSON.stringify(argv)} to exit`)
|
||||
} catch {
|
||||
return exit.mock.calls.at(-1)?.[0] as number
|
||||
@@ -122,6 +122,20 @@ describe('parseDshArgs', () => {
|
||||
.toEqual({ mode: 'plugin', profile: 'tui', args: ['add', '--save-dev', 'x'] })
|
||||
})
|
||||
|
||||
it.each(['desktop', 'Desktop', 'DESKTOP'])('permits installed Desktop plugin commands for %s', (profile) => {
|
||||
expect(parse(['plugin', '--profile', profile, 'add', 'example-plugin'], true))
|
||||
.toEqual({ mode: 'plugin', profile: 'desktop', args: ['add', 'example-plugin'] })
|
||||
expect(exitCode(['plugin', '--profile', profile, 'add', 'example-plugin'])).toBe(1)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['desktop'], ['--profile', 'Desktop'],
|
||||
['desktop', '--dump-config'], ['DESKTOP', '--dump-default-config'],
|
||||
['desktop', '--dump-config-schema'],
|
||||
])('keeps Desktop boot and inspection reserved in its installed CLI: %j', (...argv: string[]) => {
|
||||
expect(exitCode(argv, true)).toBe(1)
|
||||
})
|
||||
|
||||
it('routes profile and web config dumps', () => {
|
||||
expect(parse(['--profile', 'web', '--dump-config']))
|
||||
.toEqual({ mode: 'dump-config', profile: 'web', defaultOnly: false, patches: [] })
|
||||
|
||||
@@ -3,14 +3,15 @@ import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { expect, it, onTestFinished, vi } from 'vitest'
|
||||
import { getDshRuntimeVersion, readProfileVersionExemptions } from '@deepseek-ai/dsh-app-boot'
|
||||
import { runPluginCommand } from '@deepseek-ai/dsh-plugin-manager/operations'
|
||||
import { getDshRuntimeVersion, initProfile, PROFILE_TEMPLATES, readProfileVersionExemptions } from '@deepseek-ai/dsh-app-boot'
|
||||
import { runPluginCommand, runProfilePnpm } from '@deepseek-ai/dsh-plugin-manager/operations'
|
||||
import { runPlugin } from '../src/plugin.ts'
|
||||
|
||||
vi.mock('../src/profile-boot.ts', () => ({ INSTALL_ANCHOR: '/installation/package.json' }))
|
||||
vi.mock('@deepseek-ai/dsh-plugin-manager/operations', async importOriginal => ({
|
||||
...await importOriginal<typeof import('@deepseek-ai/dsh-plugin-manager/operations')>(),
|
||||
runPluginCommand: vi.fn(),
|
||||
runProfilePnpm: vi.fn(),
|
||||
}))
|
||||
|
||||
function fixture() {
|
||||
@@ -22,9 +23,10 @@ function fixture() {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllEnvs()
|
||||
vi.mocked(runPluginCommand).mockReset()
|
||||
vi.mocked(runProfilePnpm).mockReset()
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
})
|
||||
return { dir: join(home, 'profiles', 'test'), stdout, stderr }
|
||||
return { home, dir: join(home, 'profiles', 'test'), stdout, stderr }
|
||||
}
|
||||
|
||||
it('requires explicit acknowledgement, grants only the exact pair, lists and revokes it without pnpm', async () => {
|
||||
@@ -102,3 +104,74 @@ it('forwards all other commands unchanged and retains package diagnostics', asyn
|
||||
expect(runPluginCommand).toHaveBeenCalledWith(expect.objectContaining({ profile: 'test' }), args, expect.objectContaining({ execution: 'cli' }))
|
||||
expect(stderr.mock.calls.map(call => call[0]).join('')).toContain('pnpm was not found')
|
||||
})
|
||||
|
||||
it('uses the installation package runtime without changing CLI authentication or output policy', async () => {
|
||||
fixture()
|
||||
vi.mocked(runPluginCommand).mockResolvedValue({ exitCode: 0, output: '', truncated: false, logPath: '/profile/log' })
|
||||
const packageManager = {
|
||||
command: '/installation/electron',
|
||||
args: ['--expose-internals', '/installation/pnpm.mjs'],
|
||||
env: { ELECTRON_RUN_AS_NODE: '1', PATH: '/installation/bin' },
|
||||
}
|
||||
expect(await runPlugin('test', ['list'], packageManager)).toBe(0)
|
||||
expect(runPluginCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ profile: 'test' }), ['list'],
|
||||
expect.objectContaining({ ...packageManager, execution: 'cli' }),
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['list'], ['add', 'example-plugin'],
|
||||
['version-exemptions'],
|
||||
['allow-version', '[email protected]', '--dsh-version', 'CURRENT', '--accept-risk'],
|
||||
['revoke-version', '[email protected]', '--dsh-version', 'CURRENT'],
|
||||
])('requires Desktop initialization before %j without creating a profile', async (...arguments_) => {
|
||||
const { home, stderr } = fixture()
|
||||
const args = arguments_.map(value => value === 'CURRENT' ? getDshRuntimeVersion() : value)
|
||||
expect(await runPlugin('desktop', args)).toBe(1)
|
||||
expect(stderr.mock.calls.map(call => call[0]).join('')).toMatchInlineSnapshot(`
|
||||
"dsh: Error: Open DeepSeek Harness Desktop once to initialize its profile, then fully quit it before running dsh plugin --profile desktop.
|
||||
"
|
||||
`)
|
||||
expect(existsSync(join(home, 'profiles', 'desktop'))).toBe(false)
|
||||
expect(runPluginCommand).not.toHaveBeenCalled()
|
||||
expect(runProfilePnpm).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('runs Desktop package operations under the shared writer lock with bundled pnpm', async () => {
|
||||
const { home } = fixture()
|
||||
const dir = join(home, 'profiles', 'desktop')
|
||||
initProfile(dir, PROFILE_TEMPLATES.web!.bundles)
|
||||
const manifest = readFileSync(join(dir, 'package.json'), 'utf8')
|
||||
const packageManager = { command: '/installation/electron', args: ['/installation/pnpm.mjs'], env: { ELECTRON_RUN_AS_NODE: '1' } }
|
||||
vi.mocked(runProfilePnpm).mockImplementation(async (context, args, options) => {
|
||||
expect(context).toEqual({ profile: 'desktop', dir, installAnchor: '/installation/package.json', cwd: process.cwd() })
|
||||
expect(args).toEqual(['list'])
|
||||
expect(options).toMatchObject({ ...packageManager, execution: 'cli' })
|
||||
expect(existsSync(join(dir, 'package.json.lock'))).toBe(true)
|
||||
return { exitCode: 0, output: '', truncated: false, logPath: '/profile/log' }
|
||||
})
|
||||
expect(await runPlugin('desktop', ['list'], packageManager)).toBe(0)
|
||||
expect(runProfilePnpm).toHaveBeenCalledOnce()
|
||||
expect(runPluginCommand).not.toHaveBeenCalled()
|
||||
expect(existsSync(join(dir, 'package.json.lock'))).toBe(false)
|
||||
expect(readFileSync(join(dir, 'package.json'), 'utf8')).toBe(manifest)
|
||||
expect(existsSync(join(home, 'profiles', 'test'))).toBe(false)
|
||||
})
|
||||
|
||||
it('applies version approvals to the existing Desktop profile without replacing its bundle selection', async () => {
|
||||
const { home } = fixture()
|
||||
const dir = join(home, 'profiles', 'desktop')
|
||||
initProfile(dir, PROFILE_TEMPLATES.web!.bundles)
|
||||
const manifest = readFileSync(join(dir, 'package.json'), 'utf8')
|
||||
const runtime = getDshRuntimeVersion()
|
||||
expect(await runPlugin('desktop', ['allow-version', '[email protected]', '--dsh-version', runtime, '--accept-risk'])).toBe(0)
|
||||
expect(readProfileVersionExemptions(dir)).toEqual({ '[email protected]': [runtime] })
|
||||
expect(await runPlugin('desktop', ['version-exemptions'])).toBe(0)
|
||||
expect(await runPlugin('desktop', ['revoke-version', '[email protected]', '--dsh-version', runtime])).toBe(0)
|
||||
expect(readProfileVersionExemptions(dir)).toEqual({})
|
||||
expect(readFileSync(join(dir, 'package.json'), 'utf8')).toBe(manifest)
|
||||
expect(existsSync(join(dir, 'package.json.lock'))).toBe(false)
|
||||
expect(runPluginCommand).not.toHaveBeenCalled()
|
||||
expect(runProfilePnpm).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -226,7 +226,7 @@ describe('PTC mode typed values: keyless real-process contracts', () => {
|
||||
expect(taskOutput.job).toMatchObject({ id: jobId, kind: 'bash', status: 'completed' })
|
||||
}, 15_000)
|
||||
|
||||
it('pre-abort spawns nothing; post-publication abort leaves job_kill as the cancellation owner', async () => {
|
||||
it('pre-abort spawns nothing; post-publication abort leaves job_kill as the cancellation owner', { timeout: 15_000, retry: 0 }, async () => {
|
||||
workdir = await mkdtemp(join(tmpdir(), 'dsh-ptc-task-cancel-'))
|
||||
ctx = await backgroundPtcModeHarness(workdir)
|
||||
|
||||
@@ -238,15 +238,27 @@ describe('PTC mode typed values: keyless real-process contracts', () => {
|
||||
expect(preResult.isError).toBe(true)
|
||||
expect(ctx.jobs.list()).toEqual([])
|
||||
|
||||
const jobs = ctx.jobs
|
||||
// Registration is announced after the job's ownership has committed.
|
||||
const registered = new Promise<void>((resolve) => {
|
||||
onTestFinished(jobs.events.subscribe({ owners: 'all' }, (event) => {
|
||||
if (event.type === 'registered') resolve()
|
||||
}))
|
||||
})
|
||||
const afterPublication = new AbortController()
|
||||
const running = runCode(ctx, `
|
||||
const started = await tools.bash({ command: 'sleep 10', description: 'Wait for explicit task kill', run_in_background: true });
|
||||
console.log(started.jobId);
|
||||
await new Promise(() => {});
|
||||
`, afterPublication.signal)
|
||||
for (let attempt = 0; attempt < 100 && ctx.jobs.list().length === 0; attempt++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
}
|
||||
await Promise.race([
|
||||
registered,
|
||||
running.then((result) => {
|
||||
// Promise.race handles a late rejection after registration, too.
|
||||
completion(result)
|
||||
throw new Error('run_code completed before background job registration')
|
||||
}),
|
||||
])
|
||||
const job = ctx.jobs.list()[0]
|
||||
expect(job).toMatchObject({ id: 'bash-1', status: 'running' })
|
||||
afterPublication.abort('outer-call-cancelled')
|
||||
@@ -261,7 +273,7 @@ describe('PTC mode typed values: keyless real-process contracts', () => {
|
||||
return await tools.job_output({ job_id: ${JSON.stringify(job!.id)}, wait: true, timeout_ms: 5000 });
|
||||
`))
|
||||
expect(settled).toMatchObject({ job: { id: job!.id, status: 'killed' } })
|
||||
}, 15_000)
|
||||
})
|
||||
|
||||
it('keeps foreground bash coupled to the outer signal', async () => {
|
||||
workdir = await mkdtemp(join(tmpdir(), 'dsh-ptc-foreground-cancel-'))
|
||||
|
||||
@@ -7,9 +7,11 @@
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"files": [
|
||||
"lib/index.js"
|
||||
"lib/index.js",
|
||||
"lib/cli.js"
|
||||
],
|
||||
"dependencies": {
|
||||
"koffi": "^3.1.0",
|
||||
"@deepseek-ai/libreoffice-kit": "^0.1.1",
|
||||
"@deepseek-ai/cordis": "workspace:~",
|
||||
"@deepseek-ai/dsh": "workspace:*",
|
||||
@@ -24,5 +26,10 @@
|
||||
"@deepseek-ai/dsh-skill-office": "workspace:*",
|
||||
"@deepseek-ai/dsh-deepseek-account": "workspace:*",
|
||||
"@deepseek-ai/dsh-workspace": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis-plugin-loader": "workspace:~",
|
||||
"@deepseek-ai/cordis-plugin-include": "workspace:~",
|
||||
"@deepseek-ai/dsh-skill": "workspace:*"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/** Public dsh commands using the immutable runtime carried by the Desktop installation. */
|
||||
|
||||
import { delimiter, dirname, join, resolve } from 'node:path'
|
||||
import { runCli } from '@deepseek-ai/dsh/lib/bin.js'
|
||||
import { installOfficeEngineResolution, runtimeArchivePath } from './office-engine.ts'
|
||||
|
||||
/**
|
||||
* Run the ordinary CLI with Desktop's bundled package manager and reserved-profile plugin access.
|
||||
* @param runtimeDir - Prepared or ASAR-contained production DSH package tree.
|
||||
* @param supportDir - Physical Desktop runtime directory containing pnpm.
|
||||
* @returns Completion of the selected CLI command; profile plugins own their process lifetime.
|
||||
*/
|
||||
export async function runDesktopCli(runtimeDir: string, supportDir: string): Promise<void> {
|
||||
installOfficeEngineResolution(runtimeDir)
|
||||
await runCli({
|
||||
manageDesktopProfile: true,
|
||||
packageManager: {
|
||||
command: process.execPath,
|
||||
args: ['--expose-internals', join(supportDir, 'pnpm', 'bin', 'pnpm.mjs')],
|
||||
env: {
|
||||
ELECTRON_RUN_AS_NODE: '1',
|
||||
DSH_DESKTOP_NODE_EXECUTABLE: process.execPath,
|
||||
PATH: `${join(supportDir, 'bin')}${delimiter}${process.env.PATH ?? ''}`,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
if (process.platform === 'win32') {
|
||||
const { installWindowsCliSignals } = await import('./windows-cli-signals.ts')
|
||||
await installWindowsCliSignals()
|
||||
}
|
||||
const runtimeDir = resolve(import.meta.dirname, '../../../..')
|
||||
await runDesktopCli(runtimeDir, join(dirname(runtimeArchivePath(runtimeDir) ?? runtimeDir), 'runtime'))
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/** Deliver console interrupts to CLI listeners in Electron's Windows Node mode. */
|
||||
|
||||
/**
|
||||
* Register the CLI process's Windows console handler.
|
||||
* Koffi queues callbacks from the console thread onto the JavaScript thread.
|
||||
* The registration lasts until process exit; unknown events retain native handling.
|
||||
* @returns Completion once the console handler is registered.
|
||||
*/
|
||||
export async function installWindowsCliSignals(): Promise<void> {
|
||||
const { default: koffi } = await import('koffi')
|
||||
const kernel = koffi.load('kernel32.dll')
|
||||
const type = koffi.proto('int __stdcall DshCliConsoleHandler(uint32_t event)')
|
||||
const handler = koffi.register((event: number) => {
|
||||
if (event === 0) return Number(process.emit('SIGINT'))
|
||||
if (event === 1) return Number(process.emit('SIGBREAK'))
|
||||
return 0
|
||||
}, koffi.pointer(type))
|
||||
const install = kernel.func('int __stdcall SetConsoleCtrlHandler(DshCliConsoleHandler *handler, int add)')
|
||||
if (!install(handler, 1)) {
|
||||
koffi.unregister(handler)
|
||||
throw new Error('desktop CLI: cannot register the Windows console handler')
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
import { defineConfig } from 'tsdown'
|
||||
|
||||
export default defineConfig({
|
||||
entry: ['lib/types/index.js'],
|
||||
export default defineConfig(['index', 'cli'].map(name => ({
|
||||
entry: ['lib/types/' + name + '.js'],
|
||||
outDir: 'lib',
|
||||
format: ['esm'],
|
||||
format: ['esm'] as const,
|
||||
outputOptions: { codeSplitting: false },
|
||||
platform: 'node',
|
||||
target: 'es2024',
|
||||
fixedExtension: false,
|
||||
dts: false,
|
||||
clean: false,
|
||||
})
|
||||
})))
|
||||
@@ -5,6 +5,9 @@
|
||||
/deepseek-harness-desktop:
|
||||
en: eccac345d51428fa
|
||||
zh: 4a448b3d63cb8117
|
||||
/deepseek-harness-desktop/terminal-command:
|
||||
en: bef92953c237363f
|
||||
zh: d21d6effd21b8c00
|
||||
/deepseek-harness-desktop/closing-the-window-and-quitting:
|
||||
en: c323e02e3f0305fe
|
||||
zh: 3c730db632136443
|
||||
@@ -14,12 +17,18 @@
|
||||
/deepseek-harness-desktop/key-technical-decisions/bundled-workspace-dependencies:
|
||||
en: 320733f93ede61a4
|
||||
zh: 33206c3c4753c802
|
||||
/deepseek-harness-desktop/bundled-command-runtime:
|
||||
en: beecf076aa2f0796
|
||||
zh: c9001ce0f22d2379
|
||||
/deepseek-harness-desktop/installation-ownership:
|
||||
en: 863ebc136313b73b
|
||||
zh: 6aa9a9925ceaa80a
|
||||
en: 22153a298a6662e1
|
||||
zh: 3600803c8c9d0274
|
||||
/deepseek-harness-desktop/installation-ownership/runtime-and-plugin-activation:
|
||||
en: 0d11af8639977a6d
|
||||
zh: 86b086b12d2210d3
|
||||
/deepseek-harness-desktop/installation-ownership/host-environment:
|
||||
en: 84a9cc7ca9455aa8
|
||||
zh: 4091b8aa8231b74a
|
||||
/deepseek-harness-desktop/develop:
|
||||
en: 40d2ad4014bf445b
|
||||
zh: 6a08de06df9ed042
|
||||
@@ -39,8 +48,8 @@
|
||||
en: f0e345fd89868803
|
||||
zh: fd2c775ad23fa2f0
|
||||
/deepseek-harness-desktop/package/upload-updates:
|
||||
en: c64d7fe2fe2534b7
|
||||
zh: 2188e1ae99ab4b33
|
||||
en: 72073b2e72590067
|
||||
zh: 63b1f9790395c37d
|
||||
/deepseek-harness-desktop/package/unsigned-windows-test-installer:
|
||||
en: cfd5780dd26a8e46
|
||||
zh: f7a16b1ede793b7b
|
||||
|
||||
+24
-2
@@ -22,6 +22,14 @@ Desktop microphone access is restricted to audio requests from the primary `dsh-
|
||||
|
||||
Press F12 (Fn+F12 on media-key keyboards), Command+Option+I on macOS, or Ctrl+Shift+I on Windows to toggle DevTools for the focused application page, including in packaged builds. These native shortcuts use hidden application-menu items. Update overlays and packaged embedded browser guests disable DevTools.
|
||||
|
||||
## Terminal command
|
||||
|
||||
The application menu's **Manage dsh Command…** entry, immediately below **Check for Updates…**, shows the current command and offers Install, Repair, and Remove. The command uses Desktop's installed runtime and the ordinary [dsh CLI](../cli/README.md), including when the Desktop application is closed. Open a new terminal after installation and run `dsh --version`.
|
||||
|
||||
On macOS, installation creates `/usr/local/bin/dsh` and requests administrator authentication when the directory requires it. Shell startup files are unchanged. On Windows, the management dialog registers the command in the current user's PATH. A pre-existing command requires confirmation before switching; repairing Desktop's own selected command does not repeat that confirmation. The macOS link preserves and restores the displaced launcher; Windows preserves other PATH entries, including entries that predate registration. A command with higher PATH precedence is reported with its location. After moving the application, use Repair on macOS or Install from the new location on Windows. Remove leaves unrelated installations intact.
|
||||
|
||||
Command registration is optional after installing Desktop. Use **Manage dsh Command… → Remove** before uninstalling Desktop to remove its CLI registration; the application uninstaller does not remove it. Finish CLI commands before updating or uninstalling Desktop. The CLI runtime version follows the installed Desktop release. See [bundled command runtime](#bundled-command-runtime) for Desktop plugin commands and runtime limitations.
|
||||
|
||||
## Closing the window and quitting
|
||||
|
||||
Closing the main window (macOS close button and ⌘W; Windows ×, Alt+F4, and the taskbar Close window command) hides it; Windows asks for acknowledgement before the first hide. The page and the Host keep running, tasks continue, and the next show presents the same document with its session, drafts, and scroll position; a fullscreen macOS window leaves fullscreen before hiding. The window returns through the Dock icon, a second launch, or `dsh://open` on macOS, and through the tray on Windows. Minimize is unchanged. Closing the welcome window before the workspace opens quits on Windows and, on macOS, keeps the application in the Dock without a window.
|
||||
@@ -76,9 +84,17 @@ The [thin-wrapper decision](../../.agents/notes/implemented/architecture/2026-09
|
||||
|
||||
Welcome loads the shared Toast palette and shadow tokens, with system typography for body-mounted notifications.
|
||||
|
||||
## Bundled command runtime
|
||||
|
||||
The installed `resources/runtime/cli/bin/dsh` shell script (`dsh.cmd` on Windows) runs the ordinary CLI dispatcher using Desktop's Electron executable and bundled pnpm. It works while Desktop is closed and retains the [Electron runtime limitations](../../.agents/notes/implemented/architecture/2026-09-11-desktop-electron-node-runtime.md). Ordinary profile, configuration, and plugin commands use the same implementation as npm dsh; the command does not open Desktop.
|
||||
|
||||
To manage Desktop's plugins, launch Desktop once to initialize its profile, fully quit the application, then run `dsh plugin --profile desktop add <package>`, `list`, or `remove <package>`. Reopen Desktop to use the changes. Package operations retain the shared profile write lock and compatibility checks. The installed command refuses an uninitialized Desktop profile instead of creating an ordinary CLI profile in its place.
|
||||
|
||||
Finish CLI commands before updating or uninstalling Desktop; the installer does not coordinate with running commands. The CLI does not supply Office authoring-skill resource paths. Desktop's existing Office setup and Web attachment conversion retain their own resource configuration. `prepare:runtime` copies the launch scripts without compiling a native CLI launcher.
|
||||
|
||||
## Installation ownership
|
||||
|
||||
Electron owns `$DSH_HOME/profiles/desktop`. Its `dependencies` contains packages installed by pnpm; `dsh.profile.bundles` contains the built-in bundles followed by enabled plugins. The signed application supplies dsh, the private Desktop Host, and their production packages from `resources/app.asar/dsh`. Packaged applications select runtime profile resolution without creating package links; development profiles use filesystem links. Both host and plugins execute in the same Electron Node-mode process; Desktop does not enable `--preserve-symlinks`. The CLI cannot boot or mutate this profile.
|
||||
Electron owns `$DSH_HOME/profiles/desktop`. Its `dependencies` contains packages installed by pnpm; `dsh.profile.bundles` contains the built-in bundles followed by enabled plugins. The signed application supplies dsh, the private Desktop Host, and their production packages from `resources/app.asar/dsh`. Packaged applications select runtime profile resolution without creating package links; development profiles use filesystem links. Both host and plugins execute in the same Electron Node-mode process; Desktop does not enable `--preserve-symlinks`. The CLI cannot boot this profile. The Desktop-installed command can manage its plugins while the application is quit; npm-installed dsh cannot mutate it.
|
||||
|
||||
The application preload exposes boot readiness, fatal startup reporting, native directory selection, the `__DSH_HOST_PATHS__` bridge for composer path references, and the lease-scoped Browser bridge only to `dsh-app://app` documents. The same preload forwards the main-process machine description from `dshDesktop.deviceInfo()` as `name=value` fields separated by `; `: `platform`, `os`, `app_arch` (the architecture the application binary runs as, which differs from the hardware architecture under emulation), `cpu`, and `memory_gib` (total physical memory in GiB with one decimal). A field is omitted when its source is unavailable. The description carries no hostname, user name, or serial number. Product documents use the shared authenticated HTTP APIs and receive the Desktop marker, update presentation, and an action that opens native confirmation; they cannot choose artifacts or authorize installation. Plugin management uses the Web application's authenticated HTTP APIs. Electron serves update-dialog documents and assets locally at `dsh-app://shell/`, independently of Host readiness. Electron exposes no plugin-management IPC or separate management document. No renderer receives filesystem access, raw Electron IPC, a shell, or arbitrary pnpm arguments.
|
||||
|
||||
@@ -118,6 +134,10 @@ Before the first fatal dialog opens, Electron writes a crash report to the platf
|
||||
|
||||
Recovery waits for Host shutdown before changing plugin activation. The native recovery action calls the shared app-boot recovery function under the profile transaction lock. It disables third-party bundles and renames the profile’s `cordis.patch.yml` to `cordis.patch.yml.bak-<timestamp>` (with an ordinal on collisions) without parsing it; the next startup creates an empty patch. Installed packages and earlier backups remain. The home-level patch is unchanged. The Electron console records the backup path (or its absence) and the unchanged home-level patch. Invalid profile data, rename failures, or write failures are reported as recovery-operation errors; completed changes remain, and Desktop does not restart as though recovery succeeded. Desktop has no profile-reset action or emergency HTML document.
|
||||
|
||||
### Host environment
|
||||
|
||||
macOS and Linux GUI launches inherit only the session manager's environment, without variables exported by shell startup files. Before the first Host starts, Desktop runs the account's login shell (from the user database, not `$SHELL`) once as `<shell> -ilc` and reads its `env -0` output between delimiters, so `~/.zprofile` and `~/.zshrc` (or the shell's equivalents) take effect for the Host, agent shells, terminals, and profile configuration. Reading runs in parallel with profile preparation. The probe has no terminal input and sets `DISABLE_AUTO_UPDATE=true`, `ZSH_TMUX_AUTOSTARTED=true`, and `ZSH_TMUX_AUTOSTART=false` to keep oh-my-zsh and tmux plugins from blocking. Shell values replace inherited values except `PWD`, `OLDPWD`, `SHLVL`, `_`, those probe variables, and launcher-owned `DSH_*` and `ELECTRON_*` names; Desktop resolves paths such as `DSH_HOME` before the read, so the Host keeps the same values. The read completes at the closing delimiter, so background processes started by startup files may keep running with their output discarded. A candidate that fails to start, exits nonzero, prints no delimited output, or exceeds `DSH_DESKTOP_LOGIN_SHELL_TIMEOUT_MS` (integer milliseconds from 1000 through 2147483647, default `10000`; its process group is killed) is logged as a warning, and Desktop tries `/bin/zsh`, `/bin/bash`, and `/bin/sh` in turn; when all fail the Host uses the inherited environment. The dump command uses POSIX syntax, so an account shell such as csh, tcsh, or nushell fails and the Host receives the environment from the first system shell's startup files instead. Quitting Desktop during the read kills the running probe's process group. The result is read once per application process, so changes to shell startup files apply after quitting and reopening Desktop. Windows GUI launches already inherit the user and system environment variables from the registry, so Windows skips this step.
|
||||
|
||||
## Develop
|
||||
|
||||
The development application menu offers Reload Page (Cmd+R on macOS, Ctrl+R elsewhere) and Restart App and Host. Restart waits for Host shutdown before relaunching Electron and starting a new Host; neither action rebuilds source files.
|
||||
@@ -235,9 +255,11 @@ When extraction fails, the installer writes a report with the 7-Zip result and i
|
||||
|
||||
### Upload updates
|
||||
|
||||
Fixed installer downloads use `pnpm run upload:latest:mac:arm64`, `pnpm run upload:latest:mac:x64`, or `pnpm run upload:latest:win:x64` from the repository root. They upload the completed target's DMG or EXE to `desktop/dsh-latest-macos-arm64.dmg`, `desktop/dsh-latest-macos-x64.dmg`, or `desktop/dsh-latest-windows-x64.exe`. Production URLs start with `https://download.deepseek.com/`; test URLs use `DOWNLOAD_TEST_ORIGIN`. Both deployments reuse the target dotenv's deployment, bucket, and credentials, all normal release validation, and the upload audit below. Each command replaces one fixed object without publishing feeds, blockmaps, or release tags. Test fixed downloads are shared across release IDs in the test bucket; the packaged update URL still requires its original release ID. Publication is explicit, including prerelease versions. Deployment infrastructure must configure revalidation or purge cached fixed downloads after replacement. Local loopback tests cover the upload; release operators own real COS and CDN acceptance.
|
||||
|
||||
Test and production uploads through `upload:*` retain a fresh `.desktop-build/upload-records/<environment>-<target>-*` directory after release preflight. `plan.json` records destination, version, every file's size/SHA-512, and published YAML bytes; flushed `events.jsonl` records PUT intent and available response status/request ID; `result.json` records completion or the last failed stage. A missing final result means interruption or unavailable storage, not success. No credential values, authorization headers, or raw SDK errors are recorded. Audit-write failures stop later PUTs. Every object is one streamed Tencent COS PUT with an explicit length and Content-MD5; the COS SDK repeats a request only when its body is not a stream, and this uploader does not retry either. Retain partial records and inspect remote state before another operation: a timeout or failed receipt write does not prove that the object was not stored. These records are local, not tamper-proof or automatically backed up; archive each release's records with its build evidence in controlled storage. Public CDN readback remains separate release qualification, explicitly marked `not-performed` in the upload result.
|
||||
|
||||
Windows operators can keep a CLIXML object with DPAPI-encrypted `SecretId` and `SecretKey` SecureString fields outside the repository. The [credential launcher](scripts/upload-with-credentials.ps1) requires an explicit `-CredentialFile` and `-Environment production` or `test`; without `-Upload`, it only verifies decryption and injection into a local Node child, with no network request. It requires Node on `PATH` and the Windows user and machine that encrypted the file. Plaintext, empty, and whitespace-only fields fail. The parent environment is unchanged; the child receives only the selected COS pair after unrelated secrets and Node preload options are removed. Raw child stderr is suppressed and credential values in stdout are redacted. This check does not prove COS authorization. An explicit upload additionally requires `-Upload -Target <target> -Bucket <bucket>` and the normal completed-release prerequisites below; actual cloud upload remains release-operator qualification. This launcher supports permanent keys, not STS credentials. An explicit upload requires the selected deployment and bucket to match the target dotenv and completed package record before network writes; it uses the DPAPI credential pair even if the dotenv contains other COS keys.
|
||||
Windows operators can keep a CLIXML object with DPAPI-encrypted `SecretId` and `SecretKey` SecureString fields outside the repository. The [credential launcher](scripts/upload-with-credentials.ps1) requires an explicit `-CredentialFile` and `-Environment production` or `test`; without `-Upload`, it only verifies decryption and injection into a local Node child, with no network request. It requires Node on `PATH` and the Windows user and machine that encrypted the file. Plaintext, empty, and whitespace-only fields fail. The parent environment is unchanged; the child receives only the selected COS pair after unrelated secrets and Node preload options are removed. Raw child stderr is suppressed and credential values in stdout are redacted. This check does not prove COS authorization. An explicit upload additionally requires `-Upload -Target <target> -Bucket <bucket>` and the normal completed-release prerequisites below; actual cloud upload remains release-operator qualification. Add `-Latest` to that upload invocation to replace the fixed installer without publishing update feeds or a release tag. This launcher supports permanent keys, not STS credentials. An explicit upload requires the selected deployment and bucket to match the target dotenv and completed package record before network writes; it uses the DPAPI credential pair even if the dotenv contains other COS keys.
|
||||
|
||||
`DSH_DESKTOP_AUTO_UPDATE_ENV` selects `test` or `production` for both the URL embedded during packaging and the later COS upload; an absent value selects `test`. Test packaging requires its HTTPS origin in `DOWNLOAD_TEST_ORIGIN`; production uses `https://download.deepseek.com`. Upload requires the selected bucket in `DOWNLOAD_TEST_COS_BUCKET` or `DOWNLOAD_PROD_COS_BUCKET`. Production feeds use `dsh-desk/feeds/<target>/` and binaries use `dsh-desk/bin/<target>/`. Test releases require `DOWNLOAD_TEST_RELEASE_ID`: 32 lowercase hexadecimal characters inserted as `dsh-desk/<release-id>/feeds/<target>/` and `dsh-desk/<release-id>/bin/<target>/`. YAML references, stable-channel aliases, and blockmaps stay inside that release directory. Targets are `mac-arm64`, `mac-x64`, and `win-x64`.
|
||||
|
||||
|
||||
@@ -22,6 +22,14 @@ Desktop Host 的 Platform API 请求与更新策略请求用相同的 Platform
|
||||
|
||||
按 F12(多媒体功能键键盘上为 Fn+F12)、macOS 的 Command+Option+I 或 Windows 的 Ctrl+Shift+I,可切换当前获得焦点的应用页面的 DevTools,打包版本同样支持。这些原生快捷键通过隐藏的应用菜单项注册。更新遮罩和打包版本的内嵌浏览器禁用 DevTools。
|
||||
|
||||
## 终端命令
|
||||
|
||||
应用菜单中的**管理 dsh 命令…**位于**检查更新…**下方,显示当前命令,并提供安装、修复和移除操作。命令复用 Desktop 已安装的运行时和普通 [dsh CLI](../cli/README.zh.md),Desktop 应用关闭后也可以使用。安装后打开新终端,运行 `dsh --version`。
|
||||
|
||||
macOS 安装会创建 `/usr/local/bin/dsh`;目录权限需要时,系统会请求管理员认证,不会修改 shell 启动文件。Windows 管理对话框将命令注册到当前用户的 PATH。切换已有命令前会要求确认;修复当前已选中的 Desktop 命令不会重复要求切换确认。macOS 链接会保留并恢复被替换的启动器;Windows 会保留其他 PATH 条目,包括注册前就已存在的条目。若其他命令的 PATH 优先级更高,对话框会显示其位置。移动应用后,macOS 使用“修复”,Windows 从新位置使用“安装”。“移除”不会改动无关安装。
|
||||
|
||||
命令注册是安装 Desktop 后的可选操作。卸载 Desktop 前,请通过**管理 dsh 命令… → 移除**删除其 CLI 注册;应用卸载程序不会移除该注册。更新或卸载 Desktop 前请结束 CLI 命令。CLI 运行时版本随已安装的 Desktop 版本变化。Desktop 插件命令与运行时限制见[内置命令运行时](#bundled-command-runtime)。
|
||||
|
||||
## 关闭窗口与退出
|
||||
|
||||
关闭主窗口(macOS 的关闭按钮和 ⌘W;Windows 的 ×、Alt+F4 和任务栏"关闭窗口")会隐藏窗口;Windows 首次隐藏前需要确认。页面和 Host 继续运行,任务不受影响,下次显示时仍是原来的文档,会话、草稿和滚动位置都保留;macOS 全屏窗口先退出全屏再隐藏。macOS 通过 Dock 图标、再次启动或 `dsh://open` 找回窗口,Windows 通过托盘找回。最小化行为不变。进入工作区前关闭欢迎窗口,Windows 上走退出流程,macOS 上应用留在 Dock 中且没有窗口。
|
||||
@@ -78,9 +86,17 @@ Node 准备内置解释器和 Python 库,无需系统 Python 或 pip。[下载
|
||||
|
||||
Welcome 加载共享 Toast 的配色和阴影变量,挂载在 body 下的通知使用系统字体。
|
||||
|
||||
## 内置命令运行时
|
||||
|
||||
安装后的 `resources/runtime/cli/bin/dsh` shell 脚本(Windows 为 `dsh.cmd`)使用 Desktop 的 Electron 可执行文件和内置 pnpm 运行普通 CLI 分派入口。Desktop 关闭时也可使用,并保留 [Electron 运行时限制](../../.agents/notes/implemented/architecture/2026-09-11-desktop-electron-node-runtime.zh.md)。普通 profile、配置和插件命令与 npm dsh 使用相同实现;该命令不会打开 Desktop。
|
||||
|
||||
管理 Desktop 插件前,先启动一次 Desktop 以初始化其 profile,完全退出应用,再运行 `dsh plugin --profile desktop add <package>`、`list` 或 `remove <package>`。重新打开 Desktop 后使用更改。包操作保留共享的 profile 写锁和兼容性检查。内置命令拒绝未初始化的 Desktop profile,不会在其位置创建普通 CLI profile。
|
||||
|
||||
更新或卸载 Desktop 前请结束 CLI 命令;安装程序不与运行中的命令协调。CLI 不提供 Office 创作 skill 的资源路径。Desktop 现有 Office 设置与 Web 附件转换保留各自的资源配置。`prepare:runtime` 复制启动脚本,无需编译原生 CLI 启动器。
|
||||
|
||||
## 安装归属
|
||||
|
||||
Electron 拥有 `$DSH_HOME/profiles/desktop`。其 `dependencies` 包含 pnpm 安装的包;`dsh.profile.bundles` 包含内置 bundle,后接已启用插件。签名应用从 `resources/app.asar/dsh` 提供 dsh、私有 Desktop Host 及其生产依赖。打包应用选择 runtime profile 解析,不创建包链接;开发 profile 使用文件系统链接。宿主与插件在同一个 Electron Node 模式进程中执行;Desktop 不启用 `--preserve-symlinks`。CLI 不能启动或修改此 profile。
|
||||
Electron 拥有 `$DSH_HOME/profiles/desktop`。其 `dependencies` 包含 pnpm 安装的包;`dsh.profile.bundles` 包含内置 bundle,后接已启用插件。签名应用从 `resources/app.asar/dsh` 提供 dsh、私有 Desktop Host 及其生产依赖。打包应用选择 runtime profile 解析,不创建包链接;开发 profile 使用文件系统链接。宿主与插件在同一个 Electron Node 模式进程中执行;Desktop 不启用 `--preserve-symlinks`。CLI 不能启动此 profile。Desktop 内置命令可在应用退出后管理其插件;npm 安装的 dsh 不能修改它。
|
||||
|
||||
应用 preload 只向 `dsh-app://app` 文档暴露启动就绪、致命启动失败上报、原生目录选择、用于 composer 路径引用的 `__DSH_HOST_PATHS__` 桥接和租约范围内的 Browser 桥接。同一个 preload 通过 `dshDesktop.deviceInfo()` 转发主进程采集的机器描述,按 `name=value` 字段以 `; ` 分隔:`platform`、`os`、`app_arch`(应用二进制实际运行的架构,模拟运行时与硬件架构不同)、`cpu` 和 `memory_gib`(物理内存总量,GiB,保留一位小数)。取值不可用时省略对应字段。该描述不包含主机名、用户名或序列号。产品页面还获得 Desktop 标记、更新展示数据和打开原生确认的操作,不能选择安装产物或授权安装。插件管理使用 Web 应用经过认证的 HTTP API;Electron 在 `dsh-app://shell/` 本地提供更新弹窗文档和资源,不依赖 Host 就绪。Electron 不提供插件管理 IPC 或独立管理页面。任何渲染进程都不会获得文件系统访问、原始 Electron IPC、shell 或任意 pnpm 参数。
|
||||
|
||||
@@ -120,6 +136,10 @@ macOS 上自定义菜单保留 Electron 的标准 Window 菜单及应用隐藏
|
||||
|
||||
恢复操作等待 Host 关闭后才修改插件启用状态。原生恢复操作在 profile 事务锁内调用共享 app-boot 恢复函数。它禁用第三方 bundle,并将 profile 的 `cordis.patch.yml` 重命名为 `cordis.patch.yml.bak-<timestamp>`(重名时追加序号),无需解析;下次启动创建空 patch。已安装包和已有备份保留。home 级 patch 不变。Electron 控制台记录备份路径(或原文件不存在)以及 home 级 patch 未修改。profile 数据无效、重命名失败或写入失败会作为恢复操作错误报告;已完成的修改保留,Desktop 不会假装恢复成功后重启。Desktop 不提供 profile 重置操作或应急 HTML 文档。
|
||||
|
||||
### Host 环境
|
||||
|
||||
macOS 和 Linux 从图形界面启动的程序只继承会话管理器提供的环境,不包含 shell 启动文件导出的变量。第一个 Host 启动之前,Desktop 以 `<shell> -ilc` 运行一次账户的登录 shell(取自用户数据库,不看 `$SHELL`),读取定界符之间的 `env -0` 输出,使 `~/.zprofile` 和 `~/.zshrc`(或该 shell 的对应文件)对 Host、agent shell、终端和 profile 配置生效。读取与 profile 准备并行进行。读取进程没有终端输入,并设置 `DISABLE_AUTO_UPDATE=true`、`ZSH_TMUX_AUTOSTARTED=true` 和 `ZSH_TMUX_AUTOSTART=false`,避免 oh-my-zsh 和 tmux 插件阻塞。shell 的值覆盖继承的值,但 `PWD`、`OLDPWD`、`SHLVL`、`_`、上述读取变量以及启动方自有的 `DSH_*` 和 `ELECTRON_*` 除外;Desktop 在读取之前已按 `DSH_HOME` 等变量解析路径,因此 Host 保持相同的值。读取在结束定界符出现时完成,因此启动文件启动的后台进程可以继续运行,其输出被丢弃。候选 shell 无法启动、以非零状态退出、没有输出定界内容或超过 `DSH_DESKTOP_LOGIN_SHELL_TIMEOUT_MS`(1000 到 2147483647 的整数毫秒,默认 `10000`;超时会结束其进程组)时,Desktop 记录一条警告,并依次尝试 `/bin/zsh`、`/bin/bash` 和 `/bin/sh`;全部失败时 Host 使用继承的环境。读取命令使用 POSIX 语法,因此 csh、tcsh 或 nushell 等账户 shell 会失败,Host 改为获得第一个系统 shell 的启动文件所设置的环境。读取期间退出 Desktop 会结束正在运行的读取进程组。每个应用进程只读取一次,因此修改 shell 启动文件后需要退出并重新打开 Desktop。Windows 从图形界面启动的程序已经从注册表继承用户和系统环境变量,因此 Windows 跳过这一步。
|
||||
|
||||
## 开发
|
||||
|
||||
开发环境应用菜单提供“刷新页面”(macOS 为 Cmd+R,其他平台为 Ctrl+R)和“重启应用与 Host”。重启会等待 Host 关闭,再重新启动 Electron 和新的 Host;这两项操作都不会重新构建源码。
|
||||
@@ -237,9 +257,11 @@ Windows 安装器在启动时和选定目标目录后检查应用是否正在运
|
||||
|
||||
### 上传更新
|
||||
|
||||
固定安装包下载在仓库根目录使用 `pnpm run upload:latest:mac:arm64`、`pnpm run upload:latest:mac:x64` 或 `pnpm run upload:latest:win:x64`。它们把已完成目标的 DMG 或 EXE 上传到 `desktop/dsh-latest-macos-arm64.dmg`、`desktop/dsh-latest-macos-x64.dmg` 或 `desktop/dsh-latest-windows-x64.exe`。生产 URL 以 `https://download.deepseek.com/` 开头;测试 URL 使用 `DOWNLOAD_TEST_ORIGIN`。两个部署环境均复用目标 dotenv 中的部署、bucket 和凭据、全部常规发布校验以及下述上传审计。每个命令覆盖一个固定对象,不发布更新清单、blockmap 或发布标签。测试固定下载在测试 bucket 中跨发布 ID 共享;包内更新 URL 仍要求原有发布 ID。发布由操作人员显式执行,也允许预发布版本。部署基础设施必须配置重新验证,或在覆盖后清除固定下载的缓存。本地回环测试覆盖上传行为;真实 COS 和 CDN 验收由发布操作人员负责。
|
||||
|
||||
test 与 production 的 `upload:*` 上传在发布前置检查通过后,分别保留新的 `.desktop-build/upload-records/<environment>-<target>-*` 目录。`plan.json` 记录目标、版本、每个文件的大小/SHA-512 和发布的 YAML 字节;刷盘的 `events.jsonl` 记录 PUT 意图及可用的响应状态/请求 ID;`result.json` 记录完成结果或最后失败阶段。缺少最终结果表示中断或存储不可用,不表示成功。不记录凭据值、认证头或原始 SDK 错误。审计写入失败即停止后续 PUT。每个对象都以一次流式腾讯 COS PUT 上传,并携带显式长度与 Content-MD5;COS SDK 仅在请求体不是流时才会重发请求,上传器自身也不重试。保留部分记录,检查远端状态后再执行下一次操作:超时或回执写入失败不能证明对象未存储。这些记录仅在本地,不防篡改,也不会自动备份;每次发布应将它们与构建证据一同归档到受控存储。公网 CDN 回读仍是单独的发布验收,上传结果明确标记为 `not-performed`。
|
||||
|
||||
Windows 操作人员可以在仓库外保存 CLIXML 对象,其中 `SecretId` 和 `SecretKey` 是经 DPAPI 加密的 SecureString 字段。[凭据启动器](scripts/upload-with-credentials.ps1)要求显式提供 `-CredentialFile` 和 `-Environment production` 或 `test`;不指定 `-Upload` 时,只验证解密以及向本地 Node 子进程注入凭据,不发起网络请求。它要求 `PATH` 中有 Node,并使用加密该文件时的 Windows 用户和机器。明文、空字段及纯空白字段都会失败。父进程环境保持不变;子进程先清除无关密钥与 Node 预加载选项,再仅接收所选 COS 凭据对。原始子进程 stderr 不会显示,stdout 中的凭据值会被遮盖。此检查不能证明 COS 授权有效。显式上传还要求 `-Upload -Target <target> -Bucket <bucket>` 及下述常规发布完成前提;真实云端上传仍需发布操作人员验收。此启动器支持长期密钥,不支持 STS 凭据。显式上传要求所选部署环境和 bucket 与目标 dotenv 文件及已完成的打包记录一致,才会发起网络写入;即使 dotenv 文件含有其他 COS 密钥,也使用 DPAPI 凭据对。
|
||||
Windows 操作人员可以在仓库外保存 CLIXML 对象,其中 `SecretId` 和 `SecretKey` 是经 DPAPI 加密的 SecureString 字段。[凭据启动器](scripts/upload-with-credentials.ps1)要求显式提供 `-CredentialFile` 和 `-Environment production` 或 `test`;不指定 `-Upload` 时,只验证解密以及向本地 Node 子进程注入凭据,不发起网络请求。它要求 `PATH` 中有 Node,并使用加密该文件时的 Windows 用户和机器。明文、空字段及纯空白字段都会失败。父进程环境保持不变;子进程先清除无关密钥与 Node 预加载选项,再仅接收所选 COS 凭据对。原始子进程 stderr 不会显示,stdout 中的凭据值会被遮盖。此检查不能证明 COS 授权有效。显式上传还要求 `-Upload -Target <target> -Bucket <bucket>` 及下述常规发布完成前提;真实云端上传仍需发布操作人员验收。在该上传调用中添加 `-Latest`,即可替换固定安装包,而不发布更新清单或发布标签。此启动器支持长期密钥,不支持 STS 凭据。显式上传要求所选部署环境和 bucket 与目标 dotenv 文件及已完成的打包记录一致,才会发起网络写入;即使 dotenv 文件含有其他 COS 密钥,也使用 DPAPI 凭据对。
|
||||
|
||||
`DSH_DESKTOP_AUTO_UPDATE_ENV` 同时选择打包写入的 URL 与后续 COS 上传环境,可取 `test` 或 `production`;缺省为 `test`。测试打包通过 `DOWNLOAD_TEST_ORIGIN` 提供 HTTPS origin;生产使用 `https://download.deepseek.com`。上传通过 `DOWNLOAD_TEST_COS_BUCKET` 或 `DOWNLOAD_PROD_COS_BUCKET` 提供所选 bucket。生产清单位于 `dsh-desk/feeds/<target>/`,安装包位于 `dsh-desk/bin/<target>/`。测试发布必须配置 `DOWNLOAD_TEST_RELEASE_ID`:32 位小写十六进制字符,分别插入路径 `dsh-desk/<release-id>/feeds/<target>/` 和 `dsh-desk/<release-id>/bin/<target>/`。YAML 引用、稳定通道别名和 blockmap 都位于该发布目录内。目标为 `mac-arm64`、`mac-x64` 和 `win-x64`。
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
#!/bin/sh
|
||||
# Run the installed CLI without changing the invoking shell or working directory.
|
||||
set -e
|
||||
launcher=$0
|
||||
while [ -L "$launcher" ]; do
|
||||
directory=$(CDPATH= cd -- "$(dirname -- "$launcher")" && pwd -P)
|
||||
target=$(readlink "$launcher")
|
||||
case "$target" in
|
||||
/*) launcher=$target ;;
|
||||
*) launcher=$directory/$target ;;
|
||||
esac
|
||||
done
|
||||
resources=$(CDPATH= cd -- "$(dirname -- "$launcher")/../../.." && pwd -P)
|
||||
ELECTRON_RUN_AS_NODE=1 exec "$resources/../MacOS/DeepSeek Harness" --expose-internals "$resources/app.asar/dsh/node_modules/@deepseek-ai/dsh-desktop-host/lib/cli.js" "$@"
|
||||
@@ -0,0 +1,5 @@
|
||||
@echo off
|
||||
setlocal DisableDelayedExpansion
|
||||
set "ELECTRON_RUN_AS_NODE=1"
|
||||
"%~dp0..\..\..\..\DeepSeek Harness.exe" --expose-internals "%~dp0..\..\..\app.asar\dsh\node_modules\@deepseek-ai\dsh-desktop-host\lib\cli.js" %*
|
||||
exit /b %errorlevel%
|
||||
@@ -0,0 +1,18 @@
|
||||
// Preserve a command symlink's inode while refusing an occupied destination.
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
|
||||
int main(int count, char **arguments) {
|
||||
if (count != 3 || arguments[1][0] != '/' || arguments[2][0] != '/') {
|
||||
fprintf(stderr, "Command entry paths must be absolute.\n");
|
||||
return 1;
|
||||
}
|
||||
if (linkat(AT_FDCWD, arguments[1], AT_FDCWD, arguments[2], 0) != 0) {
|
||||
fprintf(stderr, "Cannot link command entry: %s\n", strerror(errno));
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -35,7 +35,10 @@
|
||||
"package:win:x64:dir": "tsx scripts/package-target.ts win-x64 --dir",
|
||||
"upload:mac:arm64": "tsx scripts/upload-target.ts mac-arm64",
|
||||
"upload:mac:x64": "tsx scripts/upload-target.ts mac-x64",
|
||||
"upload:win:x64": "tsx scripts/upload-target.ts win-x64"
|
||||
"upload:win:x64": "tsx scripts/upload-target.ts win-x64",
|
||||
"upload:latest:mac:arm64": "tsx scripts/upload-target.ts mac-arm64 --latest",
|
||||
"upload:latest:mac:x64": "tsx scripts/upload-target.ts mac-x64 --latest",
|
||||
"upload:latest:win:x64": "tsx scripts/upload-target.ts win-x64 --latest"
|
||||
},
|
||||
"dependencies": {
|
||||
"electron-updater": "^6.8.9",
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
<# Per-user PATH ownership. The worker owns its mutex through registry publication. #>
|
||||
function Invoke-DshCommandPath {
|
||||
param([hashtable]$Request, [string]$EnvironmentKey, [string]$OwnerKey, [string]$MachinePath, [string]$MutexName)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
function Fail([string]$Code, [string]$Message) {
|
||||
$error = [InvalidOperationException]::new($Message)
|
||||
$error.Data['code'] = $Code
|
||||
throw $error
|
||||
}
|
||||
function Comparable([string]$Path) {
|
||||
$expanded = [Environment]::ExpandEnvironmentVariables($Path.Trim().Trim('"'))
|
||||
try {
|
||||
if (-not [IO.Path]::IsPathRooted($expanded)) { return $expanded }
|
||||
return [IO.Path]::GetFullPath($expanded).TrimEnd('\','/').ToUpperInvariant()
|
||||
} catch {
|
||||
# Invalid user PATH entries cannot identify the managed directory.
|
||||
return $expanded
|
||||
}
|
||||
}
|
||||
$directory = [string]$Request.directory
|
||||
if (-not [IO.Path]::IsPathRooted($directory) -or $directory.Contains(';') -or $directory.Contains([char]0)) {
|
||||
Fail 'EUNSUPPORTED' 'The command directory cannot be represented in PATH.'
|
||||
}
|
||||
$directory = [IO.Path]::GetFullPath($directory)
|
||||
if ($Request.operation -notin @('inspect','install','remove')) { Fail 'EINVAL' 'Unknown command operation.' }
|
||||
$mutex = [Threading.Mutex]::new($false, $MutexName)
|
||||
$locked = $false
|
||||
try {
|
||||
try { $locked = $mutex.WaitOne(5000) } catch [Threading.AbandonedMutexException] { $locked = $true }
|
||||
if (-not $locked) { Fail 'EBUSY' 'Another command-management operation is running.' }
|
||||
$environment = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey($EnvironmentKey)
|
||||
$owner = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey($OwnerKey)
|
||||
try {
|
||||
$raw = if ($environment) { $environment.GetValue('Path', $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) } else { $null }
|
||||
if ($null -ne $raw -and $raw -isnot [string]) { Fail 'EUNSUPPORTED' 'The user PATH value is not a string.' }
|
||||
$kind = if ($null -ne $raw) { $environment.GetValueKind('Path') } else { [Microsoft.Win32.RegistryValueKind]::ExpandString }
|
||||
$owned = if ($owner) { $owner.GetValue('Directory', $null) } else { $null }
|
||||
$wasAbsent = if ($owner) { $owner.GetValue('PathWasAbsent', 0) -eq 1 } else { $false }
|
||||
$retained = if ($owner) { [int]$owner.GetValue('RetainedEntries', 0) } else { 0 }
|
||||
} finally {
|
||||
if ($environment) { $environment.Dispose() }
|
||||
if ($owner) { $owner.Dispose() }
|
||||
}
|
||||
$snapshot = [ordered]@{ path=$raw; kind=[string]$kind; owned=$owned; absent=$wasAbsent; retained=$retained; directory=$directory; machine=$MachinePath }
|
||||
$sha = [Security.Cryptography.SHA256]::Create()
|
||||
try { $fingerprint = ([BitConverter]::ToString($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes(($snapshot | ConvertTo-Json -Compress))))).Replace('-','').ToLowerInvariant() }
|
||||
finally { $sha.Dispose() }
|
||||
if ($Request.operation -ne 'inspect' -and $Request.expected -ne $fingerprint) { Fail 'ESTALE' 'PATH changed after confirmation.' }
|
||||
$parts = if ($null -eq $raw) { @() } else { @($raw.Split(';')) }
|
||||
$current = Comparable $directory
|
||||
$old = if ($owned) { Comparable ([string]$owned) } else { $null }
|
||||
$kept = $parts
|
||||
if ($old -and ($Request.operation -eq 'install' -or ($Request.operation -eq 'remove' -and $old -eq $current))) {
|
||||
$ownedMatches = @($parts | Where-Object { $_ -ceq $owned }).Count
|
||||
if ($ownedMatches -gt $retained) {
|
||||
$index = [Array]::IndexOf($parts, $owned)
|
||||
$kept = @(); for ($i = 0; $i -lt $parts.Count; $i++) { if ($i -ne $index) { $kept += $parts[$i] } }
|
||||
}
|
||||
}
|
||||
if ($Request.operation -eq 'install') {
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $directory 'dsh.cmd') -PathType Leaf)) { Fail 'ENOENT' 'The installed launcher is unavailable.' }
|
||||
$next = (@($directory) + $kept) -join ';'
|
||||
$environment = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey($EnvironmentKey)
|
||||
$owner = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey($OwnerKey)
|
||||
try {
|
||||
$environment.SetValue('Path', $next, $kind)
|
||||
$owner.SetValue('Directory', $directory, [Microsoft.Win32.RegistryValueKind]::String)
|
||||
$absent = ($null -eq $raw) -or ($owned -and $wasAbsent)
|
||||
$owner.SetValue('PathWasAbsent', [int]$absent, [Microsoft.Win32.RegistryValueKind]::DWord)
|
||||
$owner.SetValue('RetainedEntries', @($kept | Where-Object { $_ -ceq $directory }).Count, [Microsoft.Win32.RegistryValueKind]::DWord)
|
||||
} finally { $environment.Dispose(); $owner.Dispose() }
|
||||
$raw = $next
|
||||
$owned = $directory
|
||||
} elseif ($Request.operation -eq 'remove') {
|
||||
if ($kept.Count -ne $parts.Count) {
|
||||
$environment = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey($EnvironmentKey)
|
||||
try {
|
||||
if ($kept.Count -eq 0 -and $wasAbsent) { $environment.DeleteValue('Path', $false); $raw = $null }
|
||||
else { $raw = $kept -join ';'; $environment.SetValue('Path', $raw, $kind) }
|
||||
} finally { $environment.Dispose() }
|
||||
}
|
||||
$owner = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey($OwnerKey, $true)
|
||||
if ($owner -and $old -eq $current) {
|
||||
try { $owner.DeleteValue('Directory', $false); $owner.DeleteValue('PathWasAbsent', $false); $owner.DeleteValue('RetainedEntries', $false) }
|
||||
finally { $owner.Dispose() }
|
||||
$owned = $null
|
||||
} elseif ($owner) {
|
||||
$owner.Dispose()
|
||||
}
|
||||
}
|
||||
$active = $null
|
||||
foreach ($part in (($MachinePath + ';' + [string]$raw).Split(';'))) {
|
||||
$path = [Environment]::ExpandEnvironmentVariables($part.Trim().Trim('"'))
|
||||
try { $rooted = [IO.Path]::IsPathRooted($path) } catch { continue }
|
||||
if (-not $rooted) { continue }
|
||||
$extensions = if ($env:PATHEXT) { @($env:PATHEXT.Split(';')) } else { @('.com','.exe','.bat','.cmd') }
|
||||
foreach ($extension in (@('.ps1') + $extensions)) {
|
||||
try {
|
||||
$candidate = Join-Path $path ('dsh' + $extension)
|
||||
if (Test-Path -LiteralPath $candidate -PathType Leaf) { $active = $candidate; break }
|
||||
} catch {
|
||||
# Invalid or unavailable PATH candidates do not prevent checking later entries.
|
||||
continue
|
||||
}
|
||||
}
|
||||
if ($active) { break }
|
||||
}
|
||||
$onPath = @(([string]$raw).Split(';') | Where-Object { (Comparable $_) -eq $current }).Count -gt 0
|
||||
return [ordered]@{ fingerprint=$fingerprint; directory=$directory; ownedDirectory=$owned; managed=([bool]$owned -and (Comparable ([string]$owned)) -eq $current); activeCommand=$active; available=($onPath -and (Test-Path -LiteralPath (Join-Path $directory 'dsh.cmd') -PathType Leaf)) }
|
||||
} finally {
|
||||
if ($locked) { $mutex.ReleaseMutex() }
|
||||
$mutex.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
function Send-DshCommandEnvironmentChange {
|
||||
try {
|
||||
if (-not ('DshCommandEnvironment' -as [type])) {
|
||||
Add-Type -TypeDefinition 'using System; using System.Runtime.InteropServices; public static class DshCommandEnvironment { [DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern IntPtr SendMessageTimeout(IntPtr h, uint m, IntPtr w, string l, uint f, uint t, out IntPtr r); }'
|
||||
}
|
||||
$ignored = [IntPtr]::Zero
|
||||
[void][DshCommandEnvironment]::SendMessageTimeout([IntPtr]0xffff, 0x1a, [IntPtr]::Zero, 'Environment', 2, 2000, [ref]$ignored)
|
||||
} catch {
|
||||
# The PATH write is committed. A failed notification only delays other processes observing it.
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if ($MyInvocation.InvocationName -ne '.') {
|
||||
$ErrorActionPreference = 'Stop'
|
||||
[Console]::InputEncoding = [Text.UTF8Encoding]::new($false)
|
||||
[Console]::OutputEncoding = [Text.UTF8Encoding]::new($false)
|
||||
try {
|
||||
$inputValue = [Console]::In.ReadToEnd() | ConvertFrom-Json
|
||||
$request = @{ operation=$inputValue.operation; directory=$inputValue.directory; expected=$inputValue.expected }
|
||||
$machine = [Microsoft.Win32.Registry]::LocalMachine.OpenSubKey('SYSTEM\CurrentControlSet\Control\Session Manager\Environment')
|
||||
try { $machinePath = [string]$machine.GetValue('Path', '', [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) }
|
||||
finally { $machine.Dispose() }
|
||||
$sid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value
|
||||
$state = Invoke-DshCommandPath -Request $request -EnvironmentKey 'Environment' -OwnerKey 'Software\DeepSeekHarness\Command' -MachinePath $machinePath -MutexName ('Global\DeepSeekHarness.Command.' + $sid)
|
||||
if ($request.operation -ne 'inspect') {
|
||||
Send-DshCommandEnvironmentChange
|
||||
}
|
||||
@{ ok=$true; state=$state } | ConvertTo-Json -Compress -Depth 5
|
||||
} catch {
|
||||
$code = if ($_.Exception.Data['code']) { $_.Exception.Data['code'] } else { 'EIO' }
|
||||
@{ ok=$false; code=$code; message=$_.Exception.Message } | ConvertTo-Json -Compress
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
@@ -38,11 +38,12 @@ export interface DesktopUploadArtifact {
|
||||
readonly contents?: string
|
||||
}
|
||||
|
||||
/** A fully validated upload operation with channel metadata ordered last. */
|
||||
/** A validated installer or update upload, with any channel metadata ordered last. */
|
||||
export interface DesktopUploadPlan {
|
||||
readonly environment: 'test' | 'production'
|
||||
readonly target: DesktopPackageTargetName
|
||||
readonly version: string
|
||||
/** Update feed directory URL, or the single installer URL for a fixed download. */
|
||||
readonly publicUrl: string
|
||||
readonly bucket: string
|
||||
readonly secretIdEnvName: string
|
||||
@@ -56,6 +57,8 @@ export interface DesktopUploadPlan {
|
||||
|
||||
/** Filesystem and environment inputs used to validate one upload. */
|
||||
export interface DesktopUploadPlanOptions {
|
||||
/** Publish only the installer at its fixed download URL, replacing the previous object. */
|
||||
readonly latest?: boolean
|
||||
readonly environment?: NodeJS.ProcessEnv
|
||||
readonly repositoryRoot?: string
|
||||
readonly appRoot?: string
|
||||
@@ -171,7 +174,7 @@ function uploadArtifact(
|
||||
* Validate the completed package record, dsh version, update metadata, hashes, and target files.
|
||||
* @param targetName - Fixed platform and architecture selected by the upload command.
|
||||
* @param options - Optional filesystem roots and environment for tests or release automation.
|
||||
* @returns An upload plan whose mutable channel metadata is the final entry.
|
||||
* @returns A fixed installer upload or an update plan with channel metadata ordered last.
|
||||
*/
|
||||
export async function createDesktopUploadPlan(
|
||||
targetName: DesktopPackageTargetName,
|
||||
@@ -237,23 +240,26 @@ export async function createDesktopUploadPlan(
|
||||
const updaterPath = await verifyChecksummedArtifact(artifactsRoot, updaterInfo)
|
||||
const artifacts: DesktopUploadArtifact[] = []
|
||||
const binaryPrefix = update.binaryKeyPrefix
|
||||
let installerArtifact: DesktopUploadArtifact
|
||||
|
||||
if (target.platform === 'darwin') {
|
||||
const dmgPath = await requireArtifact(artifactsRoot, `${base}.dmg`)
|
||||
const blockmapPath = await requireArtifact(artifactsRoot, `${base}.zip.blockmap`)
|
||||
installerArtifact = uploadArtifact(dmgPath, binaryPrefix, 'application/x-apple-diskimage')
|
||||
artifacts.push(
|
||||
uploadArtifact(dmgPath, binaryPrefix, 'application/x-apple-diskimage'),
|
||||
installerArtifact,
|
||||
uploadArtifact(updaterPath, binaryPrefix, 'application/zip'),
|
||||
uploadArtifact(blockmapPath, binaryPrefix, 'application/octet-stream'),
|
||||
)
|
||||
}
|
||||
else {
|
||||
const blockmapPath = await requireArtifact(artifactsRoot, `${base}.exe.blockmap`)
|
||||
artifacts.push(uploadArtifact(
|
||||
installerArtifact = uploadArtifact(
|
||||
updaterPath,
|
||||
binaryPrefix,
|
||||
'application/vnd.microsoft.portable-executable',
|
||||
))
|
||||
)
|
||||
artifacts.push(installerArtifact)
|
||||
artifacts.push(uploadArtifact(blockmapPath, binaryPrefix, 'application/octet-stream'))
|
||||
}
|
||||
|
||||
@@ -272,15 +278,17 @@ export async function createDesktopUploadPlan(
|
||||
const stableFilename = metadataFilename.replace('nightly', 'latest')
|
||||
artifacts.push({ ...channelArtifact, filename: stableFilename, key: `${update.keyPrefix}/${stableFilename}` })
|
||||
}
|
||||
const latestFilename = `dsh-latest-${target.platform === 'darwin' ? 'macos' : 'windows'}-${target.arch}.${target.platform === 'darwin' ? 'dmg' : 'exe'}`
|
||||
const latestKey = `desktop/${latestFilename}`
|
||||
return {
|
||||
environment: update.environment,
|
||||
target: targetName,
|
||||
version: buildVersion,
|
||||
publicUrl: update.publicUrl,
|
||||
publicUrl: options.latest ? `${update.origin}/${latestKey}` : update.publicUrl,
|
||||
bucket: update.bucket,
|
||||
secretIdEnvName: update.secretIdEnvName,
|
||||
secretKeyEnvName: update.secretKeyEnvName,
|
||||
artifacts,
|
||||
artifacts: options.latest ? [{ ...installerArtifact, filename: latestFilename, key: latestKey }] : artifacts,
|
||||
...typeof buildRecord.commit === 'string' ? { commit: buildRecord.commit } : {},
|
||||
...typeof buildRecord.dirty === 'boolean' ? { dirty: buildRecord.dirty } : {},
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** Durable, credential-free evidence for test and production release uploads. */
|
||||
/** Durable, credential-free evidence for test and production Desktop uploads. */
|
||||
import { createHash } from 'node:crypto'
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises'
|
||||
@@ -52,7 +52,7 @@ function streamedBody(artifact: DesktopUploadArtifact): Readable {
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload an already validated release, flushing intent and response evidence around every PUT.
|
||||
* Upload an already validated installer or update plan, flushing intent and response evidence around every PUT.
|
||||
*
|
||||
* Each object is sent as one streamed PUT with an explicit length and Content-MD5, which is also
|
||||
* what keeps the COS SDK's internal retry path unreachable: it repeats a request only when the
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/** Package terminal launch scripts that reuse the installed Electron runtime. */
|
||||
|
||||
import { chmodSync, copyFileSync, mkdirSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
/**
|
||||
* Copy the platform launcher into the application's public command directory.
|
||||
* @param destination - Physical runtime/cli directory prepared for the application.
|
||||
* @param platform - Target Desktop operating system.
|
||||
*/
|
||||
export function prepareDesktopCli(destination: string, platform: 'darwin' | 'win32'): void {
|
||||
const name = platform === 'win32' ? 'dsh.cmd' : 'dsh'
|
||||
const command = join(destination, 'bin', name)
|
||||
mkdirSync(join(destination, 'bin'), { recursive: true })
|
||||
copyFileSync(join(import.meta.dirname, '..', 'cli', name), command)
|
||||
if (platform === 'darwin') chmodSync(command, 0o755)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/** Build the macOS helper for exact-path command symlink installation. */
|
||||
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
/**
|
||||
* Compile the command-link helper before signing Desktop resources.
|
||||
* @param destination - Physical runtime/cli directory.
|
||||
* @param arch - Desktop architecture, independent of the build host architecture.
|
||||
* @param minimumVersion - Minimum macOS version declared by Electron.
|
||||
*/
|
||||
export function prepareCommandLink(destination: string, arch: 'arm64' | 'x64', minimumVersion: string): void {
|
||||
if (process.platform !== 'darwin' || !/^\d+\.\d+(?:\.\d+)?$/u.test(minimumVersion)) {
|
||||
throw new Error('desktop command link: a macOS host and Electron minimum version are required')
|
||||
}
|
||||
mkdirSync(destination, { recursive: true })
|
||||
execFileSync('clang', ['-std=c11', '-O2', '-Wall', '-Wextra', '-Werror',
|
||||
'-arch', arch === 'arm64' ? 'arm64' : 'x86_64', '-mmacosx-version-min=' + minimumVersion,
|
||||
join(import.meta.dirname, '..', 'cli', 'link-entry.c'), '-o', join(destination, 'link-entry')], { stdio: 'inherit' })
|
||||
}
|
||||
@@ -10,6 +10,8 @@ import { downloadArtifact } from '@electron/get'
|
||||
import extractZip from 'extract-zip'
|
||||
import { resolveDesktopBuildTarget, resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs'
|
||||
import { preparePrimaryRuntime } from './prepare-primary-runtime.ts'
|
||||
import { prepareDesktopCli } from './prepare-cli.ts'
|
||||
import { prepareCommandLink } from './prepare-command-link.ts'
|
||||
|
||||
const BUILD_PATHS = resolveDesktopTargetBuildPaths()
|
||||
const RUNTIME_ROOT = BUILD_PATHS.runtime
|
||||
@@ -41,6 +43,8 @@ async function main(): Promise<void> {
|
||||
const nodeVersion = execFileSync(executable, ['-p', 'process.versions.node'], {
|
||||
encoding: 'utf8', env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' },
|
||||
}).trim()
|
||||
const macosMinimumVersion = platform === 'darwin' ? execFileSync('/usr/libexec/PlistBuddy',
|
||||
['-c', 'Print LSMinimumSystemVersion', join(BUILD_PATHS.electron, 'Electron.app', 'Contents', 'Info.plist')], { encoding: 'utf8' }).trim() : undefined
|
||||
rmSync(RUNTIME_ROOT, { recursive: true, force: true })
|
||||
mkdirSync(RUNTIME_ROOT, { recursive: true })
|
||||
const pnpmVersion = preparePnpm()
|
||||
@@ -51,6 +55,11 @@ async function main(): Promise<void> {
|
||||
node: nodeVersion,
|
||||
pnpm: pnpmVersion,
|
||||
}, undefined, 2)}\n`)
|
||||
await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'prepare:cli',
|
||||
async () => prepareDesktopCli(join(RUNTIME_ROOT, 'cli'), platform))
|
||||
if (macosMinimumVersion !== undefined) prepareCommandLink(join(RUNTIME_ROOT, 'cli'), arch, macosMinimumVersion)
|
||||
cpSync(join(import.meta.dirname, '..', 'lib', 'command-manager-entry.js'), join(RUNTIME_ROOT, 'cli', 'command-manager.js'))
|
||||
cpSync(join(import.meta.dirname, 'command-path.ps1'), join(RUNTIME_ROOT, 'cli', 'command-path.ps1'))
|
||||
await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'prepare:primary-runtime',
|
||||
() => preparePrimaryRuntime({ deferSmoke: values['defer-primary-runtime-smoke'] }))
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** Upload one validated Desktop release to its Tencent COS update directory. */
|
||||
/** Upload validated Desktop update artifacts or a fixed installer to Tencent COS. */
|
||||
|
||||
import { resolve } from 'node:path'
|
||||
import { parseArgs } from 'node:util'
|
||||
@@ -58,8 +58,14 @@ export function resolveCredentialUploadEnvironment(
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const { positionals, values } = parseArgs({ args: process.argv.slice(2), allowPositionals: true, options: {
|
||||
/**
|
||||
* Upload the selected target and record a release tag only for production update uploads.
|
||||
* @param args Target and upload options from the command line.
|
||||
* @returns Resolves after upload and any release-tag attempt complete.
|
||||
*/
|
||||
export async function uploadDesktopTarget(args: string[]): Promise<void> {
|
||||
const { positionals, values } = parseArgs({ args, allowPositionals: true, options: {
|
||||
latest: { type: 'boolean' },
|
||||
'credential-launcher': { type: 'boolean' }, environment: { type: 'string' }, bucket: { type: 'string' },
|
||||
} })
|
||||
const target = positionals[0]
|
||||
@@ -79,14 +85,14 @@ async function main(): Promise<void> {
|
||||
const environment = launcher
|
||||
? resolveCredentialUploadEnvironment(fileEnvironment, process.env, values.environment as 'test' | 'production', values.bucket!, name)
|
||||
: fileEnvironment
|
||||
const plan = await createDesktopUploadPlan(name, { environment })
|
||||
const plan = await createDesktopUploadPlan(name, { environment, latest: values.latest === true })
|
||||
const cos = createDesktopCos({
|
||||
secretId: requiredEnvironmentValue(environment, plan.secretIdEnvName),
|
||||
secretKey: requiredEnvironmentValue(environment, plan.secretKeyEnvName),
|
||||
})
|
||||
process.stdout.write(`desktop upload: ${plan.target} ${plan.version} -> ${plan.publicUrl}\n`)
|
||||
await uploadDesktopRelease(plan, cos, resolve(import.meta.dirname, '../.desktop-build/upload-records'))
|
||||
if (plan.environment === 'production') recordProductionRelease(plan)
|
||||
if (plan.environment === 'production' && !values.latest) recordProductionRelease(plan)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -116,7 +122,7 @@ function recordProductionRelease(plan: DesktopUploadPlan): void {
|
||||
}
|
||||
|
||||
if (process.argv[1] !== undefined && import.meta.filename === resolve(process.argv[1])) {
|
||||
main().catch(() => {
|
||||
uploadDesktopTarget(process.argv.slice(2)).catch(() => {
|
||||
process.stderr.write('desktop upload: failed; inspect the printed record directory if allocated. No automatic retry; reconcile remote state before another upload.\n')
|
||||
process.exitCode = 1
|
||||
})
|
||||
|
||||
@@ -14,6 +14,8 @@ Deployment that owns the credential pair; never inferred from the filename.
|
||||
Completed Desktop target to upload when Upload is explicitly selected.
|
||||
.PARAMETER Bucket
|
||||
COS bucket for an explicit upload. Not needed for the local credential check.
|
||||
.PARAMETER Latest
|
||||
Publish only the installer at its fixed download URL. Requires Upload.
|
||||
.PARAMETER Upload
|
||||
Authorize the existing target upload entry. Omit to check credentials locally.
|
||||
#>
|
||||
@@ -32,7 +34,9 @@ param(
|
||||
[ValidatePattern('^[a-z0-9-]+$')]
|
||||
[string]$Bucket,
|
||||
[Parameter(ParameterSetName = 'Publish', Mandatory = $true)]
|
||||
[switch]$Upload
|
||||
[switch]$Upload,
|
||||
[Parameter(ParameterSetName = 'Publish')]
|
||||
[switch]$Latest
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
@@ -87,6 +91,7 @@ try {
|
||||
if ($Upload) {
|
||||
$startInfo.EnvironmentVariables["${prefix}_BUCKET"] = $Bucket
|
||||
$startInfo.Arguments = "--import tsx/esm apps/desktop/scripts/upload-target.ts $Target --credential-launcher --environment $Environment --bucket $Bucket"
|
||||
if ($Latest) { $startInfo.Arguments += " --latest" }
|
||||
Write-Output "desktop credentials: uploading $Target to $Environment; release validation runs before network writes."
|
||||
} else {
|
||||
$probe = "const id=process.env.${prefix}_SECRET_ID;const key=process.env.${prefix}_SECRET_KEY;process.exit(id?.trim()&&key?.trim()?0:1)"
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
/** Reversible command-link ownership; operations never follow or overwrite a changed command entry. */
|
||||
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import { execFile } from 'node:child_process'
|
||||
import { constants } from 'node:fs'
|
||||
import { access, link, lstat, mkdir, readFile, readlink, rename, stat, symlink, unlink } from 'node:fs/promises'
|
||||
import { basename, dirname, isAbsolute, join } from 'node:path'
|
||||
import { promisify } from 'node:util'
|
||||
import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
|
||||
|
||||
/** Fixed installation locations supplied by the Desktop shell. */
|
||||
export interface FileCommandInstallation {
|
||||
readonly destination: string
|
||||
readonly launcher: string
|
||||
readonly linkHelper: string
|
||||
}
|
||||
|
||||
type Entry = { readonly kind: 'symlink'; readonly fingerprint: string; readonly target: string }
|
||||
| { readonly kind: 'missing' | 'file' | 'unsupported'; readonly fingerprint: string }
|
||||
|
||||
interface Backup {
|
||||
readonly name: string
|
||||
readonly fingerprint: string
|
||||
}
|
||||
|
||||
interface Receipt {
|
||||
readonly schemaVersion: 1
|
||||
readonly launcher: string
|
||||
readonly installedFingerprint: string
|
||||
readonly backup?: Backup
|
||||
}
|
||||
|
||||
/** State displayed before a command-management operation. */
|
||||
export interface CommandInspection {
|
||||
readonly fingerprint: string
|
||||
readonly destination: string
|
||||
readonly launcher: string
|
||||
readonly kind: Entry['kind']
|
||||
readonly managed: boolean
|
||||
readonly available: boolean
|
||||
readonly target?: string
|
||||
readonly backup?: string
|
||||
readonly preservedBackup?: string
|
||||
}
|
||||
|
||||
/** A declined stale operation or a command entry outside this installer's ownership. */
|
||||
export class CommandInstallationError extends Error {
|
||||
constructor(readonly code: string, message: string) { super(message) }
|
||||
}
|
||||
|
||||
function digest(value: unknown): string {
|
||||
return createHash('sha256').update(JSON.stringify(value)).digest('hex')
|
||||
}
|
||||
|
||||
function receiptPath(options: FileCommandInstallation): string {
|
||||
return join(dirname(options.destination), '.dsh-desktop-command.json')
|
||||
}
|
||||
|
||||
async function readEntry(path: string): Promise<Entry> {
|
||||
let info
|
||||
try { info = await lstat(path, { bigint: true }) } catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { kind: 'missing', fingerprint: digest('missing') }
|
||||
throw error
|
||||
}
|
||||
const fields = [info.dev, info.ino, info.mode, info.uid, info.gid, info.size, info.mtimeNs].map(String)
|
||||
if (info.isSymbolicLink()) {
|
||||
const target = await readlink(path)
|
||||
return { kind: 'symlink', fingerprint: digest({ kind: 'symlink', fields, target }), target }
|
||||
}
|
||||
const kind = info.isFile() ? 'file' : 'unsupported'
|
||||
return { kind, fingerprint: digest({ kind, fields }) }
|
||||
}
|
||||
|
||||
async function readReceipt(options: FileCommandInstallation): Promise<Receipt | undefined> {
|
||||
const path = receiptPath(options)
|
||||
let info
|
||||
try { info = await lstat(path) } catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined
|
||||
throw error
|
||||
}
|
||||
if (!info.isFile() || info.size > 8192) throw new CommandInstallationError('EOWNERSHIP', 'Invalid command ownership record.')
|
||||
let value: unknown
|
||||
try { value = JSON.parse(await readFile(path, 'utf8')) } catch (error) {
|
||||
if (!(error instanceof SyntaxError)) throw error
|
||||
throw new CommandInstallationError('EOWNERSHIP', 'Invalid command ownership record.')
|
||||
}
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||
throw new CommandInstallationError('EOWNERSHIP', 'Invalid command ownership record.')
|
||||
}
|
||||
const record = value as Record<string, unknown>
|
||||
if (record.schemaVersion !== 1 || typeof record.launcher !== 'string' || !isAbsolute(record.launcher)
|
||||
|| typeof record.installedFingerprint !== 'string' || !/^[a-f0-9]{64}$/u.test(record.installedFingerprint)) {
|
||||
throw new CommandInstallationError('EOWNERSHIP', 'Invalid command ownership record.')
|
||||
}
|
||||
let backup: Backup | undefined
|
||||
if (record.backup !== undefined) {
|
||||
if (typeof record.backup !== 'object' || record.backup === null) throw new CommandInstallationError('EOWNERSHIP', 'Invalid command backup record.')
|
||||
const candidate = record.backup as Record<string, unknown>
|
||||
if (typeof candidate.name !== 'string' || !/^\.dsh-command-backup-[a-f0-9-]{36}$/u.test(candidate.name)
|
||||
|| typeof candidate.fingerprint !== 'string' || !/^[a-f0-9]{64}$/u.test(candidate.fingerprint)) {
|
||||
throw new CommandInstallationError('EOWNERSHIP', 'Invalid command backup record.')
|
||||
}
|
||||
backup = { name: candidate.name, fingerprint: candidate.fingerprint }
|
||||
}
|
||||
return { schemaVersion: 1, launcher: record.launcher, installedFingerprint: record.installedFingerprint,
|
||||
...backup === undefined ? {} : { backup } }
|
||||
}
|
||||
|
||||
async function available(path: string): Promise<boolean> {
|
||||
try { await access(path, constants.X_OK); return (await stat(path)).isFile() } catch (error) {
|
||||
if (['ENOENT', 'EACCES'].includes((error as NodeJS.ErrnoException).code ?? '')) return false
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function readSnapshot(options: FileCommandInstallation) {
|
||||
const entry = await readEntry(options.destination)
|
||||
const receipt = await readReceipt(options)
|
||||
const state: CommandInspection = {
|
||||
fingerprint: digest({ entry: entry.fingerprint, receipt }),
|
||||
destination: options.destination, launcher: options.launcher, kind: entry.kind,
|
||||
managed: receipt !== undefined && entry.kind === 'symlink' && entry.target === receipt.launcher && entry.fingerprint === receipt.installedFingerprint,
|
||||
available: await available(options.destination),
|
||||
...entry.kind === 'symlink' ? { target: entry.target } : {},
|
||||
...receipt?.backup === undefined ? {} : { backup: join(dirname(options.destination), receipt.backup.name) },
|
||||
}
|
||||
return { entry, receipt, state }
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the command and receipt without changing either.
|
||||
* @param options - Fixed command destination and current installed launcher.
|
||||
* @returns A fingerprint to bind a later user decision to the observed entries.
|
||||
*/
|
||||
export async function inspectFileCommand(options: FileCommandInstallation): Promise<CommandInspection> {
|
||||
return (await readSnapshot(options)).state
|
||||
}
|
||||
|
||||
async function restoreEntry(options: FileCommandInstallation, source: string, destination: string, expected?: string): Promise<void> {
|
||||
const entry = await readEntry(source)
|
||||
if (expected !== undefined && entry.fingerprint !== expected) throw new CommandInstallationError('EOWNERSHIP', 'Command backup changed.')
|
||||
if (entry.kind !== 'symlink' && entry.kind !== 'file') throw new CommandInstallationError('EOWNERSHIP', 'Command backup is unavailable.')
|
||||
if (expected !== undefined) {
|
||||
const claimed = await withdraw(options, source, entry)
|
||||
if (claimed === undefined) throw new CommandInstallationError('EOWNERSHIP', 'Command backup is unavailable.')
|
||||
try { await restoreEntry(options, claimed, destination) } catch (error) {
|
||||
try { await restoreEntry(options, claimed, source) } catch (restoreError) {
|
||||
throw new AggregateError([error, restoreError], 'The command backup is preserved at ' + claimed)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
return
|
||||
}
|
||||
await linkEntry(options, source, destination)
|
||||
if ((await readEntry(destination)).fingerprint !== entry.fingerprint) throw new CommandInstallationError('EOWNERSHIP', 'Command backup changed during restoration.')
|
||||
await unlink(source)
|
||||
}
|
||||
|
||||
async function linkEntry(options: FileCommandInstallation, source: string, destination: string): Promise<void> {
|
||||
// macOS Node link() follows symlinks; linkat preserves their identity and refuses occupied destinations.
|
||||
if (process.platform === 'darwin') await promisify(execFile)(options.linkHelper, [source, destination])
|
||||
else await link(source, destination)
|
||||
}
|
||||
|
||||
async function withdraw(options: FileCommandInstallation, destination: string, expected: Entry): Promise<string | undefined> {
|
||||
if (expected.kind === 'missing') return undefined
|
||||
if (expected.kind === 'unsupported') throw new CommandInstallationError('EUNSUPPORTED', 'The command path is not a file or symbolic link.')
|
||||
const path = join(dirname(destination), '.dsh-command-backup-' + randomUUID())
|
||||
await rename(destination, path)
|
||||
if ((await readEntry(path)).fingerprint !== expected.fingerprint) {
|
||||
try { await restoreEntry(options, path, destination) } catch (error) {
|
||||
throw new AggregateError([error], 'The command changed; its entry is preserved at ' + path)
|
||||
}
|
||||
throw new CommandInstallationError('ESTALE', 'The command changed after confirmation.')
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
/**
|
||||
* Install or repair a link, keeping the previous launcher available for removal.
|
||||
* @param options - Fixed command destination and current installed launcher.
|
||||
* @param expected - Fingerprint displayed in the user's confirmation.
|
||||
* @returns Updated state and any older backup preserved after an external command replacement.
|
||||
*/
|
||||
export async function installFileCommand(options: FileCommandInstallation, expected: string): Promise<CommandInspection> {
|
||||
await mkdir(dirname(options.destination), { recursive: true })
|
||||
return withFileLock(receiptPath(options), async () => {
|
||||
const { state, entry, receipt } = await readSnapshot(options)
|
||||
if (state.fingerprint !== expected) throw new CommandInstallationError('ESTALE', 'The command changed after confirmation.')
|
||||
if (!await available(options.launcher)) throw new CommandInstallationError('ENOENT', 'The installed launcher is unavailable.')
|
||||
const moved = await withdraw(options, options.destination, entry)
|
||||
const pending = join(dirname(options.destination), '.dsh-command-new-' + randomUUID())
|
||||
let created: Entry | undefined
|
||||
try {
|
||||
await symlink(options.launcher, pending)
|
||||
created = await readEntry(pending)
|
||||
await linkEntry(options, pending, options.destination)
|
||||
const backup = state.managed || moved === undefined ? receipt?.backup
|
||||
: { name: basename(moved), fingerprint: entry.fingerprint }
|
||||
const next: Receipt = { schemaVersion: 1, launcher: options.launcher, installedFingerprint: created.fingerprint,
|
||||
...backup === undefined ? {} : { backup } }
|
||||
await writeFileAtomic(receiptPath(options), JSON.stringify(next) + '\n', { mode: 0o644 })
|
||||
} catch (error) {
|
||||
if (created !== undefined && (await readEntry(options.destination)).fingerprint === created.fingerprint) {
|
||||
const rollback = await withdraw(options, options.destination, created)
|
||||
if (rollback !== undefined) await unlink(rollback)
|
||||
}
|
||||
if (moved !== undefined) {
|
||||
try { await restoreEntry(options, moved, options.destination, entry.fingerprint) } catch (restoreError) {
|
||||
throw new AggregateError([error, restoreError], 'The previous command is preserved at ' + moved)
|
||||
}
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
try { await unlink(pending) } catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
}
|
||||
}
|
||||
if (state.managed && moved !== undefined) await unlink(moved)
|
||||
const result = await inspectFileCommand(options)
|
||||
const previousBackup = receipt?.backup === undefined ? undefined : join(dirname(options.destination), receipt.backup.name)
|
||||
return previousBackup !== undefined && !state.managed && moved !== undefined && (await readEntry(previousBackup)).kind !== 'missing'
|
||||
? { ...result, preservedBackup: previousBackup } : result
|
||||
}, { waitMs: 5000 })
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove only a recorded Desktop link and restore its unchanged previous entry.
|
||||
* @param options - Fixed command destination and current installed launcher.
|
||||
* @param expected - Fingerprint displayed when removal was requested.
|
||||
* @returns State after removal; an externally replaced command remains untouched.
|
||||
*/
|
||||
export async function removeFileCommand(options: FileCommandInstallation, expected: string): Promise<CommandInspection> {
|
||||
return withFileLock(receiptPath(options), async () => {
|
||||
const { state, entry, receipt } = await readSnapshot(options)
|
||||
if (state.fingerprint !== expected) throw new CommandInstallationError('ESTALE', 'The command changed after confirmation.')
|
||||
if (receipt === undefined) return state
|
||||
const backup = receipt.backup === undefined ? undefined : join(dirname(options.destination), receipt.backup.name)
|
||||
if (state.managed && backup !== undefined && receipt.backup !== undefined
|
||||
&& (await readEntry(backup)).fingerprint !== receipt.backup.fingerprint) {
|
||||
throw new CommandInstallationError('EOWNERSHIP', 'The previous command backup changed or is missing.')
|
||||
}
|
||||
if (state.managed) {
|
||||
const removed = await withdraw(options, options.destination, entry)
|
||||
try {
|
||||
if (backup !== undefined) await restoreEntry(options, backup, options.destination, receipt.backup?.fingerprint)
|
||||
await unlink(receiptPath(options))
|
||||
} catch (error) {
|
||||
if (removed !== undefined) {
|
||||
try {
|
||||
if ((await readEntry(options.destination)).kind === 'missing') await restoreEntry(options, removed, options.destination, entry.fingerprint)
|
||||
else await unlink(removed)
|
||||
} catch (restoreError) {
|
||||
throw new AggregateError([error, restoreError], 'The Desktop command link is preserved at ' + removed)
|
||||
}
|
||||
}
|
||||
throw error
|
||||
}
|
||||
if (removed !== undefined) await unlink(removed)
|
||||
} else {
|
||||
// Another installer owns the current entry. Keep it and the old backup.
|
||||
await unlink(receiptPath(options))
|
||||
}
|
||||
const result = await inspectFileCommand(options)
|
||||
return !state.managed && backup !== undefined && (await readEntry(backup)).kind !== 'missing'
|
||||
? { ...result, preservedBackup: backup } : result
|
||||
}, { waitMs: 5000 })
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
/** Native command-management dialogs; only fixed installation locations reach the worker. */
|
||||
|
||||
import { execFile } from 'node:child_process'
|
||||
import { isAbsolute, join, normalize, win32 } from 'node:path'
|
||||
import { userInfo } from 'node:os'
|
||||
import { promisify } from 'node:util'
|
||||
import type { MessageBoxOptions, MessageBoxReturnValue } from 'electron'
|
||||
import type { DesktopMessages } from './locale.ts'
|
||||
import type { UpdateDialogOptions } from './update-dialog.ts'
|
||||
|
||||
interface CommandState {
|
||||
fingerprint: string
|
||||
managed: boolean
|
||||
available: boolean
|
||||
destination: string
|
||||
launcher: string
|
||||
target?: string
|
||||
activeCommand?: string
|
||||
backup?: string
|
||||
preservedBackup?: string
|
||||
occupied: boolean
|
||||
selectionUnknown?: boolean
|
||||
}
|
||||
|
||||
/** Data required to render one command-management decision. */
|
||||
export interface CommandPresentation {
|
||||
readonly managed: boolean
|
||||
readonly available: boolean
|
||||
readonly destination: string
|
||||
readonly activeCommand?: string
|
||||
readonly target?: string
|
||||
readonly selectionUnknown?: boolean
|
||||
}
|
||||
|
||||
/** Shell-owned access to installed resources, locale and native dialogs. */
|
||||
export interface CommandManagerOptions {
|
||||
readonly resources: string
|
||||
readonly isPackaged: boolean
|
||||
readonly isInstalledLocation: () => boolean
|
||||
readonly isInstalling: () => boolean
|
||||
readonly isQuitting: () => boolean
|
||||
readonly messages: () => DesktopMessages
|
||||
readonly show: (options: UpdateDialogOptions) => Promise<MessageBoxReturnValue>
|
||||
}
|
||||
|
||||
class CommandWorkerError extends Error {
|
||||
constructor(readonly code: string, message: string) { super(message) }
|
||||
}
|
||||
|
||||
function object(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function parseState(value: unknown, platform: NodeJS.Platform): CommandState {
|
||||
if (!object(value) || typeof value.fingerprint !== 'string' || !/^[a-f0-9]{64}$/u.test(value.fingerprint)
|
||||
|| typeof value.managed !== 'boolean' || typeof value.available !== 'boolean') throw new Error('Invalid command-manager response.')
|
||||
const destination = platform === 'win32' && typeof value.directory === 'string'
|
||||
? join(value.directory, 'dsh.cmd') : value.destination
|
||||
const launcher = platform === 'win32' ? destination : value.launcher
|
||||
if (typeof destination !== 'string' || !isAbsolute(destination) || typeof launcher !== 'string' || !isAbsolute(launcher)) {
|
||||
throw new Error('Invalid command-manager locations.')
|
||||
}
|
||||
for (const name of ['target', 'activeCommand', 'backup', 'preservedBackup']) {
|
||||
if (value[name] !== undefined && value[name] !== null && typeof value[name] !== 'string') throw new Error('Invalid command-manager detail.')
|
||||
}
|
||||
return {
|
||||
fingerprint: value.fingerprint, managed: value.managed, available: value.available,
|
||||
destination, launcher,
|
||||
occupied: platform === 'win32' ? typeof value.activeCommand === 'string' : value.kind !== 'missing',
|
||||
...typeof value.target === 'string' ? { target: value.target } : {},
|
||||
...typeof value.activeCommand === 'string' ? { activeCommand: value.activeCommand } : {},
|
||||
...typeof value.backup === 'string' ? { backup: value.backup } : {},
|
||||
...typeof value.preservedBackup === 'string' ? { preservedBackup: value.preservedBackup } : {},
|
||||
}
|
||||
}
|
||||
|
||||
function format(text: string, path: string): string { return text.replace('{path}', path) }
|
||||
|
||||
function sameCommand(left: string, right: string, platform: NodeJS.Platform): boolean {
|
||||
return platform === 'win32' ? win32.normalize(left).toLowerCase() === win32.normalize(right).toLowerCase() : normalize(left) === normalize(right)
|
||||
}
|
||||
|
||||
/**
|
||||
* Describe installation state using Desktop-owned copy.
|
||||
* @param state - Observed link or PATH selection.
|
||||
* @param messages - Current Desktop locale.
|
||||
* @param platform - Command lookup semantics of the host operating system.
|
||||
* @returns Native dialog with stable Close/Repair/Remove or Install/Cancel indices.
|
||||
*/
|
||||
export function presentCommandManagement(
|
||||
state: CommandPresentation, messages: DesktopMessages, platform: NodeJS.Platform,
|
||||
): MessageBoxOptions {
|
||||
const other = state.activeCommand !== undefined && !sameCommand(state.activeCommand, state.destination, platform)
|
||||
const detail = [
|
||||
format(messages.cliCommandLocation, state.destination),
|
||||
...state.activeCommand === undefined ? [] : [format(messages.cliCommandSelected, state.activeCommand)],
|
||||
...state.target === undefined ? [] : [format(messages.cliCommandTarget, state.target)],
|
||||
...other ? [messages.cliCommandShadowed] : [],
|
||||
...state.selectionUnknown ? [messages.cliCommandSelectionUnknown] : [],
|
||||
].join('\n\n')
|
||||
return {
|
||||
type: other ? 'warning' : 'info', title: messages.cliCommandTitle,
|
||||
message: state.managed ? (state.available ? messages.cliCommandInstalled : messages.cliCommandBroken) : messages.cliCommandNotInstalled,
|
||||
detail,
|
||||
buttons: state.managed
|
||||
? [messages.cliCommandClose, messages.cliCommandRepair, messages.cliCommandRemove]
|
||||
: [messages.cliCommandInstall, messages.cancel],
|
||||
defaultId: 0, cancelId: state.managed ? 0 : 1,
|
||||
}
|
||||
}
|
||||
|
||||
async function shellCommand(): Promise<{ activeCommand?: string; selectionUnknown?: boolean }> {
|
||||
const shell = userInfo().shell
|
||||
if (shell === null) return { selectionUnknown: true }
|
||||
const script = "printf '\\0DSH_COMMAND\\0'; command -v dsh; printf '\\0'"
|
||||
const { stdout } = await promisify(execFile)(shell, ['-ilc', script], { timeout: 5000, maxBuffer: 65536 })
|
||||
const value = stdout.split('\0DSH_COMMAND\0')[1]?.split('\0')[0]?.trim()
|
||||
if (value === undefined) return { selectionUnknown: true }
|
||||
if (value === '') return {}
|
||||
return isAbsolute(value) && !/[\r\n]/u.test(value) ? { activeCommand: value } : { selectionUnknown: true }
|
||||
}
|
||||
|
||||
/** One visible operation; updater preparation waits for its started worker to finish. */
|
||||
export class DesktopCommandManager {
|
||||
private operation: Promise<void> | undefined
|
||||
|
||||
/** @param options - Installed resources and shell-owned UI callbacks. */
|
||||
constructor(private readonly options: CommandManagerOptions) {}
|
||||
|
||||
/** Open or join the command-management operation. */
|
||||
show(): Promise<void> {
|
||||
return this.operation ??= this.run().finally(() => { this.operation = undefined })
|
||||
}
|
||||
|
||||
/** Wait for a command operation already started by the user. */
|
||||
async idle(): Promise<void> { await this.operation }
|
||||
|
||||
private async worker(operation: string, expected?: string, elevated = false): Promise<CommandState> {
|
||||
const node = join(this.options.resources, 'runtime', 'primary-runtime', 'dependencies', 'node', 'bin', process.platform === 'win32' ? 'node.exe' : 'node')
|
||||
const entry = join(this.options.resources, 'runtime', 'cli', 'command-manager.js')
|
||||
let stdout: string
|
||||
try {
|
||||
if (elevated) {
|
||||
const appleScript = 'on run argv\nset cmd to "/usr/bin/env -i " & quoted form of (item 1 of argv) & " " & quoted form of (item 2 of argv) & " " & quoted form of (item 3 of argv) & " " & quoted form of (item 4 of argv)\ndo shell script cmd with administrator privileges\nend run'
|
||||
stdout = (await promisify(execFile)('/usr/bin/osascript', ['-e', appleScript, node, entry, operation, expected ?? ''],
|
||||
{ maxBuffer: 65536 })).stdout
|
||||
} else {
|
||||
const env = Object.fromEntries(Object.entries(process.env)
|
||||
.filter(([name]) => !/KEY|SECRET|TOKEN|PASSWORD|^NODE_OPTIONS$|^NODE_PATH$/iu.test(name)))
|
||||
stdout = (await promisify(execFile)(node, [entry, operation, ...expected === undefined ? [] : [expected]],
|
||||
{ timeout: 30000, maxBuffer: 65536, windowsHide: true, env })).stdout
|
||||
}
|
||||
} catch (error) {
|
||||
if (elevated && object(error) && typeof error.stderr === 'string' && /\(-128\)\s*$/u.test(error.stderr)) {
|
||||
throw new CommandWorkerError('ECANCELED', 'Command authorization was cancelled.')
|
||||
}
|
||||
if (object(error) && typeof error.stdout === 'string' && error.stdout.trim().startsWith('{')) stdout = error.stdout
|
||||
else throw new CommandWorkerError('EIO', 'Command-manager process failed.')
|
||||
}
|
||||
const response: unknown = JSON.parse(stdout.trim())
|
||||
if (!object(response) || typeof response.ok !== 'boolean') throw new Error('Invalid command-manager response.')
|
||||
if (!response.ok) {
|
||||
const code = typeof response.code === 'string' ? response.code : 'EIO'
|
||||
if (!elevated && process.platform === 'darwin' && operation !== 'inspect' && ['EACCES', 'EPERM'].includes(code)) {
|
||||
return this.worker(operation, expected, true)
|
||||
}
|
||||
throw new CommandWorkerError(code, typeof response.message === 'string' ? response.message : 'Command management failed.')
|
||||
}
|
||||
return parseState(response.state, process.platform)
|
||||
}
|
||||
|
||||
private async inspect(): Promise<CommandState> {
|
||||
const state = await this.worker('inspect')
|
||||
if (process.platform !== 'darwin') return state
|
||||
try {
|
||||
return { ...state, ...await shellCommand() }
|
||||
} catch {
|
||||
// A custom login shell can fail or wait for interaction; its output is not a diagnostic.
|
||||
return { ...state, selectionUnknown: true }
|
||||
}
|
||||
}
|
||||
|
||||
private async run(): Promise<void> {
|
||||
const messages = this.options.messages()
|
||||
if (!this.options.isPackaged || !this.options.isInstalledLocation()) {
|
||||
await this.options.show({ type: 'info', title: messages.cliCommandTitle, message: messages.cliCommandInstallApp, buttons: [messages.cliCommandClose] })
|
||||
return
|
||||
}
|
||||
if (this.options.isInstalling()) {
|
||||
await this.options.show({ type: 'info', title: messages.cliCommandTitle, message: messages.cliCommandUpdating, buttons: [messages.cliCommandClose] })
|
||||
return
|
||||
}
|
||||
try {
|
||||
const state = await this.inspect()
|
||||
const choice = await this.options.show(presentCommandManagement(state, messages, process.platform))
|
||||
if (this.options.isQuitting()) return
|
||||
const operation = state.managed ? (choice.response === 1 ? 'install' : choice.response === 2 ? 'remove' : undefined)
|
||||
: choice.response === 0 ? 'install' : undefined
|
||||
if (operation === undefined) return
|
||||
const ownsSelection = state.managed
|
||||
&& (state.activeCommand === undefined || sameCommand(state.activeCommand, state.destination, process.platform))
|
||||
if (operation === 'install' && (state.selectionUnknown || (!ownsSelection && (state.occupied || state.activeCommand !== undefined)))) {
|
||||
const confirmation = await this.options.show({
|
||||
type: 'warning', title: messages.cliCommandTitle, message: messages.cliCommandSwitch,
|
||||
detail: (state.selectionUnknown ? messages.cliCommandSelectionUnknown
|
||||
: format(messages.cliCommandSelected, state.activeCommand ?? state.destination))
|
||||
+ '\n\n' + messages.cliCommandPreserve,
|
||||
buttons: [messages.cliCommandContinue, messages.cancel], defaultId: 1, cancelId: 1,
|
||||
})
|
||||
if (confirmation.response !== 0 || this.options.isQuitting()) return
|
||||
}
|
||||
const result = await this.worker(operation, state.fingerprint)
|
||||
if (this.options.isQuitting()) return
|
||||
const current = await this.inspect()
|
||||
const shadowed = current.activeCommand !== undefined && !sameCommand(current.activeCommand, current.destination, process.platform)
|
||||
await this.options.show({
|
||||
type: shadowed && operation === 'install' ? 'warning' : 'info', title: messages.cliCommandTitle,
|
||||
message: operation === 'remove' ? messages.cliCommandRemoved : messages.cliCommandInstalled,
|
||||
detail: [operation === 'install' ? messages.cliCommandNewTerminal : state.backup !== undefined && state.managed ? messages.cliCommandPreviousRestored : messages.cliCommandOtherKept,
|
||||
...shadowed && operation === 'install' && current.activeCommand !== undefined
|
||||
? [format(messages.cliCommandSelected, current.activeCommand), messages.cliCommandShadowed] : [],
|
||||
...current.selectionUnknown && operation === 'install' ? [messages.cliCommandSelectionUnknown] : [],
|
||||
...result.preservedBackup === undefined ? [] : [format(messages.cliCommandBackupKept, result.preservedBackup)],
|
||||
].join('\n\n'),
|
||||
buttons: [messages.cliCommandClose],
|
||||
})
|
||||
} catch (error) {
|
||||
if (this.options.isQuitting()) return
|
||||
const code = error instanceof CommandWorkerError ? error.code : 'EIO'
|
||||
if (code === 'ECANCELED') return
|
||||
await this.options.show({
|
||||
type: 'error', title: messages.cliCommandTitle,
|
||||
message: code === 'ESTALE' ? messages.cliCommandChanged : code === 'EOWNERSHIP' ? messages.cliCommandOwnershipError : messages.cliCommandFailed,
|
||||
technicalDetails: error instanceof Error ? error.message : '',
|
||||
buttons: [messages.cliCommandClose],
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/** Private command-management worker; macOS mutation targets are fixed before elevation. */
|
||||
|
||||
import { spawn } from 'node:child_process'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { CommandInstallationError, inspectFileCommand, installFileCommand, removeFileCommand } from './command-installation.ts'
|
||||
|
||||
const [operation, expected] = process.argv.slice(2)
|
||||
const fingerprint = expected ?? ''
|
||||
const resources = resolve(dirname(fileURLToPath(import.meta.url)), '../..')
|
||||
// This dedicated worker writes public command ownership metadata, not user secrets.
|
||||
if (process.platform === 'darwin') process.umask(0o022)
|
||||
|
||||
try {
|
||||
if (!['inspect', 'install', 'remove'].includes(operation ?? '')) throw new CommandInstallationError('EINVAL', 'Invalid command-management operation.')
|
||||
if (operation !== 'inspect' && !/^[a-f0-9]{64}$/u.test(fingerprint)) throw new CommandInstallationError('EINVAL', 'Missing command confirmation.')
|
||||
if (process.platform === 'darwin') {
|
||||
const options = { destination: '/usr/local/bin/dsh', launcher: join(resources, 'runtime', 'cli', 'bin', 'dsh'),
|
||||
linkHelper: join(resources, 'runtime', 'cli', 'link-entry') }
|
||||
const state = operation === 'inspect' ? await inspectFileCommand(options)
|
||||
: operation === 'install' ? await installFileCommand(options, fingerprint) : await removeFileCommand(options, fingerprint)
|
||||
process.stdout.write(JSON.stringify({ ok: true, state }) + '\n')
|
||||
} else if (process.platform === 'win32') {
|
||||
const systemRoot = process.env.SystemRoot ?? process.env.WINDIR
|
||||
if (systemRoot === undefined) throw new CommandInstallationError('ENOENT', 'Windows system directory is unavailable.')
|
||||
const child = spawn(join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'),
|
||||
['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', join(resources, 'runtime', 'cli', 'command-path.ps1')],
|
||||
{ stdio: ['pipe', 'pipe', 'inherit'], windowsHide: true })
|
||||
child.stdout.pipe(process.stdout)
|
||||
const exited = new Promise<number | null>((accept, reject) => {
|
||||
child.once('error', reject)
|
||||
child.once('close', accept)
|
||||
})
|
||||
child.stdin.end(JSON.stringify({ operation, expected, directory: join(resources, 'runtime', 'cli', 'bin') }))
|
||||
process.exitCode = await exited ?? 1
|
||||
} else {
|
||||
throw new CommandInstallationError('EUNSUPPORTED', 'Command installation is supported on macOS and Windows.')
|
||||
}
|
||||
} catch (error) {
|
||||
// osascript preserves stdout only when the command exits successfully; the response owns operation failures.
|
||||
process.stdout.write(JSON.stringify({
|
||||
ok: false,
|
||||
code: error instanceof CommandInstallationError ? error.code : (error as NodeJS.ErrnoException).code ?? 'EIO',
|
||||
message: error instanceof Error ? error.message : 'Command management failed.',
|
||||
}) + '\n')
|
||||
}
|
||||
@@ -16,6 +16,7 @@ export const DESKTOP_HOST_PACKAGE = '@deepseek-ai/dsh-desktop-host'
|
||||
/** Package-relative Desktop Host files required before a profile can boot. */
|
||||
export const DESKTOP_HOST_RUNTIME_FILES = [
|
||||
'lib/index.js',
|
||||
'lib/cli.js',
|
||||
] as const
|
||||
|
||||
/** One immutable npm tarball in the Desktop core package set. */
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
/** Millisecond settings read from the Desktop process environment. */
|
||||
|
||||
/**
|
||||
* Read a duration that `setTimeout` accepts without clamping.
|
||||
* @param env - Desktop process environment.
|
||||
* @param name - Variable to read; the validation error names it.
|
||||
* @param fallback - Value used when the variable is unset.
|
||||
* @returns Integer milliseconds from 1000 through 2147483647.
|
||||
*/
|
||||
export function resolveDurationMs(env: NodeJS.ProcessEnv, name: string, fallback: number): number {
|
||||
const value = Number(env[name] ?? fallback)
|
||||
if (!Number.isSafeInteger(value) || value < 1_000 || value > 2_147_483_647) {
|
||||
throw new Error(`${name} must be an integer from 1000 through 2147483647`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -1,6 +1,33 @@
|
||||
/** Typed English and Chinese copy owned by the Electron shell. */
|
||||
|
||||
export const en = {
|
||||
cliCommandMenu: 'Manage dsh Command…',
|
||||
cliCommandTitle: 'Manage dsh Command',
|
||||
cliCommandLocation: 'Desktop command: {path}',
|
||||
cliCommandSelected: 'Current dsh command: {path}',
|
||||
cliCommandTarget: 'Current launcher target: {path}',
|
||||
cliCommandShadowed: 'Another dsh takes precedence. Remove or reorder that installation to use the Desktop command by default. You can also run the Desktop command by its full path.',
|
||||
cliCommandSelectionUnknown: 'Your shell command could not be verified. An alias or another dsh installation may take precedence.',
|
||||
cliCommandInstalled: 'The Desktop command is installed.',
|
||||
cliCommandNotInstalled: 'Add the Desktop command to your terminal.',
|
||||
cliCommandBroken: 'The Desktop command needs repair.',
|
||||
cliCommandInstall: 'Install',
|
||||
cliCommandRepair: 'Repair',
|
||||
cliCommandRemove: 'Remove',
|
||||
cliCommandClose: 'Close',
|
||||
cliCommandSwitch: 'Continue with the Desktop command?',
|
||||
cliCommandPreserve: 'An existing command will be preserved. Other installations and shell startup files will not be changed.',
|
||||
cliCommandContinue: 'Continue',
|
||||
cliCommandInstallApp: 'Install Desktop in your Applications folder before managing the dsh command.',
|
||||
cliCommandUpdating: 'An update is being installed. Manage the command after installation finishes.',
|
||||
cliCommandNewTerminal: 'Open a new terminal and run dsh --version.',
|
||||
cliCommandRemoved: 'Desktop command registration removed.',
|
||||
cliCommandPreviousRestored: 'The previous launcher has been restored.',
|
||||
cliCommandOtherKept: 'Other command installations have been left in place.',
|
||||
cliCommandBackupKept: 'A previous launcher is preserved at: {path}',
|
||||
cliCommandChanged: 'The command or PATH changed while this dialog was open. Open Manage dsh Command again to review the current state.',
|
||||
cliCommandOwnershipError: 'The command registration or backup changed. No unrelated command was removed.',
|
||||
cliCommandFailed: 'The command could not be updated. Check that the application is installed and the destination is writable, then retry.',
|
||||
application: 'Application',
|
||||
fileMenu: 'File',
|
||||
closePage: 'Close Page or Window',
|
||||
@@ -145,6 +172,33 @@ export const en = {
|
||||
export type DesktopMessages = { readonly [Key in keyof typeof en]: string }
|
||||
|
||||
export const zh = {
|
||||
cliCommandMenu: '管理 dsh 命令…',
|
||||
cliCommandTitle: '管理 dsh 命令',
|
||||
cliCommandLocation: 'Desktop 命令:{path}',
|
||||
cliCommandSelected: '当前 dsh 命令:{path}',
|
||||
cliCommandTarget: '当前启动器目标:{path}',
|
||||
cliCommandShadowed: '另一个 dsh 的优先级更高。请移除或调整该安装的顺序,以默认使用 Desktop 命令;也可以通过完整路径运行 Desktop 命令。',
|
||||
cliCommandSelectionUnknown: '无法确认 shell 中的命令。别名或另一个 dsh 安装可能具有更高优先级。',
|
||||
cliCommandInstalled: 'Desktop 命令已安装。',
|
||||
cliCommandNotInstalled: '将 Desktop 命令添加到终端。',
|
||||
cliCommandBroken: 'Desktop 命令需要修复。',
|
||||
cliCommandInstall: '安装',
|
||||
cliCommandRepair: '修复',
|
||||
cliCommandRemove: '移除',
|
||||
cliCommandClose: '关闭',
|
||||
cliCommandSwitch: '继续使用 Desktop 命令?',
|
||||
cliCommandPreserve: '现有命令会被保留,不会修改其他安装或 shell 启动文件。',
|
||||
cliCommandContinue: '继续',
|
||||
cliCommandInstallApp: '请先将 Desktop 安装到“应用程序”文件夹,再管理 dsh 命令。',
|
||||
cliCommandUpdating: '正在安装更新。请在安装完成后管理命令。',
|
||||
cliCommandNewTerminal: '打开新终端并运行 dsh --version。',
|
||||
cliCommandRemoved: '已移除 Desktop 命令注册。',
|
||||
cliCommandPreviousRestored: '已恢复之前的启动器。',
|
||||
cliCommandOtherKept: '其他命令安装保持不变。',
|
||||
cliCommandBackupKept: '之前的启动器保留在:{path}',
|
||||
cliCommandChanged: '对话框打开期间命令或 PATH 已改变。请重新打开“管理 dsh 命令”检查当前状态。',
|
||||
cliCommandOwnershipError: '命令注册或备份已改变,未移除无关命令。',
|
||||
cliCommandFailed: '无法更新命令。请检查应用是否已安装、目标位置是否可写,然后重试。',
|
||||
application: '应用',
|
||||
fileMenu: '文件',
|
||||
closePage: '关闭页面或窗口',
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
/** Login-shell environment for POSIX Desktop launches that inherit only the session manager's environment. */
|
||||
|
||||
import { spawn, type ChildProcessByStdio } from 'node:child_process'
|
||||
import { homedir, userInfo } from 'node:os'
|
||||
import type { Readable } from 'node:stream'
|
||||
import { resolveDurationMs } from './duration-env.ts'
|
||||
|
||||
const DELIMITER = '_DSH_SHELL_ENV_DELIMITER_'
|
||||
const MARKER = Buffer.from(`\0${DELIMITER}\0`)
|
||||
const DUMP = `printf '\\0%s\\0' '${DELIMITER}'; command env -0 || exit; printf '\\0%s\\0' '${DELIMITER}'; exit`
|
||||
const FALLBACK_SHELLS = ['/bin/zsh', '/bin/bash', '/bin/sh'] as const
|
||||
/** Keep oh-my-zsh update prompts and tmux autostart plugins from blocking a non-terminal read. */
|
||||
const PROBE_ENVIRONMENT = { DISABLE_AUTO_UPDATE: 'true', ZSH_TMUX_AUTOSTARTED: 'true', ZSH_TMUX_AUTOSTART: 'false' } as const
|
||||
/** Variables that describe the probe shell process rather than the user's configuration. */
|
||||
const SHELL_SESSION_KEYS = new Set(['PWD', 'OLDPWD', 'SHLVL', '_', ...Object.keys(PROBE_ENVIRONMENT)])
|
||||
/** Desktop resolves paths such as `DSH_HOME` before the read, so the Host keeps the same inherited values. */
|
||||
const LAUNCHER_OWNED_PREFIXES = ['DSH_', 'ELECTRON_'] as const
|
||||
|
||||
/** Validated login-shell read settings. */
|
||||
export interface DesktopLoginShellConfig {
|
||||
/** Deadline for each candidate shell, in milliseconds. */
|
||||
readonly timeoutMs: number
|
||||
}
|
||||
|
||||
/** One candidate shell that did not produce an environment. */
|
||||
export interface DesktopLoginShellFailure {
|
||||
readonly shell: string
|
||||
/**
|
||||
* `exit <code>`, terminating signal name, spawn error, `timeout`, `aborted`, or `unparsed`
|
||||
* for output without both delimiters.
|
||||
*/
|
||||
readonly reason: string
|
||||
}
|
||||
|
||||
/** Environment for the Host and the candidate failures that preceded it. */
|
||||
export interface DesktopLoginShellResult {
|
||||
/** `base` merged with the first successful shell's variables, or `base` itself when none succeeded. */
|
||||
readonly environment: NodeJS.ProcessEnv
|
||||
readonly failures: readonly DesktopLoginShellFailure[]
|
||||
}
|
||||
|
||||
/** Inputs of {@link readDesktopLoginShellEnvironment} that callers other than tests leave unset. */
|
||||
export interface DesktopLoginShellReadOptions {
|
||||
/** Operating system running Desktop; defaults to `process.platform`. */
|
||||
readonly platform?: NodeJS.Platform
|
||||
/** Candidates in trial order; defaults to {@link loginShellCandidates}. */
|
||||
readonly shells?: readonly string[]
|
||||
/** Aborting kills the running candidate's process group and skips the remaining candidates. */
|
||||
readonly signal?: AbortSignal
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve login-shell read settings.
|
||||
* @param env - Desktop process environment.
|
||||
* @returns Validated per-candidate deadline; `DSH_DESKTOP_LOGIN_SHELL_TIMEOUT_MS` defaults to 10000.
|
||||
*/
|
||||
export function resolveDesktopLoginShellConfig(env: NodeJS.ProcessEnv): DesktopLoginShellConfig {
|
||||
return { timeoutMs: resolveDurationMs(env, 'DSH_DESKTOP_LOGIN_SHELL_TIMEOUT_MS', 10_000) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Candidate shells in order: the account record's login shell (not `$SHELL`), then fixed system shells.
|
||||
* @returns Distinct absolute candidates; only the fixed shells when the account record is unreadable or empty.
|
||||
*/
|
||||
export function loginShellCandidates(): readonly string[] {
|
||||
let account: string | null = null
|
||||
try { account = userInfo().shell } catch (_error) { /* No account record for this uid: fixed candidates only. */ }
|
||||
return [...new Set([...account === null || account === '' ? [] : [account], ...FALLBACK_SHELLS])]
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the variables printed between the two delimiters of the dump command.
|
||||
* @param stdout - Probe output, including anything rc files printed around the dump.
|
||||
* @returns Variables, or undefined when both delimiters are not present.
|
||||
*/
|
||||
export function parseLoginShellOutput(stdout: string): Record<string, string> | undefined {
|
||||
const parts = stdout.split('\0')
|
||||
const first = parts.indexOf(DELIMITER)
|
||||
const last = parts.lastIndexOf(DELIMITER)
|
||||
if (first === -1 || first === last) return undefined
|
||||
const variables: Record<string, string> = {}
|
||||
for (const entry of parts.slice(first + 1, last)) {
|
||||
const separator = entry.indexOf('=')
|
||||
if (separator > 0) variables[entry.slice(0, separator)] = entry.slice(separator + 1)
|
||||
}
|
||||
return variables
|
||||
}
|
||||
|
||||
/**
|
||||
* Overlay login-shell variables on the inherited environment; shell values win except for
|
||||
* probe-session variables and launcher-owned `DSH_*` / `ELECTRON_*` names.
|
||||
* @param base - Environment Desktop inherited.
|
||||
* @param shell - Variables printed by the login shell.
|
||||
* @returns A new environment; neither argument is modified.
|
||||
*/
|
||||
export function mergeLoginShellEnvironment(base: NodeJS.ProcessEnv, shell: Readonly<Record<string, string>>): NodeJS.ProcessEnv {
|
||||
const merged = { ...base }
|
||||
for (const [key, value] of Object.entries(shell)) {
|
||||
if (SHELL_SESSION_KEYS.has(key) || LAUNCHER_OWNED_PREFIXES.some(prefix => key.startsWith(prefix))) continue
|
||||
merged[key] = value
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
function readShell(
|
||||
shell: string, base: NodeJS.ProcessEnv, cwd: string, timeoutMs: number, signal: AbortSignal | undefined,
|
||||
): Promise<Record<string, string> | string> {
|
||||
return new Promise((resolve) => {
|
||||
if (signal?.aborted === true) { resolve('aborted'); return }
|
||||
let child: ChildProcessByStdio<null, Readable, null>
|
||||
try {
|
||||
// Interactive login mode runs ~/.zprofile and ~/.zshrc (or the shell's equivalents); stdin is
|
||||
// closed so prompts in rc files read EOF instead of waiting.
|
||||
child = spawn(shell, ['-ilc', DUMP], {
|
||||
cwd, env: { ...base, ...PROBE_ENVIRONMENT }, stdio: ['ignore', 'pipe', 'ignore'], detached: true,
|
||||
})
|
||||
} catch (error) {
|
||||
resolve(String(error))
|
||||
return
|
||||
}
|
||||
const chunks: Buffer[] = []
|
||||
let tail = Buffer.alloc(0)
|
||||
let markers = 0
|
||||
const output = (): Record<string, string> | string => parseLoginShellOutput(Buffer.concat(chunks).toString('utf8')) ?? 'unparsed'
|
||||
const onData = (chunk: Buffer): void => {
|
||||
chunks.push(chunk)
|
||||
const window = Buffer.concat([tail, chunk])
|
||||
for (let index = window.indexOf(MARKER); index !== -1; index = window.indexOf(MARKER, index + MARKER.length)) markers++
|
||||
tail = window.subarray(Math.max(0, window.length - MARKER.length + 1))
|
||||
// Background children started by rc files inherit stdout and can hold it open long after the
|
||||
// shell exits, so the read completes at the closing delimiter rather than at `close`.
|
||||
if (markers >= 2) finish(output())
|
||||
}
|
||||
const killGroup = (): void => {
|
||||
// An asynchronous spawn failure settles through the error listener before a timer or abort
|
||||
// listener can run, so the pid is present here.
|
||||
const group = -(child.pid as number)
|
||||
try { process.kill(group, 'SIGKILL') } catch (_error) { /* The group already exited. */ }
|
||||
}
|
||||
// Members that left the process group can keep stdout open after SIGKILL, so a stopped read
|
||||
// resolves without waiting for `close`.
|
||||
const stop = (reason: 'timeout' | 'aborted'): void => { finish(reason); killGroup() }
|
||||
const onAbort = (): void => { stop('aborted') }
|
||||
const timer = setTimeout(() => { stop('timeout') }, timeoutMs)
|
||||
function finish(result: Record<string, string> | string): void {
|
||||
clearTimeout(timer)
|
||||
signal?.removeEventListener('abort', onAbort)
|
||||
// Keep draining so rc-started children that write to stdout do not fail with EPIPE.
|
||||
child.stdout.off('data', onData)
|
||||
child.stdout.resume()
|
||||
resolve(result)
|
||||
}
|
||||
signal?.addEventListener('abort', onAbort, { once: true })
|
||||
child.stdout.on('data', onData)
|
||||
child.on('error', (error) => { finish(error.message) })
|
||||
child.on('close', (code, closeSignal) => {
|
||||
if (code !== 0) { finish(closeSignal ?? `exit ${String(code)}`); return }
|
||||
finish(output())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the user's login-shell environment once; Windows GUI launches already inherit the registry
|
||||
* environment, so `win32` returns `base` unchanged. Never rejects: each failing candidate is
|
||||
* recorded and the next one tried, and `base` is returned when all fail or the read is aborted.
|
||||
* @param base - Environment Desktop inherited.
|
||||
* @param config - Validated read settings.
|
||||
* @param options - Platform, candidates, and cancellation.
|
||||
* @returns Host environment and the candidate failures that preceded it.
|
||||
*/
|
||||
export async function readDesktopLoginShellEnvironment(
|
||||
base: NodeJS.ProcessEnv, config: DesktopLoginShellConfig, options: DesktopLoginShellReadOptions = {},
|
||||
): Promise<DesktopLoginShellResult> {
|
||||
const { platform = process.platform, shells = loginShellCandidates(), signal } = options
|
||||
if (platform === 'win32') return { environment: base, failures: [] }
|
||||
const failures: DesktopLoginShellFailure[] = []
|
||||
for (const shell of shells) {
|
||||
const result = await readShell(shell, base, homedir(), config.timeoutMs, signal)
|
||||
if (typeof result !== 'string') return { environment: mergeLoginShellEnvironment(base, result), failures }
|
||||
failures.push({ shell, reason: result })
|
||||
if (result === 'aborted') break
|
||||
}
|
||||
return { environment: base, failures }
|
||||
}
|
||||
@@ -34,6 +34,7 @@ import { readDeviceInfo } from './device-info.ts'
|
||||
import { desktopUpdateReadyConfirmation, formatDesktopMessage, resolveDesktopLocale, resolveDesktopStartupLocale } from './locale.ts'
|
||||
import { claimDesktopSingleInstance } from './single-instance.ts'
|
||||
import { DesktopUpdateCoordinator } from './update-coordinator.ts'
|
||||
import { DesktopCommandManager } from './command-management.ts'
|
||||
import { serveWebDocument, authenticateWebHost, forwardWebRequest } from './web-document.ts'
|
||||
import { DesktopFatalRecovery } from './fatal-recovery.ts'
|
||||
import { pruneCrashReports, RendererConsoleTail, writeCrashReport, type CrashReportSource } from './crash-report.ts'
|
||||
@@ -45,6 +46,7 @@ import { DesktopUpdatePreparationError } from './update-error.ts'
|
||||
import { DesktopUpdateSchedule, resolveDesktopUpdateScheduleConfig } from './update-schedule.ts'
|
||||
import { desktopUpdateErrorSummary, presentDesktopUpdate } from './update-presentation.ts'
|
||||
import { desktopErrorState } from './startup-error.ts'
|
||||
import { readDesktopLoginShellEnvironment, resolveDesktopLoginShellConfig } from './login-shell-environment.ts'
|
||||
import { DesktopMandatoryUpdatePolicy, resolveDesktopPolicyConfig, type DesktopPolicyState } from './mandatory-update-policy.ts'
|
||||
import { desktopClientMetadata, desktopClientVersion } from './client-metadata.ts'
|
||||
import { DesktopMandatoryUpdateWindow } from './mandatory-update-window.ts'
|
||||
@@ -322,6 +324,18 @@ async function main(): Promise<void> {
|
||||
: join(process.resourcesPath, 'runtime', 'primary-runtime')
|
||||
const activeProject = paths.profile
|
||||
const manager = new DesktopProjectManager(paths, resources)
|
||||
// Dock and Finder launches inherit only launchd's environment; every Host shares one login-shell read.
|
||||
const loginShellRead = new AbortController()
|
||||
// The probe runs in its own process group, which outlives Desktop unless the read is aborted.
|
||||
app.on('will-quit', () => { loginShellRead.abort() })
|
||||
const loginShell = readDesktopLoginShellEnvironment(process.env, resolveDesktopLoginShellConfig(process.env), {
|
||||
signal: loginShellRead.signal,
|
||||
}).then((result) => {
|
||||
for (const failure of result.failures) console.warn(`desktop login shell: ${failure.shell} failed (${failure.reason})`)
|
||||
return result.environment
|
||||
})
|
||||
let hostEnvironment: NodeJS.ProcessEnv = process.env
|
||||
const prepareHostEnvironment = async (): Promise<void> => { hostEnvironment = await loginShell }
|
||||
let quitting = false
|
||||
let startup: Promise<void> | undefined
|
||||
let workspaceRecovery: Promise<void> | undefined
|
||||
@@ -372,6 +386,15 @@ async function main(): Promise<void> {
|
||||
detail: formatDesktopMessage(locale.messages.aboutVersion, { version: app.getVersion() }),
|
||||
buttons: [locale.messages.updateAcknowledge], cancelId: 0 })
|
||||
}
|
||||
const commandManager = new DesktopCommandManager({
|
||||
resources: process.resourcesPath,
|
||||
isPackaged: app.isPackaged,
|
||||
isInstalledLocation: () => process.platform !== 'darwin' || app.isInApplicationsFolder(),
|
||||
isInstalling: () => updateState.phase === 'installing',
|
||||
isQuitting,
|
||||
messages: () => currentDesktopLocale().messages,
|
||||
show: ordinaryMessageBox,
|
||||
})
|
||||
const appPreload = fileURLToPath(new URL('./preload-app.cjs', import.meta.url))
|
||||
const applicationUrl = `${SCHEME}://app/`
|
||||
let hostUrl: string | undefined
|
||||
@@ -419,7 +442,7 @@ async function main(): Promise<void> {
|
||||
const backend = new DesktopBackendController((onFailure) => {
|
||||
const hostInspectPort = developmentHostInspectPort(development)
|
||||
const host = new DesktopHostProcess(resources.node, resources.dsh, activeProject,
|
||||
hostInspectPort, { ...process.env, DSH_CLIENT_VERSION: desktopClientVersion() }, onFailure,
|
||||
hostInspectPort, { ...hostEnvironment, DSH_CLIENT_VERSION: desktopClientVersion() }, onFailure,
|
||||
primaryRuntime,
|
||||
resources, (next) => { platformView.setSession(next) })
|
||||
return {
|
||||
@@ -515,7 +538,7 @@ async function main(): Promise<void> {
|
||||
updateStoppedHost = false
|
||||
if (restoreHost) {
|
||||
// Only confirmed process exit permits replacement before another installation confirmation.
|
||||
const hostReady = backend.start(async () => {})
|
||||
const hostReady = backend.start(prepareHostEnvironment)
|
||||
startup = hostReady
|
||||
const recovery = hostReady.then(async () => {
|
||||
if (quitting) return
|
||||
@@ -545,7 +568,7 @@ async function main(): Promise<void> {
|
||||
startup ??= (async () => {
|
||||
await navigateMain(applicationUrl)
|
||||
await backend.start(async () => {
|
||||
await manager.applyRelease()
|
||||
await Promise.all([manager.applyRelease(), prepareHostEnvironment()])
|
||||
})
|
||||
if (backend.host !== undefined) await openInitialWindow()
|
||||
if (backend.host !== undefined) updateJournal?.action('workspace-ready')
|
||||
@@ -561,6 +584,7 @@ async function main(): Promise<void> {
|
||||
const updates = new DesktopUpdateCoordinator(
|
||||
publishUpdate,
|
||||
async () => {
|
||||
await commandManager.idle()
|
||||
await workspaceRecovery
|
||||
await startup?.catch(() => undefined)
|
||||
const host = backend.host
|
||||
@@ -610,6 +634,7 @@ async function main(): Promise<void> {
|
||||
},
|
||||
undefined, undefined, undefined,
|
||||
(success, reason) => { void track('desktop_upgrade_download_result', { is_success: success, ...reason === undefined ? {} : { error_reason: reason } }) },
|
||||
|
||||
)
|
||||
|
||||
const updateSchedule = new DesktopUpdateSchedule(updates, resolveDesktopUpdateScheduleConfig(process.env))
|
||||
@@ -928,6 +953,8 @@ async function main(): Promise<void> {
|
||||
: { label: currentDesktopLocale().messages.aboutMenu, role: 'about' },
|
||||
{ type: 'separator' },
|
||||
{ label: currentDesktopLocale().messages.checkUpdatesMenu, click: () => { void openUpdatePrompt(true) } },
|
||||
...process.platform === 'darwin' || process.platform === 'win32'
|
||||
? [{ label: currentDesktopLocale().messages.cliCommandMenu, click: () => { void commandManager.show() } }] : [],
|
||||
...development ? [
|
||||
{ type: 'separator' as const },
|
||||
{ label: currentDesktopLocale().messages.reloadPageMenu, role: 'reload' as const },
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/** Ordinary feed polling; policy queries and user-authorized transfers keep their own lifetimes. */
|
||||
|
||||
import { resolveDurationMs } from './duration-env.ts'
|
||||
import type { DesktopUpdateCoordinator } from './update-coordinator.ts'
|
||||
import type { DesktopUpdateState } from './ipc.ts'
|
||||
|
||||
@@ -16,15 +17,8 @@ export interface DesktopUpdateScheduleConfig {
|
||||
* @returns Validated durations and fractional jitter.
|
||||
*/
|
||||
export function resolveDesktopUpdateScheduleConfig(env: NodeJS.ProcessEnv): DesktopUpdateScheduleConfig {
|
||||
function duration(name: string, fallback: number): number {
|
||||
const value = Number(env[name] ?? fallback)
|
||||
if (!Number.isSafeInteger(value) || value < 1_000 || value > 2_147_483_647) {
|
||||
throw new Error(`${name} must be an integer from 1000 through 2147483647`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
const intervalMs = duration('DSH_DESKTOP_UPDATE_CHECK_INTERVAL_MS', 600_000)
|
||||
const maxBackoffMs = duration('DSH_DESKTOP_UPDATE_CHECK_MAX_BACKOFF_MS', Math.max(intervalMs, 3_600_000))
|
||||
const intervalMs = resolveDurationMs(env, 'DSH_DESKTOP_UPDATE_CHECK_INTERVAL_MS', 600_000)
|
||||
const maxBackoffMs = resolveDurationMs(env, 'DSH_DESKTOP_UPDATE_CHECK_MAX_BACKOFF_MS', Math.max(intervalMs, 3_600_000))
|
||||
const jitter = Number(env.DSH_DESKTOP_UPDATE_CHECK_JITTER ?? 0.2)
|
||||
if (!Number.isFinite(jitter) || jitter < 0 || jitter > 1 || maxBackoffMs < intervalMs) {
|
||||
throw new Error('desktop update: check jitter must be in [0, 1] and max backoff must cover the check interval')
|
||||
|
||||
@@ -55,7 +55,7 @@ try {
|
||||
const directory = skill.resourceBase.path;
|
||||
assert.ok(!directory.includes('.asar'));
|
||||
const output = execFileSync('pwsh', ['-NoProfile', '-NonInteractive', '-File',
|
||||
join(directory, 'scripts/diagnose-windows-sandbox-acl.ps1'), '-Path', root],
|
||||
join(directory, 'scripts/diagnose-windows-sandbox-acl.ps1'), '-Path', root, '-AllowRoot', root, '-Out', join(root, 'out')],
|
||||
{ encoding: 'utf8', timeout: 30000, windowsHide: true });
|
||||
assert.ok(output.includes('READABLE=True'));
|
||||
await fiber.dispose();
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
/** Installed launcher scripts preserve terminal invocation through a minimal runtime fixture. */
|
||||
|
||||
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
|
||||
import { copyFileSync, mkdirSync, mkdtempSync, realpathSync, symlinkSync, writeFileSync } from 'node:fs'
|
||||
import { rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join, relative } from 'node:path'
|
||||
import { expect, it, onTestFinished } from 'vitest'
|
||||
import { prepareDesktopCli } from '../scripts/prepare-cli.ts'
|
||||
|
||||
function fixture() {
|
||||
const root = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-cli-launcher-')))
|
||||
const children: ChildProcessWithoutNullStreams[] = []
|
||||
const exits: Promise<unknown>[] = []
|
||||
onTestFinished(async () => {
|
||||
for (const child of children) {
|
||||
if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL')
|
||||
}
|
||||
await Promise.allSettled(exits)
|
||||
await rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 })
|
||||
})
|
||||
const application = join(root, 'Application 中文 with spaces.app')
|
||||
const platform = process.platform === 'win32' ? 'win32' : 'darwin'
|
||||
const resources = join(application, ...platform === 'darwin' ? ['Contents', 'Resources'] : ['resources'])
|
||||
const cli = join(resources, 'runtime', 'cli')
|
||||
prepareDesktopCli(cli, platform)
|
||||
const electron = join(application, ...platform === 'darwin' ? ['Contents', 'MacOS', 'DeepSeek Harness'] : ['DeepSeek Harness.exe'])
|
||||
mkdirSync(dirname(electron), { recursive: true })
|
||||
if (platform === 'win32') copyFileSync(process.execPath, electron)
|
||||
else symlinkSync(process.execPath, electron)
|
||||
const entry = join(resources, 'app.asar', 'dsh', 'node_modules', '@deepseek-ai', 'dsh-desktop-host', 'lib', 'cli.js')
|
||||
mkdirSync(dirname(entry), { recursive: true })
|
||||
writeFileSync(join(dirname(entry), 'package.json'), '{"type":"module"}\n')
|
||||
writeFileSync(entry, [
|
||||
'const chunks = []',
|
||||
'for await (const chunk of process.stdin) chunks.push(chunk)',
|
||||
"process.stdout.write(JSON.stringify({ args: process.argv.slice(2), cwd: process.cwd(), value: process.env.DSH_CLI_TEST_VALUE, nodeMode: process.env.ELECTRON_RUN_AS_NODE, input: Buffer.concat(chunks).toString('hex') }))",
|
||||
"process.stderr.write('separate stderr\\n')",
|
||||
'process.exitCode = 23',
|
||||
'',
|
||||
].join('\n'))
|
||||
const command = join(cli, 'bin', platform === 'win32' ? 'dsh.cmd' : 'dsh')
|
||||
function start(args: string[], executable = command) {
|
||||
// cmd fixture inputs contain no metacharacters; POSIX cases exercise literal expansion characters separately.
|
||||
const child = platform === 'win32'
|
||||
? spawn(process.env.ComSpec ?? 'cmd.exe', ['/d', '/s', '/c', `""${executable}" ${args.map(value => `"${value}"`).join(' ')}"`], {
|
||||
cwd: root, env: { ...process.env, DSH_CLI_TEST_VALUE: 'kept' }, stdio: 'pipe', windowsVerbatimArguments: true,
|
||||
})
|
||||
: spawn(executable, args, { cwd: root, env: { ...process.env, DSH_CLI_TEST_VALUE: 'kept' }, stdio: 'pipe' })
|
||||
children.push(child)
|
||||
const closed = new Promise<number | null>((resolve, reject) => {
|
||||
child.once('error', reject)
|
||||
child.once('close', resolve)
|
||||
})
|
||||
exits.push(closed)
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
child.stdout.setEncoding('utf8').on('data', (text: string) => { stdout += text })
|
||||
child.stderr.setEncoding('utf8').on('data', (text: string) => { stderr += text })
|
||||
return { child, closed, stdout: () => stdout, stderr: () => stderr }
|
||||
}
|
||||
return { root, command, start }
|
||||
}
|
||||
|
||||
it('preserves common arguments, cwd, environment, binary input, stderr and exit status', async () => {
|
||||
const f = fixture()
|
||||
const args = ['plugin', '--profile', 'desktop', 'hello world', '中文 🚀', '']
|
||||
const run = f.start(args)
|
||||
const input = Buffer.from([0, 1, 10, 255])
|
||||
run.child.stdin.end(input)
|
||||
expect(await run.closed, run.stderr()).toBe(23)
|
||||
expect(JSON.parse(run.stdout())).toEqual({ args, cwd: f.root, value: 'kept', nodeMode: '1', input: input.toString('hex') })
|
||||
expect(run.stderr()).toBe('separate stderr\n')
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')('resolves chained command symlinks without expanding argument contents', async () => {
|
||||
const f = fixture()
|
||||
const link = join(f.root, 'command-link')
|
||||
const command = join(f.root, 'dsh')
|
||||
symlinkSync(relative(f.root, f.command), link)
|
||||
symlinkSync(link, command)
|
||||
const args = ['quote"inside', 'trailing\\', '%PATH%', '$HOME', '`literal`', '']
|
||||
const run = f.start(args, command)
|
||||
run.child.stdin.end()
|
||||
expect(await run.closed, run.stderr()).toBe(23)
|
||||
expect(JSON.parse(run.stdout())).toMatchObject({ args, cwd: f.root, nodeMode: '1' })
|
||||
})
|
||||
@@ -0,0 +1,271 @@
|
||||
/** Real command entries and receipts; every test owns its destination directory. */
|
||||
|
||||
import { chmod, lstat, mkdir, mkdtemp, readFile, readdir, readlink, rm, symlink, unlink, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFinished, vi } from 'vitest'
|
||||
import { inspectFileCommand, installFileCommand, removeFileCommand } from '../src/command-installation.ts'
|
||||
import { prepareCommandLink } from '../scripts/prepare-command-link.ts'
|
||||
|
||||
const barrier = vi.hoisted(() => ({
|
||||
afterRead: undefined as ((path: unknown) => Promise<void>) | undefined,
|
||||
afterRename: undefined as ((path: unknown) => Promise<void>) | undefined,
|
||||
afterStat: undefined as ((path: unknown, file: boolean) => Promise<void>) | undefined,
|
||||
beforeUnlink: undefined as ((path: unknown) => void) | undefined,
|
||||
failReceipt: false,
|
||||
}))
|
||||
vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs/promises')>()
|
||||
return { ...actual, readFile: async (...args: Parameters<typeof actual.readFile>) => {
|
||||
const result = await actual.readFile(...args)
|
||||
await barrier.afterRead?.(args[0])
|
||||
return result
|
||||
}, lstat: async (...args: Parameters<typeof actual.lstat>) => {
|
||||
const result = await actual.lstat(...args)
|
||||
await barrier.afterStat?.(args[0], result.isFile())
|
||||
return result
|
||||
}, rename: async (...args: Parameters<typeof actual.rename>) => {
|
||||
if (barrier.failReceipt && String(args[1]).endsWith('.dsh-desktop-command.json')) {
|
||||
throw Object.assign(new Error('No space for receipt'), { code: 'ENOSPC' })
|
||||
}
|
||||
await actual.rename(...args)
|
||||
await barrier.afterRename?.(args[0])
|
||||
}, unlink: async (...args: Parameters<typeof actual.unlink>) => {
|
||||
barrier.beforeUnlink?.(args[0])
|
||||
await actual.unlink(...args)
|
||||
} }
|
||||
})
|
||||
|
||||
let compiled: string
|
||||
|
||||
async function fixture() {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-command-link-'))
|
||||
onTestFinished(() => rm(root, { recursive: true, force: true }))
|
||||
const launcher = join(root, 'desktop-launcher')
|
||||
await writeFile(launcher, 'desktop\n', { mode: 0o755 })
|
||||
return { root, options: { destination: join(root, 'dsh'), launcher, linkHelper: join(compiled, 'link-entry') } }
|
||||
}
|
||||
|
||||
describe.skipIf(process.platform === 'win32')('macOS command entry ownership', () => {
|
||||
beforeAll(async () => {
|
||||
compiled = await mkdtemp(join(tmpdir(), 'dsh-command-control-'))
|
||||
if (process.platform === 'darwin') prepareCommandLink(compiled, process.arch === 'arm64' ? 'arm64' : 'x64', '13.0')
|
||||
})
|
||||
afterAll(async () => { if (compiled !== undefined) await rm(compiled, { recursive: true, force: true }) })
|
||||
it('installs and removes only its own link', async () => {
|
||||
const f = await fixture()
|
||||
const before = await inspectFileCommand(f.options)
|
||||
const installed = await installFileCommand(f.options, before.fingerprint)
|
||||
expect(installed.managed).toBe(true)
|
||||
expect(await readlink(f.options.destination)).toBe(f.options.launcher)
|
||||
await removeFileCommand(f.options, installed.fingerprint)
|
||||
await expect(lstat(f.options.destination)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
expect(await readFile(f.options.launcher, 'utf8')).toBe('desktop\n')
|
||||
})
|
||||
|
||||
it('preserves the raw target of an existing relative npm link and restores it', async () => {
|
||||
const f = await fixture()
|
||||
await writeFile(join(f.root, 'npm-cli'), 'npm\n', { mode: 0o755 })
|
||||
await symlink('npm-cli', f.options.destination)
|
||||
const installed = await installFileCommand(f.options, (await inspectFileCommand(f.options)).fingerprint)
|
||||
expect(await readlink(installed.backup!)).toBe('npm-cli')
|
||||
await removeFileCommand(f.options, installed.fingerprint)
|
||||
expect(await readlink(f.options.destination)).toBe('npm-cli')
|
||||
expect(await readFile(f.options.destination, 'utf8')).toBe('npm\n')
|
||||
})
|
||||
|
||||
it('restores a regular executable with its bytes and permission bits', async () => {
|
||||
const f = await fixture()
|
||||
const bytes = Buffer.from([0, 1, 2, 255])
|
||||
await writeFile(f.options.destination, bytes, { mode: 0o751 })
|
||||
const installed = await installFileCommand(f.options, (await inspectFileCommand(f.options)).fingerprint)
|
||||
await removeFileCommand(f.options, installed.fingerprint)
|
||||
expect(await readFile(f.options.destination)).toEqual(bytes)
|
||||
expect((await lstat(f.options.destination)).mode & 0o777).toBe(0o751)
|
||||
})
|
||||
|
||||
it('rejects a changed confirmation without moving the replacement', async () => {
|
||||
const f = await fixture()
|
||||
const before = await inspectFileCommand(f.options)
|
||||
await symlink('another-command', f.options.destination)
|
||||
await expect(installFileCommand(f.options, before.fingerprint)).rejects.toMatchObject({ code: 'ESTALE' })
|
||||
expect(await readlink(f.options.destination)).toBe('another-command')
|
||||
})
|
||||
|
||||
it('repairs a moved application without replacing the original backup', async () => {
|
||||
const f = await fixture()
|
||||
await symlink('npm-cli', f.options.destination)
|
||||
const first = await installFileCommand(f.options, (await inspectFileCommand(f.options)).fingerprint)
|
||||
const moved = join(f.root, 'moved-desktop')
|
||||
await writeFile(moved, 'moved\n', { mode: 0o755 })
|
||||
await unlink(f.options.launcher)
|
||||
const options = { ...f.options, launcher: moved }
|
||||
const broken = await inspectFileCommand(options)
|
||||
expect(broken.managed).toBe(true)
|
||||
expect(broken.available).toBe(false)
|
||||
const repaired = await installFileCommand(options, broken.fingerprint)
|
||||
expect(repaired.backup).toBe(first.backup)
|
||||
expect(await readlink(options.destination)).toBe(moved)
|
||||
await removeFileCommand(options, repaired.fingerprint)
|
||||
expect(await readlink(options.destination)).toBe('npm-cli')
|
||||
})
|
||||
|
||||
it.each(['install', 'remove'] as const)('preserves a replacement arriving during %s after the approved entry was read', async (operation) => {
|
||||
const f = await fixture()
|
||||
const installed = await installFileCommand(f.options, (await inspectFileCommand(f.options)).fingerprint)
|
||||
const receipt = join(f.root, '.dsh-desktop-command.json')
|
||||
const originalReceipt = await readFile(receipt, 'utf8')
|
||||
barrier.afterRead = async (path) => {
|
||||
if (path !== receipt) return
|
||||
barrier.afterRead = undefined
|
||||
await unlink(f.options.destination)
|
||||
await writeFile(f.options.destination, 'replacement\n', { mode: 0o755 })
|
||||
}
|
||||
onTestFinished(() => { barrier.afterRead = undefined })
|
||||
const apply = operation === 'install' ? installFileCommand : removeFileCommand
|
||||
await expect(apply(f.options, installed.fingerprint)).rejects.toMatchObject({ code: 'ESTALE' })
|
||||
expect(await readFile(f.options.destination, 'utf8')).toBe('replacement\n')
|
||||
expect(await readFile(receipt, 'utf8')).toBe(originalReceipt)
|
||||
})
|
||||
|
||||
it('leaves a command installed by someone else after Desktop registration intact', async () => {
|
||||
const f = await fixture()
|
||||
await symlink('old-npm', f.options.destination)
|
||||
const installed = await installFileCommand(f.options, (await inspectFileCommand(f.options)).fingerprint)
|
||||
await unlink(f.options.destination)
|
||||
await symlink('new-npm', f.options.destination)
|
||||
const state = await inspectFileCommand(f.options)
|
||||
const removed = await removeFileCommand(f.options, state.fingerprint)
|
||||
expect(await readlink(f.options.destination)).toBe('new-npm')
|
||||
expect(removed.preservedBackup).toBe(installed.backup)
|
||||
expect(await readlink(installed.backup!)).toBe('old-npm')
|
||||
})
|
||||
|
||||
it('refuses to restore a modified backup', async () => {
|
||||
const f = await fixture()
|
||||
await writeFile(f.options.destination, 'original\n', { mode: 0o755 })
|
||||
const installed = await installFileCommand(f.options, (await inspectFileCommand(f.options)).fingerprint)
|
||||
await writeFile(installed.backup!, 'changed backup with different bytes\n')
|
||||
await expect(removeFileCommand(f.options, installed.fingerprint)).rejects.toMatchObject({ code: 'EOWNERSHIP' })
|
||||
expect(await readlink(f.options.destination)).toBe(f.options.launcher)
|
||||
})
|
||||
|
||||
it('retains ownership and the original inode when receipt publication fails during repair', async () => {
|
||||
const f = await fixture()
|
||||
const installed = await installFileCommand(f.options, (await inspectFileCommand(f.options)).fingerprint)
|
||||
const inode = (await lstat(f.options.destination)).ino
|
||||
barrier.failReceipt = true
|
||||
onTestFinished(() => { barrier.failReceipt = false })
|
||||
await expect(installFileCommand(f.options, installed.fingerprint)).rejects.toMatchObject({ code: 'ENOSPC' })
|
||||
expect((await lstat(f.options.destination)).ino).toBe(inode)
|
||||
expect((await inspectFileCommand(f.options)).managed).toBe(true)
|
||||
barrier.failReceipt = false
|
||||
await removeFileCommand(f.options, (await inspectFileCommand(f.options)).fingerprint)
|
||||
await expect(lstat(f.options.destination)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
})
|
||||
|
||||
it('preserves a backup replaced after the removal decision and restores the Desktop link', async () => {
|
||||
const f = await fixture()
|
||||
await writeFile(f.options.destination, 'original\n', { mode: 0o755 })
|
||||
const installed = await installFileCommand(f.options, (await inspectFileCommand(f.options)).fingerprint)
|
||||
barrier.afterRename = async (source) => {
|
||||
if (source !== f.options.destination) return
|
||||
barrier.afterRename = undefined
|
||||
await unlink(installed.backup!)
|
||||
await writeFile(installed.backup!, 'changed backup\n', { mode: 0o755 })
|
||||
}
|
||||
onTestFinished(() => { barrier.afterRename = undefined })
|
||||
await expect(removeFileCommand(f.options, installed.fingerprint)).rejects.toMatchObject({ code: 'EOWNERSHIP' })
|
||||
expect(await readFile(installed.backup!, 'utf8')).toBe('changed backup\n')
|
||||
expect((await inspectFileCommand(f.options)).managed).toBe(true)
|
||||
})
|
||||
|
||||
it('retains both removal and restoration failures with the preserved link location', async () => {
|
||||
const f = await fixture()
|
||||
const installed = await installFileCommand(f.options, (await inspectFileCommand(f.options)).fingerprint)
|
||||
barrier.beforeUnlink = (path) => {
|
||||
if (path !== join(f.root, '.dsh-desktop-command.json')) return
|
||||
barrier.afterStat = async () => { throw Object.assign(new Error('Restoration denied'), { code: 'EPERM' }) }
|
||||
throw Object.assign(new Error('Receipt removal denied'), { code: 'EACCES' })
|
||||
}
|
||||
onTestFinished(() => { barrier.beforeUnlink = undefined; barrier.afterStat = undefined })
|
||||
const failure: unknown = await removeFileCommand(f.options, installed.fingerprint).catch((error: unknown) => error)
|
||||
expect(failure).toBeInstanceOf(AggregateError)
|
||||
if (!(failure instanceof AggregateError)) throw new Error('Removal did not report both failures')
|
||||
expect(failure.errors).toMatchObject([{ code: 'EACCES' }, { code: 'EPERM' }])
|
||||
expect(failure.message).toContain('The Desktop command link is preserved at ')
|
||||
barrier.beforeUnlink = undefined; barrier.afterStat = undefined
|
||||
const preserved = (await readdir(f.root)).filter(name => name.startsWith('.dsh-command-backup-'))
|
||||
expect(preserved).toHaveLength(1)
|
||||
expect(await readlink(join(f.root, preserved[0]!))).toBe(f.options.launcher)
|
||||
})
|
||||
|
||||
it('cleans the obsolete Desktop link after backup restoration even when receipt removal fails', async () => {
|
||||
const f = await fixture()
|
||||
await symlink('previous-command', f.options.destination)
|
||||
const installed = await installFileCommand(f.options, (await inspectFileCommand(f.options)).fingerprint)
|
||||
barrier.beforeUnlink = (path) => {
|
||||
if (path === join(f.root, '.dsh-desktop-command.json')) throw Object.assign(new Error('Receipt removal denied'), { code: 'EACCES' })
|
||||
}
|
||||
onTestFinished(() => { barrier.beforeUnlink = undefined })
|
||||
await expect(removeFileCommand(f.options, installed.fingerprint)).rejects.toMatchObject({ code: 'EACCES' })
|
||||
expect(await readlink(f.options.destination)).toBe('previous-command')
|
||||
expect((await readdir(f.root)).filter(name => name.startsWith('.dsh-command-backup-'))).toEqual([])
|
||||
barrier.beforeUnlink = undefined
|
||||
const retried = await installFileCommand(f.options, (await inspectFileCommand(f.options)).fingerprint)
|
||||
expect(retried.preservedBackup).toBeUndefined()
|
||||
await removeFileCommand(f.options, retried.fingerprint)
|
||||
expect(await readlink(f.options.destination)).toBe('previous-command')
|
||||
})
|
||||
|
||||
it('leaves a replacement at the original backup path after restoration has claimed its source', async () => {
|
||||
const f = await fixture()
|
||||
await writeFile(f.options.destination, 'original\n', { mode: 0o755 })
|
||||
const installed = await installFileCommand(f.options, (await inspectFileCommand(f.options)).fingerprint)
|
||||
barrier.afterStat = async (path, file) => {
|
||||
if (path !== f.options.destination || !file) return
|
||||
barrier.afterStat = undefined
|
||||
await writeFile(installed.backup!, 'later replacement\n', { mode: 0o755 })
|
||||
}
|
||||
onTestFinished(() => { barrier.afterStat = undefined })
|
||||
await removeFileCommand(f.options, installed.fingerprint)
|
||||
expect(await readFile(f.options.destination, 'utf8')).toBe('original\n')
|
||||
expect(await readFile(installed.backup!, 'utf8')).toBe('later replacement\n')
|
||||
})
|
||||
|
||||
it('rejects directories and non-executable launcher resources', async () => {
|
||||
const f = await fixture()
|
||||
await mkdir(f.options.destination)
|
||||
await expect(installFileCommand(f.options, (await inspectFileCommand(f.options)).fingerprint)).rejects.toMatchObject({ code: 'EUNSUPPORTED' })
|
||||
expect((await lstat(f.options.destination)).isDirectory()).toBe(true)
|
||||
await rm(f.options.destination, { recursive: true })
|
||||
await chmod(f.options.launcher, 0o644)
|
||||
await expect(installFileCommand(f.options, (await inspectFileCommand(f.options)).fingerprint)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
})
|
||||
|
||||
it('rejects linked receipts and backup paths outside the command directory', async () => {
|
||||
const f = await fixture()
|
||||
const receipt = join(f.root, '.dsh-desktop-command.json')
|
||||
await symlink(f.options.launcher, receipt)
|
||||
await expect(inspectFileCommand(f.options)).rejects.toMatchObject({ code: 'EOWNERSHIP' })
|
||||
await unlink(receipt)
|
||||
await writeFile(receipt, JSON.stringify({
|
||||
schemaVersion: 1, launcher: f.options.launcher, installedFingerprint: '0'.repeat(64),
|
||||
backup: { name: '../../other-file', fingerprint: '0'.repeat(64) },
|
||||
}))
|
||||
await expect(inspectFileCommand(f.options)).rejects.toMatchObject({ code: 'EOWNERSHIP' })
|
||||
expect(await readFile(f.options.launcher, 'utf8')).toBe('desktop\n')
|
||||
})
|
||||
|
||||
it('serializes simultaneous choices so only one stale snapshot can install', async () => {
|
||||
const f = await fixture()
|
||||
const state = await inspectFileCommand(f.options)
|
||||
const results = await Promise.allSettled([
|
||||
installFileCommand(f.options, state.fingerprint),
|
||||
installFileCommand(f.options, state.fingerprint),
|
||||
])
|
||||
expect(results.filter(result => result.status === 'fulfilled')).toHaveLength(1)
|
||||
expect(results.filter(result => result.status === 'rejected')).toHaveLength(1)
|
||||
expect((await inspectFileCommand(f.options)).managed).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
/** Locale-owned native command-management states. */
|
||||
|
||||
import { expect, it } from 'vitest'
|
||||
import { en, zh } from '../src/locale.ts'
|
||||
import { presentCommandManagement } from '../src/command-management.ts'
|
||||
|
||||
it.each([en, zh])('shows installation, repair/removal and shadowed command locations', (messages) => {
|
||||
const destination = '/usr/local/bin/dsh'
|
||||
const missing = presentCommandManagement({ managed: false, available: false, destination }, messages, 'darwin')
|
||||
expect(missing.buttons).toEqual([messages.cliCommandInstall, messages.cancel])
|
||||
expect(missing.cancelId).toBe(1)
|
||||
const installed = presentCommandManagement({ managed: true, available: true, destination, activeCommand: destination }, messages, 'darwin')
|
||||
expect(installed.buttons).toEqual([messages.cliCommandClose, messages.cliCommandRepair, messages.cliCommandRemove])
|
||||
expect(installed.cancelId).toBe(0)
|
||||
const shadowed = presentCommandManagement({ managed: true, available: true, destination, activeCommand: '/opt/homebrew/bin/dsh' }, messages, 'darwin')
|
||||
expect(shadowed.type).toBe('warning')
|
||||
expect(shadowed.detail).toContain('/opt/homebrew/bin/dsh')
|
||||
expect(shadowed.detail).toContain(messages.cliCommandShadowed)
|
||||
const unknown = presentCommandManagement({ managed: false, available: false, destination, selectionUnknown: true }, messages, 'darwin')
|
||||
expect(unknown.detail).toContain(messages.cliCommandSelectionUnknown)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['darwin', 'en-US', en], ['darwin', 'zh-CN', zh],
|
||||
['win32', 'en-US', en], ['win32', 'zh-CN', zh],
|
||||
] as const)('records command status on %s in %s', async (platform, language, messages) => {
|
||||
const destination = platform === 'darwin' ? '/usr/local/bin/dsh' : 'C:\\Users\\user\\AppData\\Local\\Programs\\DeepSeek Harness\\resources\\runtime\\cli\\bin\\dsh.cmd'
|
||||
const other = platform === 'darwin' ? '/opt/homebrew/bin/dsh' : 'C:\\Program Files\\nodejs\\dsh.cmd'
|
||||
const states = [
|
||||
{ managed: false, available: false, destination },
|
||||
{ managed: true, available: true, destination, activeCommand: destination },
|
||||
{ managed: true, available: false, destination },
|
||||
{ managed: true, available: true, destination, activeCommand: other },
|
||||
{ managed: false, available: false, destination, selectionUnknown: true },
|
||||
]
|
||||
await expect(JSON.stringify({
|
||||
dialogs: states.map(state => presentCommandManagement(state, messages, platform)),
|
||||
}, null, 2) + '\n').toMatchFileSnapshot(`./expected/command-management-${platform}-${language}.json`)
|
||||
})
|
||||
@@ -0,0 +1,162 @@
|
||||
/** User decisions cross a real worker process; the worker replaces only the OS mutation boundary. */
|
||||
|
||||
import { copyFile, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, expect, it, onTestFinished, vi } from 'vitest'
|
||||
import type { ExecFileOptions } from 'node:child_process'
|
||||
import type { MessageBoxOptions, MessageBoxReturnValue } from 'electron'
|
||||
import { DesktopCommandManager } from '../src/command-management.ts'
|
||||
import { en, zh, type DesktopMessages } from '../src/locale.ts'
|
||||
|
||||
const external = vi.hoisted(() => ({ shell: null as string | null, cancelAuthorization: false, elevations: 0 }))
|
||||
|
||||
vi.mock('node:os', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:os')>()
|
||||
return { ...actual, userInfo: () => ({ ...actual.userInfo(), shell: external.shell }) }
|
||||
})
|
||||
|
||||
vi.mock('node:child_process', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:child_process')>()
|
||||
const { promisify } = await import('node:util')
|
||||
const execute = promisify(actual.execFile)
|
||||
const invoke = async (file: string, args: string[], options: ExecFileOptions) => {
|
||||
if (file !== '/usr/bin/osascript') return execute(file, args, options)
|
||||
external.elevations++
|
||||
// Exercise AppleScript's real stdout/error transport without authorizing an OS mutation.
|
||||
return execute(file, external.cancelAuthorization ? ['-e', 'error number -128']
|
||||
: ['-e', args[1]!.replace(' with administrator privileges', ''), ...args.slice(2)], options)
|
||||
}
|
||||
return { ...actual, execFile: Object.assign(vi.fn(), { [promisify.custom]: invoke }) }
|
||||
})
|
||||
|
||||
afterEach(() => { external.shell = null; external.cancelAuthorization = false; external.elevations = 0 })
|
||||
|
||||
async function fixture(options: {
|
||||
failure?: string
|
||||
managed?: boolean
|
||||
shadowed?: boolean
|
||||
elevate?: boolean
|
||||
messages?: DesktopMessages
|
||||
} = {}) {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-command-dialog-'))
|
||||
onTestFinished(() => rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }))
|
||||
const resources = join(root, 'resources')
|
||||
const bin = join(resources, 'runtime', 'primary-runtime', 'dependencies', 'node', 'bin')
|
||||
const worker = join(resources, 'runtime', 'cli')
|
||||
await mkdir(bin, { recursive: true })
|
||||
await mkdir(worker, { recursive: true })
|
||||
const node = join(bin, process.platform === 'win32' ? 'node.exe' : 'node')
|
||||
if (process.platform === 'win32') await copyFile(process.execPath, node)
|
||||
else await symlink(process.execPath, node)
|
||||
await writeFile(join(worker, 'package.json'), '{"type":"module"}\n')
|
||||
const state = {
|
||||
fingerprint: 'a'.repeat(64), managed: options.managed ?? false, available: true, kind: options.managed ? 'symlink' : 'file',
|
||||
destination: join(root, process.platform === 'win32' ? 'dsh.cmd' : 'dsh'),
|
||||
directory: root, launcher: join(root, 'desktop-dsh'),
|
||||
activeCommand: options.managed && !options.shadowed ? join(root, process.platform === 'win32' ? 'dsh.cmd' : 'dsh') : join(root, 'other-dsh'),
|
||||
}
|
||||
if (process.platform === 'darwin') {
|
||||
const shell = join(root, 'lookup-shell')
|
||||
await writeFile(shell, '#!/bin/sh\nprintf "\\0DSH_COMMAND\\0"\ncat "$(dirname "$0")/selected-command"\nprintf "\\0"\n', { mode: 0o755 })
|
||||
await writeFile(join(root, 'selected-command'), state.activeCommand + '\n')
|
||||
external.shell = shell
|
||||
}
|
||||
await writeFile(join(worker, 'state.json'), JSON.stringify({ state, failure: options.failure, elevate: options.elevate }))
|
||||
await writeFile(join(worker, 'command-manager.js'), [
|
||||
"import { readFileSync, appendFileSync, existsSync } from 'node:fs'",
|
||||
"import { join } from 'node:path'",
|
||||
"const { state, failure, elevate } = JSON.parse(readFileSync(join(import.meta.dirname, 'state.json'), 'utf8'))",
|
||||
'const operation = process.argv[2]',
|
||||
"const calls = join(import.meta.dirname, 'calls.jsonl')",
|
||||
"const attempted = existsSync(calls) && readFileSync(calls, 'utf8').includes('install')",
|
||||
"appendFileSync(join(import.meta.dirname, 'calls.jsonl'), JSON.stringify({operation, expected:process.argv[3]})+'\\n')",
|
||||
"if (operation === 'install' && elevate && !attempted) {",
|
||||
" process.stdout.write(JSON.stringify({ok:false,code:'EACCES',message:'authorization required'}))",
|
||||
"} else if (operation === 'install' && failure) {",
|
||||
" process.stdout.write(JSON.stringify({ok:false,code:failure,message:'worker declined'}))",
|
||||
'} else {',
|
||||
' process.stdout.write(JSON.stringify({ok:true,state}))',
|
||||
'}',
|
||||
'',
|
||||
].join('\n'))
|
||||
const show = vi.fn<(options: MessageBoxOptions) => Promise<MessageBoxReturnValue>>()
|
||||
const manager = new DesktopCommandManager({
|
||||
resources, isPackaged: true, isInstalledLocation: () => true, isInstalling: () => false, isQuitting: () => false,
|
||||
messages: () => options.messages ?? en, show,
|
||||
})
|
||||
const calls = async (): Promise<Array<{ operation: string; expected?: string }>> =>
|
||||
(await readFile(join(worker, 'calls.jsonl'), 'utf8')).trim().split('\n').map(line => JSON.parse(line) as { operation: string; expected?: string })
|
||||
return { manager, show, calls }
|
||||
}
|
||||
|
||||
it('requires confirmation before replacing an existing command', async () => {
|
||||
const f = await fixture()
|
||||
f.show.mockResolvedValueOnce({ response: 0, checkboxChecked: false })
|
||||
.mockResolvedValueOnce({ response: 1, checkboxChecked: false })
|
||||
await f.manager.show()
|
||||
expect(f.show.mock.calls[1]![0]).toMatchObject({ message: en.cliCommandSwitch, defaultId: 1, cancelId: 1 })
|
||||
expect(await f.calls()).toEqual([{ operation: 'inspect' }])
|
||||
})
|
||||
|
||||
it('passes the displayed fingerprint and reports a stale worker refusal', async () => {
|
||||
const f = await fixture({ failure: 'ESTALE' })
|
||||
f.show.mockResolvedValue({ response: 0, checkboxChecked: false })
|
||||
await f.manager.show()
|
||||
expect(await f.calls()).toEqual([{ operation: 'inspect' }, { operation: 'install', expected: 'a'.repeat(64) }])
|
||||
expect(f.show.mock.lastCall![0].message).toBe(en.cliCommandChanged)
|
||||
})
|
||||
|
||||
it.each([['en-US', en], ['zh-CN', zh]] as const)('repairs its own selected command without a switch confirmation in %s', async (locale, messages) => {
|
||||
const f = await fixture({ managed: true, messages })
|
||||
f.show.mockResolvedValueOnce({ response: 1, checkboxChecked: false }).mockResolvedValue({ response: 0, checkboxChecked: false })
|
||||
await f.manager.show()
|
||||
expect(await f.calls()).toEqual([{ operation: 'inspect' }, { operation: 'install', expected: 'a'.repeat(64) }, { operation: 'inspect' }])
|
||||
await expect(JSON.stringify(f.show.mock.calls.map(([options]) => ({ message: options.message, buttons: options.buttons })), null, 2) + '\n')
|
||||
.toMatchFileSnapshot(join(import.meta.dirname, 'expected', `command-repair-${locale}.json`))
|
||||
})
|
||||
|
||||
it('keeps confirmation when repairing a command shadowed by another installation', async () => {
|
||||
const f = await fixture({ managed: true, shadowed: true })
|
||||
f.show.mockResolvedValue({ response: 1, checkboxChecked: false })
|
||||
await f.manager.show()
|
||||
expect(f.show.mock.calls[1]![0].message).toBe(en.cliCommandSwitch)
|
||||
expect(await f.calls()).toEqual([{ operation: 'inspect' }])
|
||||
})
|
||||
|
||||
it.skipIf(process.platform !== 'darwin')('keeps a declined worker response through the AppleScript transport', async () => {
|
||||
const f = await fixture({ failure: 'ESTALE', elevate: true })
|
||||
f.show.mockResolvedValue({ response: 0, checkboxChecked: false })
|
||||
await f.manager.show()
|
||||
expect(external.elevations).toBe(1)
|
||||
expect(await f.calls()).toEqual([{ operation: 'inspect' }, { operation: 'install', expected: 'a'.repeat(64) }, { operation: 'install', expected: 'a'.repeat(64) }])
|
||||
expect(f.show.mock.lastCall![0]).toMatchObject({ message: en.cliCommandChanged, technicalDetails: 'worker declined' })
|
||||
})
|
||||
|
||||
it.skipIf(process.platform !== 'darwin')('returns without an error dialog when administrator authorization is cancelled', async () => {
|
||||
const f = await fixture({ elevate: true })
|
||||
external.cancelAuthorization = true
|
||||
f.show.mockResolvedValue({ response: 0, checkboxChecked: false })
|
||||
await f.manager.show()
|
||||
expect(external.elevations).toBe(1)
|
||||
expect(f.show).toHaveBeenCalledTimes(2)
|
||||
expect(await f.calls()).toEqual([{ operation: 'inspect' }, { operation: 'install', expected: 'a'.repeat(64) }])
|
||||
})
|
||||
|
||||
it('coalesces repeated menu clicks and keeps update preparation waiting for the active decision', async () => {
|
||||
const f = await fixture()
|
||||
const shown = Promise.withResolvers<undefined>()
|
||||
const choice = Promise.withResolvers<MessageBoxReturnValue>()
|
||||
f.show.mockImplementation(() => { shown.resolve(undefined); return choice.promise })
|
||||
const first = f.manager.show()
|
||||
expect(f.manager.show()).toBe(first)
|
||||
await shown.promise
|
||||
let idle = false
|
||||
const waiting = f.manager.idle().then(() => { idle = true })
|
||||
await Promise.resolve()
|
||||
expect(idle).toBe(false)
|
||||
choice.resolve({ response: 1, checkboxChecked: false })
|
||||
await Promise.all([first, waiting])
|
||||
expect(idle).toBe(true)
|
||||
expect(await f.calls()).toEqual([{ operation: 'inspect' }])
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
/** Real Windows registry operations use a unique key tree rather than the user's environment. */
|
||||
|
||||
import { execFile } from 'node:child_process'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { promisify } from 'node:util'
|
||||
import { expect, it } from 'vitest'
|
||||
|
||||
it.skipIf(process.platform !== 'win32')('preserves unrelated PATH entries and rejects stale or foreign removal', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-command-path-'))
|
||||
try {
|
||||
const systemRoot = process.env.SystemRoot ?? process.env.WINDIR
|
||||
if (systemRoot === undefined) throw new Error('SystemRoot is required for the Windows registry test')
|
||||
const result = await promisify(execFile)(join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'), [
|
||||
'-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File',
|
||||
join(import.meta.dirname, 'fixtures', 'command-path.ps1'),
|
||||
'-Worker', join(import.meta.dirname, '..', 'scripts', 'command-path.ps1'), '-Root', root,
|
||||
], { timeout: 60000, windowsHide: true })
|
||||
expect(result.stdout).toContain('WINDOWS_COMMAND_PATH_OK')
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 })
|
||||
}
|
||||
}, 75000)
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { createRequire } from 'node:module'
|
||||
import { join } from 'node:path'
|
||||
@@ -8,12 +8,15 @@ import { load } from 'js-yaml'
|
||||
import { createDesktopUploadPlan } from '../scripts/desktop-upload-plan.ts'
|
||||
import { desktopUpdateMetadataFilename } from '../scripts/desktop-auto-update-environment.mjs'
|
||||
import type { DesktopPackageTargetName } from '../scripts/package-target.ts'
|
||||
import { createDesktopCos } from '../scripts/desktop-cos.ts'
|
||||
import { uploadDesktopRelease } from '../scripts/desktop-upload-run.ts'
|
||||
import { startCosLoopback } from './cos-loopback.ts'
|
||||
|
||||
const temporaryDirectories: string[] = []
|
||||
const TEST_ORIGIN = 'https://desktop-updates.example.com'
|
||||
const TEST_BUCKET = 'test-download-bucket'
|
||||
const TEST_BUCKET = 'test-download-bucket-1250000000'
|
||||
const RELEASE_ID = '0123456789abcdef0123456789abcdef'
|
||||
const PRODUCTION_BUCKET = 'production-download-bucket'
|
||||
const PRODUCTION_BUCKET = 'production-download-bucket-1250000000'
|
||||
const require = createRequire(import.meta.url)
|
||||
const { createBlockmap } = require('app-builder-lib/out/targets/differentialUpdateInfoBuilder.js') as {
|
||||
createBlockmap: (file: string, target: object, packager: { info: { emitArtifactBuildCompleted(event: object): Promise<void> } },
|
||||
@@ -110,6 +113,64 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
describe('desktop upload plan', () => {
|
||||
it('uploads only the selected latest installer to each deployment using the existing COS transport', async () => {
|
||||
const published = []
|
||||
for (const environment of ['production', 'test'] as const) {
|
||||
for (const target of ['mac-arm64', 'mac-x64', 'win-x64'] as const) {
|
||||
const paths = await fixture(target, '1.2.3', environment)
|
||||
const plan = await createDesktopUploadPlan(target, { ...paths, latest: true })
|
||||
const loopback = await startCosLoopback()
|
||||
try {
|
||||
const cos = createDesktopCos({ secretId: 'fixture-id', secretKey: 'fixture-secret' })
|
||||
loopback.redirect(cos)
|
||||
const directory = await uploadDesktopRelease(plan, cos, join(paths.appRoot, 'records'))
|
||||
expect(plan.artifacts).toHaveLength(1)
|
||||
const artifact = plan.artifacts[0]!
|
||||
expect(loopback.requests).toHaveLength(1)
|
||||
const request = loopback.requests[0]!
|
||||
expect(request.method).toBe('PUT')
|
||||
expect(request.path).toBe(`/${artifact.key}`)
|
||||
expect(request.body).toEqual(await readFile(artifact.path))
|
||||
expect(JSON.parse(await readFile(join(directory, 'result.json'), 'utf8'))).toMatchObject({
|
||||
success: true, confirmedPuts: 1, publicReadback: 'not-performed',
|
||||
})
|
||||
published.push({ environment, target, bucket: plan.bucket, publicUrl: plan.publicUrl,
|
||||
filename: artifact.filename, key: artifact.key, contentType: artifact.contentType,
|
||||
channelMetadata: artifact.channelMetadata })
|
||||
} finally {
|
||||
await loopback.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
await expect(`${JSON.stringify(published, null, 2)}\n`).toMatchFileSnapshot('./expected/latest-installer-uploads.json')
|
||||
})
|
||||
|
||||
it('allows an explicitly selected production prerelease at the fixed installer URL', async () => {
|
||||
const paths = await fixture('win-x64', '1.2.3-alpha.4', 'production')
|
||||
const plan = await createDesktopUploadPlan('win-x64', { ...paths, latest: true })
|
||||
expect(plan.version).toBe('1.2.3-alpha.4')
|
||||
expect(plan.artifacts).toHaveLength(1)
|
||||
expect(plan.artifacts[0]).toMatchObject({
|
||||
path: join(paths.artifactsRoot, 'deepseek-harness-1.2.3-alpha.4-win-x64.exe'),
|
||||
key: 'desktop/dsh-latest-windows-x64.exe', channelMetadata: false,
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['completion', 'deployment', 'checksum'] as const)('rejects invalid %s before planning a latest upload', async (failure) => {
|
||||
const paths = await fixture('win-x64', '1.2.3', 'production')
|
||||
if (failure === 'completion') await rm(join(paths.artifactsRoot, 'win-x64-release.json'))
|
||||
if (failure === 'deployment') Object.assign(paths.environment, {
|
||||
DSH_DESKTOP_AUTO_UPDATE_ENV: 'test', DOWNLOAD_TEST_ORIGIN: TEST_ORIGIN,
|
||||
DOWNLOAD_TEST_RELEASE_ID: RELEASE_ID, DOWNLOAD_TEST_COS_BUCKET: TEST_BUCKET,
|
||||
})
|
||||
if (failure === 'checksum') {
|
||||
const path = join(paths.artifactsRoot, 'deepseek-harness-1.2.3-win-x64.exe')
|
||||
await writeFile(path, Buffer.alloc((await readFile(path)).length))
|
||||
}
|
||||
await expect(createDesktopUploadPlan('win-x64', { ...paths, latest: true }))
|
||||
.rejects.toThrow(failure === 'checksum' ? /SHA-512/u : /completion record/u)
|
||||
})
|
||||
|
||||
it('publishes fixed feeds referencing versioned binaries without overriding CDN cache policy', async () => {
|
||||
const paths = await fixture('win-x64', '1.2.3', 'production')
|
||||
const plan = await createDesktopUploadPlan('win-x64', paths)
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
[
|
||||
{
|
||||
"label": "Check for Updates…"
|
||||
},
|
||||
{
|
||||
"label": "Manage dsh Command…"
|
||||
},
|
||||
{
|
||||
"role": "hide",
|
||||
"label": "Hide DeepSeek Harness"
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
[
|
||||
{
|
||||
"label": "检查更新…"
|
||||
},
|
||||
{
|
||||
"label": "管理 dsh 命令…"
|
||||
},
|
||||
{
|
||||
"role": "hide",
|
||||
"label": "隐藏 DeepSeek Harness"
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"dialogs": [
|
||||
{
|
||||
"type": "info",
|
||||
"title": "Manage dsh Command",
|
||||
"message": "Add the Desktop command to your terminal.",
|
||||
"detail": "Desktop command: /usr/local/bin/dsh",
|
||||
"buttons": [
|
||||
"Install",
|
||||
"Cancel"
|
||||
],
|
||||
"defaultId": 0,
|
||||
"cancelId": 1
|
||||
},
|
||||
{
|
||||
"type": "info",
|
||||
"title": "Manage dsh Command",
|
||||
"message": "The Desktop command is installed.",
|
||||
"detail": "Desktop command: /usr/local/bin/dsh\n\nCurrent dsh command: /usr/local/bin/dsh",
|
||||
"buttons": [
|
||||
"Close",
|
||||
"Repair",
|
||||
"Remove"
|
||||
],
|
||||
"defaultId": 0,
|
||||
"cancelId": 0
|
||||
},
|
||||
{
|
||||
"type": "info",
|
||||
"title": "Manage dsh Command",
|
||||
"message": "The Desktop command needs repair.",
|
||||
"detail": "Desktop command: /usr/local/bin/dsh",
|
||||
"buttons": [
|
||||
"Close",
|
||||
"Repair",
|
||||
"Remove"
|
||||
],
|
||||
"defaultId": 0,
|
||||
"cancelId": 0
|
||||
},
|
||||
{
|
||||
"type": "warning",
|
||||
"title": "Manage dsh Command",
|
||||
"message": "The Desktop command is installed.",
|
||||
"detail": "Desktop command: /usr/local/bin/dsh\n\nCurrent dsh command: /opt/homebrew/bin/dsh\n\nAnother dsh takes precedence. Remove or reorder that installation to use the Desktop command by default. You can also run the Desktop command by its full path.",
|
||||
"buttons": [
|
||||
"Close",
|
||||
"Repair",
|
||||
"Remove"
|
||||
],
|
||||
"defaultId": 0,
|
||||
"cancelId": 0
|
||||
},
|
||||
{
|
||||
"type": "info",
|
||||
"title": "Manage dsh Command",
|
||||
"message": "Add the Desktop command to your terminal.",
|
||||
"detail": "Desktop command: /usr/local/bin/dsh\n\nYour shell command could not be verified. An alias or another dsh installation may take precedence.",
|
||||
"buttons": [
|
||||
"Install",
|
||||
"Cancel"
|
||||
],
|
||||
"defaultId": 0,
|
||||
"cancelId": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"dialogs": [
|
||||
{
|
||||
"type": "info",
|
||||
"title": "管理 dsh 命令",
|
||||
"message": "将 Desktop 命令添加到终端。",
|
||||
"detail": "Desktop 命令:/usr/local/bin/dsh",
|
||||
"buttons": [
|
||||
"安装",
|
||||
"取消"
|
||||
],
|
||||
"defaultId": 0,
|
||||
"cancelId": 1
|
||||
},
|
||||
{
|
||||
"type": "info",
|
||||
"title": "管理 dsh 命令",
|
||||
"message": "Desktop 命令已安装。",
|
||||
"detail": "Desktop 命令:/usr/local/bin/dsh\n\n当前 dsh 命令:/usr/local/bin/dsh",
|
||||
"buttons": [
|
||||
"关闭",
|
||||
"修复",
|
||||
"移除"
|
||||
],
|
||||
"defaultId": 0,
|
||||
"cancelId": 0
|
||||
},
|
||||
{
|
||||
"type": "info",
|
||||
"title": "管理 dsh 命令",
|
||||
"message": "Desktop 命令需要修复。",
|
||||
"detail": "Desktop 命令:/usr/local/bin/dsh",
|
||||
"buttons": [
|
||||
"关闭",
|
||||
"修复",
|
||||
"移除"
|
||||
],
|
||||
"defaultId": 0,
|
||||
"cancelId": 0
|
||||
},
|
||||
{
|
||||
"type": "warning",
|
||||
"title": "管理 dsh 命令",
|
||||
"message": "Desktop 命令已安装。",
|
||||
"detail": "Desktop 命令:/usr/local/bin/dsh\n\n当前 dsh 命令:/opt/homebrew/bin/dsh\n\n另一个 dsh 的优先级更高。请移除或调整该安装的顺序,以默认使用 Desktop 命令;也可以通过完整路径运行 Desktop 命令。",
|
||||
"buttons": [
|
||||
"关闭",
|
||||
"修复",
|
||||
"移除"
|
||||
],
|
||||
"defaultId": 0,
|
||||
"cancelId": 0
|
||||
},
|
||||
{
|
||||
"type": "info",
|
||||
"title": "管理 dsh 命令",
|
||||
"message": "将 Desktop 命令添加到终端。",
|
||||
"detail": "Desktop 命令:/usr/local/bin/dsh\n\n无法确认 shell 中的命令。别名或另一个 dsh 安装可能具有更高优先级。",
|
||||
"buttons": [
|
||||
"安装",
|
||||
"取消"
|
||||
],
|
||||
"defaultId": 0,
|
||||
"cancelId": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"dialogs": [
|
||||
{
|
||||
"type": "info",
|
||||
"title": "Manage dsh Command",
|
||||
"message": "Add the Desktop command to your terminal.",
|
||||
"detail": "Desktop command: C:\\Users\\user\\AppData\\Local\\Programs\\DeepSeek Harness\\resources\\runtime\\cli\\bin\\dsh.cmd",
|
||||
"buttons": [
|
||||
"Install",
|
||||
"Cancel"
|
||||
],
|
||||
"defaultId": 0,
|
||||
"cancelId": 1
|
||||
},
|
||||
{
|
||||
"type": "info",
|
||||
"title": "Manage dsh Command",
|
||||
"message": "The Desktop command is installed.",
|
||||
"detail": "Desktop command: C:\\Users\\user\\AppData\\Local\\Programs\\DeepSeek Harness\\resources\\runtime\\cli\\bin\\dsh.cmd\n\nCurrent dsh command: C:\\Users\\user\\AppData\\Local\\Programs\\DeepSeek Harness\\resources\\runtime\\cli\\bin\\dsh.cmd",
|
||||
"buttons": [
|
||||
"Close",
|
||||
"Repair",
|
||||
"Remove"
|
||||
],
|
||||
"defaultId": 0,
|
||||
"cancelId": 0
|
||||
},
|
||||
{
|
||||
"type": "info",
|
||||
"title": "Manage dsh Command",
|
||||
"message": "The Desktop command needs repair.",
|
||||
"detail": "Desktop command: C:\\Users\\user\\AppData\\Local\\Programs\\DeepSeek Harness\\resources\\runtime\\cli\\bin\\dsh.cmd",
|
||||
"buttons": [
|
||||
"Close",
|
||||
"Repair",
|
||||
"Remove"
|
||||
],
|
||||
"defaultId": 0,
|
||||
"cancelId": 0
|
||||
},
|
||||
{
|
||||
"type": "warning",
|
||||
"title": "Manage dsh Command",
|
||||
"message": "The Desktop command is installed.",
|
||||
"detail": "Desktop command: C:\\Users\\user\\AppData\\Local\\Programs\\DeepSeek Harness\\resources\\runtime\\cli\\bin\\dsh.cmd\n\nCurrent dsh command: C:\\Program Files\\nodejs\\dsh.cmd\n\nAnother dsh takes precedence. Remove or reorder that installation to use the Desktop command by default. You can also run the Desktop command by its full path.",
|
||||
"buttons": [
|
||||
"Close",
|
||||
"Repair",
|
||||
"Remove"
|
||||
],
|
||||
"defaultId": 0,
|
||||
"cancelId": 0
|
||||
},
|
||||
{
|
||||
"type": "info",
|
||||
"title": "Manage dsh Command",
|
||||
"message": "Add the Desktop command to your terminal.",
|
||||
"detail": "Desktop command: C:\\Users\\user\\AppData\\Local\\Programs\\DeepSeek Harness\\resources\\runtime\\cli\\bin\\dsh.cmd\n\nYour shell command could not be verified. An alias or another dsh installation may take precedence.",
|
||||
"buttons": [
|
||||
"Install",
|
||||
"Cancel"
|
||||
],
|
||||
"defaultId": 0,
|
||||
"cancelId": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"dialogs": [
|
||||
{
|
||||
"type": "info",
|
||||
"title": "管理 dsh 命令",
|
||||
"message": "将 Desktop 命令添加到终端。",
|
||||
"detail": "Desktop 命令:C:\\Users\\user\\AppData\\Local\\Programs\\DeepSeek Harness\\resources\\runtime\\cli\\bin\\dsh.cmd",
|
||||
"buttons": [
|
||||
"安装",
|
||||
"取消"
|
||||
],
|
||||
"defaultId": 0,
|
||||
"cancelId": 1
|
||||
},
|
||||
{
|
||||
"type": "info",
|
||||
"title": "管理 dsh 命令",
|
||||
"message": "Desktop 命令已安装。",
|
||||
"detail": "Desktop 命令:C:\\Users\\user\\AppData\\Local\\Programs\\DeepSeek Harness\\resources\\runtime\\cli\\bin\\dsh.cmd\n\n当前 dsh 命令:C:\\Users\\user\\AppData\\Local\\Programs\\DeepSeek Harness\\resources\\runtime\\cli\\bin\\dsh.cmd",
|
||||
"buttons": [
|
||||
"关闭",
|
||||
"修复",
|
||||
"移除"
|
||||
],
|
||||
"defaultId": 0,
|
||||
"cancelId": 0
|
||||
},
|
||||
{
|
||||
"type": "info",
|
||||
"title": "管理 dsh 命令",
|
||||
"message": "Desktop 命令需要修复。",
|
||||
"detail": "Desktop 命令:C:\\Users\\user\\AppData\\Local\\Programs\\DeepSeek Harness\\resources\\runtime\\cli\\bin\\dsh.cmd",
|
||||
"buttons": [
|
||||
"关闭",
|
||||
"修复",
|
||||
"移除"
|
||||
],
|
||||
"defaultId": 0,
|
||||
"cancelId": 0
|
||||
},
|
||||
{
|
||||
"type": "warning",
|
||||
"title": "管理 dsh 命令",
|
||||
"message": "Desktop 命令已安装。",
|
||||
"detail": "Desktop 命令:C:\\Users\\user\\AppData\\Local\\Programs\\DeepSeek Harness\\resources\\runtime\\cli\\bin\\dsh.cmd\n\n当前 dsh 命令:C:\\Program Files\\nodejs\\dsh.cmd\n\n另一个 dsh 的优先级更高。请移除或调整该安装的顺序,以默认使用 Desktop 命令;也可以通过完整路径运行 Desktop 命令。",
|
||||
"buttons": [
|
||||
"关闭",
|
||||
"修复",
|
||||
"移除"
|
||||
],
|
||||
"defaultId": 0,
|
||||
"cancelId": 0
|
||||
},
|
||||
{
|
||||
"type": "info",
|
||||
"title": "管理 dsh 命令",
|
||||
"message": "将 Desktop 命令添加到终端。",
|
||||
"detail": "Desktop 命令:C:\\Users\\user\\AppData\\Local\\Programs\\DeepSeek Harness\\resources\\runtime\\cli\\bin\\dsh.cmd\n\n无法确认 shell 中的命令。别名或另一个 dsh 安装可能具有更高优先级。",
|
||||
"buttons": [
|
||||
"安装",
|
||||
"取消"
|
||||
],
|
||||
"defaultId": 0,
|
||||
"cancelId": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
[
|
||||
{
|
||||
"message": "The Desktop command is installed.",
|
||||
"buttons": [
|
||||
"Close",
|
||||
"Repair",
|
||||
"Remove"
|
||||
]
|
||||
},
|
||||
{
|
||||
"message": "The Desktop command is installed.",
|
||||
"buttons": [
|
||||
"Close"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,16 @@
|
||||
[
|
||||
{
|
||||
"message": "Desktop 命令已安装。",
|
||||
"buttons": [
|
||||
"关闭",
|
||||
"修复",
|
||||
"移除"
|
||||
]
|
||||
},
|
||||
{
|
||||
"message": "Desktop 命令已安装。",
|
||||
"buttons": [
|
||||
"关闭"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,62 @@
|
||||
[
|
||||
{
|
||||
"environment": "production",
|
||||
"target": "mac-arm64",
|
||||
"bucket": "production-download-bucket-1250000000",
|
||||
"publicUrl": "https://download.deepseek.com/desktop/dsh-latest-macos-arm64.dmg",
|
||||
"filename": "dsh-latest-macos-arm64.dmg",
|
||||
"key": "desktop/dsh-latest-macos-arm64.dmg",
|
||||
"contentType": "application/x-apple-diskimage",
|
||||
"channelMetadata": false
|
||||
},
|
||||
{
|
||||
"environment": "production",
|
||||
"target": "mac-x64",
|
||||
"bucket": "production-download-bucket-1250000000",
|
||||
"publicUrl": "https://download.deepseek.com/desktop/dsh-latest-macos-x64.dmg",
|
||||
"filename": "dsh-latest-macos-x64.dmg",
|
||||
"key": "desktop/dsh-latest-macos-x64.dmg",
|
||||
"contentType": "application/x-apple-diskimage",
|
||||
"channelMetadata": false
|
||||
},
|
||||
{
|
||||
"environment": "production",
|
||||
"target": "win-x64",
|
||||
"bucket": "production-download-bucket-1250000000",
|
||||
"publicUrl": "https://download.deepseek.com/desktop/dsh-latest-windows-x64.exe",
|
||||
"filename": "dsh-latest-windows-x64.exe",
|
||||
"key": "desktop/dsh-latest-windows-x64.exe",
|
||||
"contentType": "application/vnd.microsoft.portable-executable",
|
||||
"channelMetadata": false
|
||||
},
|
||||
{
|
||||
"environment": "test",
|
||||
"target": "mac-arm64",
|
||||
"bucket": "test-download-bucket-1250000000",
|
||||
"publicUrl": "https://desktop-updates.example.com/desktop/dsh-latest-macos-arm64.dmg",
|
||||
"filename": "dsh-latest-macos-arm64.dmg",
|
||||
"key": "desktop/dsh-latest-macos-arm64.dmg",
|
||||
"contentType": "application/x-apple-diskimage",
|
||||
"channelMetadata": false
|
||||
},
|
||||
{
|
||||
"environment": "test",
|
||||
"target": "mac-x64",
|
||||
"bucket": "test-download-bucket-1250000000",
|
||||
"publicUrl": "https://desktop-updates.example.com/desktop/dsh-latest-macos-x64.dmg",
|
||||
"filename": "dsh-latest-macos-x64.dmg",
|
||||
"key": "desktop/dsh-latest-macos-x64.dmg",
|
||||
"contentType": "application/x-apple-diskimage",
|
||||
"channelMetadata": false
|
||||
},
|
||||
{
|
||||
"environment": "test",
|
||||
"target": "win-x64",
|
||||
"bucket": "test-download-bucket-1250000000",
|
||||
"publicUrl": "https://desktop-updates.example.com/desktop/dsh-latest-windows-x64.exe",
|
||||
"filename": "dsh-latest-windows-x64.exe",
|
||||
"key": "desktop/dsh-latest-windows-x64.exe",
|
||||
"contentType": "application/vnd.microsoft.portable-executable",
|
||||
"channelMetadata": false
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,50 @@
|
||||
// Own a console so the test never interrupts its caller's terminal.
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#include <windows.h>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <string>
|
||||
#pragma comment(lib, "user32.lib")
|
||||
|
||||
BOOL WINAPI ignoreInterrupt(DWORD event) {
|
||||
return event == CTRL_C_EVENT || event == CTRL_BREAK_EVENT;
|
||||
}
|
||||
|
||||
int wmain(int count, wchar_t** args) {
|
||||
if (count != 7) return 2;
|
||||
FreeConsole();
|
||||
if (!AllocConsole()) return 3;
|
||||
ShowWindow(GetConsoleWindow(), SW_HIDE);
|
||||
SetEnvironmentVariableW(L"ELECTRON_RUN_AS_NODE", L"1");
|
||||
std::wstring command = L"\"" + std::wstring(args[1]) + L"\" --expose-internals \"" + args[2]
|
||||
+ L"\" \"" + args[3] + L"\" \"" + args[4] + L"\"";
|
||||
STARTUPINFOW startup{};
|
||||
startup.cb = sizeof(startup);
|
||||
HANDLE job = CreateJobObjectW(nullptr, nullptr);
|
||||
JOBOBJECT_EXTENDED_LIMIT_INFORMATION limits{};
|
||||
limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
|
||||
if (!job || !SetInformationJobObject(job, JobObjectExtendedLimitInformation, &limits, sizeof(limits))) return 4;
|
||||
PROCESS_INFORMATION child{};
|
||||
if (!CreateProcessW(args[1], command.data(), nullptr, nullptr, FALSE, CREATE_SUSPENDED, nullptr, nullptr, &startup, &child)) return 4;
|
||||
if (!AssignProcessToJobObject(job, child.hProcess)) { TerminateProcess(child.hProcess, 1); WaitForSingleObject(child.hProcess, INFINITE); CloseHandle(child.hThread); CloseHandle(child.hProcess); CloseHandle(job); return 4; }
|
||||
ResumeThread(child.hThread);
|
||||
CloseHandle(child.hThread);
|
||||
bool ready = false;
|
||||
for (int attempt = 0; attempt < 1500; ++attempt) {
|
||||
if (GetFileAttributesW(args[4]) != INVALID_FILE_ATTRIBUTES) { ready = true; break; }
|
||||
if (WaitForSingleObject(child.hProcess, 10) != WAIT_TIMEOUT) break;
|
||||
}
|
||||
const DWORD event = static_cast<DWORD>(wcstoul(args[6], nullptr, 10));
|
||||
const bool signalled = ready && SetConsoleCtrlHandler(ignoreInterrupt, TRUE) && GenerateConsoleCtrlEvent(event, 0);
|
||||
DWORD code = 0;
|
||||
const bool completed = signalled && WaitForSingleObject(child.hProcess, 15000) == WAIT_OBJECT_0
|
||||
&& GetExitCodeProcess(child.hProcess, &code);
|
||||
if (!completed) { TerminateProcess(child.hProcess, 1); WaitForSingleObject(child.hProcess, INFINITE); }
|
||||
FreeConsole();
|
||||
FILE* report = nullptr;
|
||||
_wfopen_s(&report, args[5], L"w");
|
||||
if (report) { fprintf(report, "ready=%d signalled=%d completed=%d code=%lu\n", ready, signalled, completed, code); fclose(report); }
|
||||
CloseHandle(child.hProcess);
|
||||
CloseHandle(job);
|
||||
return completed && code == (event == CTRL_C_EVENT ? 130u : 131u) ? 0 : 5;
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
param([string]$Worker, [string]$Root)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
[Console]::OutputEncoding = [Text.UTF8Encoding]::new($false)
|
||||
. $Worker
|
||||
$identity = [Guid]::NewGuid().ToString('N')
|
||||
$keyRoot = 'Software\DeepSeekHarness\CommandTests\' + $identity
|
||||
$environmentKey = $keyRoot + '\Environment'
|
||||
$ownerKey = $keyRoot + '\Owner'
|
||||
$options = @{ EnvironmentKey=$environmentKey; OwnerKey=$ownerKey; MachinePath=''; MutexName=('Local\DshCommandTest-' + $identity) }
|
||||
function Require([bool]$Condition, [string]$Message) { if (-not $Condition) { throw $Message } }
|
||||
function UserPath {
|
||||
$key = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey($environmentKey)
|
||||
try { return $key.GetValue('Path', $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) }
|
||||
finally { $key.Dispose() }
|
||||
}
|
||||
function State([string]$Directory) { return Invoke-DshCommandPath @options -Request @{ operation='inspect'; directory=$Directory } }
|
||||
function Apply([string]$Operation, [string]$Directory) {
|
||||
$state = State $Directory
|
||||
return Invoke-DshCommandPath @options -Request @{ operation=$Operation; directory=$Directory; expected=$state.fingerprint }
|
||||
}
|
||||
try {
|
||||
$desktop = Join-Path $Root ('Desktop space ' + [char]0x4e2d + [char]0x6587)
|
||||
$other = Join-Path $Root 'Other Desktop'
|
||||
$npm = Join-Path $Root 'npm'
|
||||
$system = Join-Path $Root 'system'
|
||||
New-Item -ItemType Directory -Force $desktop, $other, $npm, $system | Out-Null
|
||||
[IO.File]::WriteAllText((Join-Path $desktop 'dsh.cmd'), 'fixture')
|
||||
[IO.File]::WriteAllText((Join-Path $other 'dsh.cmd'), 'fixture')
|
||||
[IO.File]::WriteAllText((Join-Path $npm 'dsh.cmd'), 'fixture')
|
||||
$key = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey($environmentKey)
|
||||
try { $key.SetValue('Path', $npm, [Microsoft.Win32.RegistryValueKind]::ExpandString) }
|
||||
finally { $key.Dispose() }
|
||||
$before = State $desktop
|
||||
# .NET may expand a CI runner's short temporary-directory name.
|
||||
$desktop = $before.directory
|
||||
$other = (State $other).directory
|
||||
Require ($before.activeCommand -eq (Join-Path $npm 'dsh.cmd')) 'existing npm command was not detected'
|
||||
[void](Apply 'install' $desktop)
|
||||
Require ((UserPath) -eq ($desktop + ';' + $npm)) 'installation changed unrelated PATH entries'
|
||||
$key = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey($environmentKey, $true)
|
||||
try { $key.SetValue('Path', (UserPath) + ';extra-user-entry', [Microsoft.Win32.RegistryValueKind]::ExpandString) }
|
||||
finally { $key.Dispose() }
|
||||
[void](Apply 'remove' $desktop)
|
||||
Require ((UserPath) -eq ($npm + ';extra-user-entry')) 'removal lost concurrent user entries'
|
||||
$stale = State $desktop
|
||||
$key = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey($environmentKey, $true)
|
||||
try { $key.SetValue('Path', (UserPath) + ';newer-entry', [Microsoft.Win32.RegistryValueKind]::ExpandString) }
|
||||
finally { $key.Dispose() }
|
||||
$rejected = $false
|
||||
try { [void](Invoke-DshCommandPath @options -Request @{ operation='install'; directory=$desktop; expected=$stale.fingerprint }) }
|
||||
catch { $rejected = $_.Exception.Data['code'] -eq 'ESTALE' }
|
||||
Require $rejected 'stale confirmation was accepted'
|
||||
[void](Apply 'install' $desktop)
|
||||
[void](Apply 'install' $other)
|
||||
$newer = UserPath
|
||||
[void](Apply 'remove' $desktop)
|
||||
Require ((UserPath) -eq $newer) 'old installation removed the newer command'
|
||||
Require ((State $other).managed) 'old installation deleted newer ownership'
|
||||
[IO.File]::WriteAllText((Join-Path $system 'dsh.exe'), 'fixture')
|
||||
$options.MachinePath = $system
|
||||
Require ((State $other).activeCommand -eq (Join-Path $system 'dsh.exe')) 'machine PATH precedence was hidden'
|
||||
[void](Apply 'remove' $other)
|
||||
$key = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey($environmentKey, $true)
|
||||
$manualPath = $npm + ';' + $desktop + ';' + $desktop
|
||||
try { $key.SetValue('Path', $manualPath, [Microsoft.Win32.RegistryValueKind]::ExpandString) }
|
||||
finally { $key.Dispose() }
|
||||
[void](Apply 'remove' $desktop)
|
||||
Require ((UserPath) -eq $manualPath) 'unowned manual PATH entries were removed'
|
||||
[void](Apply 'install' $desktop)
|
||||
[void](Apply 'install' $desktop)
|
||||
Require ((UserPath) -eq ($desktop + ';' + $manualPath)) 'repair adopted or duplicated manual PATH entries'
|
||||
[void](Apply 'remove' $desktop)
|
||||
Require ((UserPath) -eq $manualPath) 'removal did not preserve preexisting duplicates'
|
||||
[void](Apply 'install' $desktop)
|
||||
$key = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey($environmentKey, $true)
|
||||
try { $key.SetValue('Path', $manualPath, [Microsoft.Win32.RegistryValueKind]::ExpandString) }
|
||||
finally { $key.Dispose() }
|
||||
[void](Apply 'remove' $desktop)
|
||||
Require ((UserPath) -eq $manualPath) 'removal deleted a retained entry after the owned entry was removed manually'
|
||||
[IO.File]::Delete((Join-Path $npm 'dsh.cmd'))
|
||||
[IO.File]::WriteAllText((Join-Path $npm 'dsh.ps1'), 'fixture')
|
||||
$options.MachinePath = ''
|
||||
Require ((State $desktop).activeCommand -eq (Join-Path $npm 'dsh.ps1')) 'PowerShell launcher was not detected'
|
||||
$invalidPath = 'C:\invalid|entry;C:\<missing>;' + $npm
|
||||
$key = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey($environmentKey, $true)
|
||||
try { $key.SetValue('Path', $invalidPath, [Microsoft.Win32.RegistryValueKind]::ExpandString) }
|
||||
finally { $key.Dispose() }
|
||||
Require ((State $desktop).activeCommand -eq (Join-Path $npm 'dsh.ps1')) 'invalid PATH entries prevented command discovery'
|
||||
[void](Apply 'install' $desktop)
|
||||
function Add-Type { throw 'Environment notification is unavailable' }
|
||||
try { Send-DshCommandEnvironmentChange } finally { Remove-Item Function:\Add-Type }
|
||||
Require ((UserPath) -eq ($desktop + ';' + $invalidPath)) 'a notification failure changed the committed PATH'
|
||||
[void](Apply 'remove' $desktop)
|
||||
Require ((UserPath) -eq $invalidPath) 'removal changed invalid user PATH entries'
|
||||
'WINDOWS_COMMAND_PATH_OK'
|
||||
} finally {
|
||||
[Microsoft.Win32.Registry]::CurrentUser.DeleteSubKeyTree($keyRoot, $false)
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { homedir, tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest'
|
||||
import {
|
||||
loginShellCandidates, mergeLoginShellEnvironment, parseLoginShellOutput, readDesktopLoginShellEnvironment,
|
||||
resolveDesktopLoginShellConfig,
|
||||
} from '../src/login-shell-environment.ts'
|
||||
|
||||
const account = vi.hoisted(() => ({ userInfo: vi.fn() }))
|
||||
vi.mock('node:os', async importOriginal => ({ ...await importOriginal<typeof import('node:os')>(), userInfo: account.userInfo }))
|
||||
|
||||
const DELIMITER = '_DSH_SHELL_ENV_DELIMITER_'
|
||||
const config = { timeoutMs: 5_000 }
|
||||
let directory: string
|
||||
|
||||
beforeEach(() => { directory = mkdtempSync(join(tmpdir(), 'dsh-login-shell-')) })
|
||||
afterEach(() => { rmSync(directory, { recursive: true, force: true }) })
|
||||
|
||||
/** Whether `pid` has exited; Linux can keep a killed process as a zombie until its new parent reaps it. */
|
||||
function exited(pid: number): boolean {
|
||||
try {
|
||||
if (process.platform === 'linux') return /^State:\s+[ZXx]\b/m.test(readFileSync(`/proc/${pid}/status`, 'utf8'))
|
||||
process.kill(pid, 0)
|
||||
return false
|
||||
} catch (error) {
|
||||
if (['ENOENT', 'ESRCH'].includes((error as NodeJS.ErrnoException).code ?? '')) return true
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/** A stand-in login shell: `body` plays the rc files, then the dump command runs under /bin/sh. */
|
||||
function shell(name: string, body: string): string {
|
||||
const path = join(directory, name)
|
||||
writeFileSync(path, `#!/bin/sh\n[ "$1" = -ilc ] || exit 64\n${body}\nexec /bin/sh -c "$2"\n`)
|
||||
chmodSync(path, 0o755)
|
||||
return path
|
||||
}
|
||||
|
||||
describe('resolveDesktopLoginShellConfig', () => {
|
||||
it('defaults to ten seconds and accepts an override', () => {
|
||||
expect(resolveDesktopLoginShellConfig({})).toEqual({ timeoutMs: 10_000 })
|
||||
expect(resolveDesktopLoginShellConfig({ DSH_DESKTOP_LOGIN_SHELL_TIMEOUT_MS: '2500' })).toEqual({ timeoutMs: 2_500 })
|
||||
})
|
||||
|
||||
it.each(['999', '1.5', 'soon', '2147483648'])('rejects %s', (value) => {
|
||||
expect(() => resolveDesktopLoginShellConfig({ DSH_DESKTOP_LOGIN_SHELL_TIMEOUT_MS: value }))
|
||||
.toThrow('DSH_DESKTOP_LOGIN_SHELL_TIMEOUT_MS must be an integer from 1000 through 2147483647')
|
||||
})
|
||||
})
|
||||
|
||||
describe('loginShellCandidates', () => {
|
||||
it.each([
|
||||
['/opt/homebrew/bin/fish', ['/opt/homebrew/bin/fish', '/bin/zsh', '/bin/bash', '/bin/sh']],
|
||||
['/bin/bash', ['/bin/bash', '/bin/zsh', '/bin/sh']],
|
||||
['', ['/bin/zsh', '/bin/bash', '/bin/sh']],
|
||||
[null, ['/bin/zsh', '/bin/bash', '/bin/sh']],
|
||||
])('tries account shell %j before distinct system shells', (shell, candidates) => {
|
||||
account.userInfo.mockReturnValueOnce({ shell })
|
||||
expect(loginShellCandidates()).toEqual(candidates)
|
||||
})
|
||||
|
||||
it('uses only system shells when the account record is unreadable', () => {
|
||||
account.userInfo.mockImplementationOnce(() => { throw new Error('ENOENT: no such user') })
|
||||
expect(loginShellCandidates()).toEqual(['/bin/zsh', '/bin/bash', '/bin/sh'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseLoginShellOutput', () => {
|
||||
it('reads only the variables between the delimiters', () => {
|
||||
const output = `motd\0${DELIMITER}\0A=1\0MULTI=x\ny=z\0=ignored\0NOEQUALS\0\0${DELIMITER}\0trailing`
|
||||
expect(parseLoginShellOutput(output)).toEqual({ A: '1', MULTI: 'x\ny=z' })
|
||||
})
|
||||
|
||||
it('rejects output without both delimiters', () => {
|
||||
expect(parseLoginShellOutput('A=1\0')).toBeUndefined()
|
||||
expect(parseLoginShellOutput(`\0${DELIMITER}\0A=1\0`)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('mergeLoginShellEnvironment', () => {
|
||||
it('lets shell values win except probe-session and launcher-owned names', () => {
|
||||
const base = { PATH: '/usr/bin', HOME: '/home/user', DSH_DESKTOP_DSH_DIR: '/launcher', PWD: '/' }
|
||||
const merged = mergeLoginShellEnvironment(base, {
|
||||
PATH: '/login/bin:/usr/bin', XDG_CACHE_HOME: '/cache', PWD: '/home/user', OLDPWD: '/', SHLVL: '2', _: '/usr/bin/env',
|
||||
DISABLE_AUTO_UPDATE: 'true', ZSH_TMUX_AUTOSTARTED: 'true', ZSH_TMUX_AUTOSTART: 'false',
|
||||
DSH_DESKTOP_DSH_DIR: '/rc', DSH_HOME: '/rc-home', ELECTRON_RUN_AS_NODE: '1',
|
||||
})
|
||||
expect(merged).toEqual({ PATH: '/login/bin:/usr/bin', HOME: '/home/user', DSH_DESKTOP_DSH_DIR: '/launcher', PWD: '/', XDG_CACHE_HOME: '/cache' })
|
||||
expect(base.PATH).toBe('/usr/bin')
|
||||
})
|
||||
})
|
||||
|
||||
describe('readDesktopLoginShellEnvironment', () => {
|
||||
it('returns the inherited environment on Windows without running a shell', async () => {
|
||||
const base = { Path: 'C:\\Windows' }
|
||||
await expect(readDesktopLoginShellEnvironment(base, config, { platform: 'win32', shells: [shell('unused', 'exit 1')] }))
|
||||
.resolves.toEqual({ environment: base, failures: [] })
|
||||
})
|
||||
|
||||
it('records a candidate whose path cannot be passed to spawn', async () => {
|
||||
await expect(readDesktopLoginShellEnvironment({}, config, { platform: 'linux', shells: ['/bin/sh\0'] })).resolves.toEqual({
|
||||
environment: {}, failures: [{ shell: '/bin/sh\0', reason: expect.stringContaining('null bytes') as string }],
|
||||
})
|
||||
})
|
||||
|
||||
it('skips every candidate when already aborted', async () => {
|
||||
const base = { PATH: '/usr/bin' }
|
||||
const signal = AbortSignal.abort()
|
||||
await expect(readDesktopLoginShellEnvironment(base, config, { platform: 'darwin', shells: ['/a', '/b'], signal })).resolves
|
||||
.toEqual({ environment: base, failures: [{ shell: '/a', reason: 'aborted' }] })
|
||||
})
|
||||
})
|
||||
|
||||
// Stand-in shells are POSIX scripts; Windows returns before spawning.
|
||||
describe.skipIf(process.platform === 'win32')('readDesktopLoginShellEnvironment on POSIX', () => {
|
||||
it('merges variables exported by rc files, ignoring their other output', async () => {
|
||||
const login = shell('login', [
|
||||
'echo "rc banner"',
|
||||
'[ "$DISABLE_AUTO_UPDATE" = true ] && [ "$ZSH_TMUX_AUTOSTART" = false ] || exit 65',
|
||||
'read -r answer; export ANSWER="${answer:-eof}"',
|
||||
'export PATH="/login/bin:$PATH" MULTI=\'a\nb\' STARTED_IN="$PWD"',
|
||||
].join('\n'))
|
||||
const result = await readDesktopLoginShellEnvironment({ PATH: '/usr/bin:/bin', KEPT: '1' }, config, { platform: 'darwin', shells: [login] })
|
||||
expect(result.failures).toEqual([])
|
||||
expect(result.environment).toMatchObject({ PATH: '/login/bin:/usr/bin:/bin', KEPT: '1', MULTI: 'a\nb', ANSWER: 'eof' })
|
||||
expect(result.environment.STARTED_IN).toBe(homedir())
|
||||
expect(result.environment).not.toHaveProperty('DISABLE_AUTO_UPDATE')
|
||||
})
|
||||
|
||||
it('records each failing candidate and uses the next one', async () => {
|
||||
const candidates = [
|
||||
join(directory, 'missing'),
|
||||
shell('exits', 'exit 3'),
|
||||
shell('signalled', 'kill -TERM $$'),
|
||||
shell('silent', 'exit 0'),
|
||||
shell('working', 'export FROM=working'),
|
||||
]
|
||||
const result = await readDesktopLoginShellEnvironment({ PATH: '/usr/bin:/bin' }, config, { platform: 'linux', shells: candidates })
|
||||
expect(result.environment.FROM).toBe('working')
|
||||
expect(result.failures).toEqual([
|
||||
{ shell: candidates[0], reason: expect.stringContaining('ENOENT') as string },
|
||||
{ shell: candidates[1], reason: 'exit 3' },
|
||||
{ shell: candidates[2], reason: 'SIGTERM' },
|
||||
{ shell: candidates[3], reason: 'unparsed' },
|
||||
])
|
||||
})
|
||||
|
||||
it('completes at the closing delimiter while an rc-started child keeps stdout open', async () => {
|
||||
const pidFile = join(directory, 'child.pid')
|
||||
const detaching = shell('detaching', `sleep 30 & echo $! > '${pidFile}'\nexport FROM=detaching`)
|
||||
const result = await readDesktopLoginShellEnvironment({ PATH: '/usr/bin:/bin' }, config, { platform: 'darwin', shells: [detaching] })
|
||||
const child = Number(readFileSync(pidFile, 'utf8'))
|
||||
onTestFinished(() => { process.kill(child, 'SIGKILL') })
|
||||
expect(result.failures).toEqual([])
|
||||
expect(result.environment.FROM).toBe('detaching')
|
||||
expect(exited(child)).toBe(false)
|
||||
})
|
||||
|
||||
it('ends a timed-out shell with its background children and keeps the inherited environment', async () => {
|
||||
const pidFile = join(directory, 'child.pid')
|
||||
const hanging = shell('hanging', `sleep 30 & echo $! > '${pidFile}'; wait`)
|
||||
const base = { PATH: '/usr/bin:/bin' }
|
||||
const result = await readDesktopLoginShellEnvironment(base, { timeoutMs: 2_000 }, { platform: 'darwin', shells: [hanging] })
|
||||
expect(result).toEqual({ environment: base, failures: [{ shell: hanging, reason: 'timeout' }] })
|
||||
const child = Number(readFileSync(pidFile, 'utf8'))
|
||||
await vi.waitFor(() => { expect(exited(child)).toBe(true) })
|
||||
})
|
||||
|
||||
it('ends the running shell group on abort and skips the remaining candidates', async () => {
|
||||
const pidFile = join(directory, 'child.pid')
|
||||
const hanging = shell('hanging', `sleep 30 & echo $! > '${pidFile}'; wait`)
|
||||
const base = { PATH: '/usr/bin:/bin' }
|
||||
const controller = new AbortController()
|
||||
const read = readDesktopLoginShellEnvironment(base, config, {
|
||||
platform: 'linux', shells: [hanging, shell('unused', 'export FROM=unused')], signal: controller.signal,
|
||||
})
|
||||
const child = await vi.waitFor(() => {
|
||||
const pid = Number(readFileSync(pidFile, 'utf8'))
|
||||
expect(pid).toBeGreaterThan(0)
|
||||
return pid
|
||||
})
|
||||
controller.abort()
|
||||
await expect(read).resolves.toEqual({ environment: base, failures: [{ shell: hanging, reason: 'aborted' }] })
|
||||
await vi.waitFor(() => { expect(exited(child)).toBe(true) })
|
||||
})
|
||||
})
|
||||
@@ -9,7 +9,7 @@ import type { MenuItemConstructorOptions, MessageBoxOptions } from 'electron'
|
||||
import { DESKTOP_IPC, type DesktopUpdateState } from '../src/ipc.ts'
|
||||
import { MANDATORY_IPC } from '../src/mandatory-update-ipc.ts'
|
||||
import { DesktopHostFatalError, DesktopHostUncleanExitError } from '../src/host-process.ts'
|
||||
import { en } from '../src/locale.ts'
|
||||
import { en, zh } from '../src/locale.ts'
|
||||
import { DesktopUpdatePreparationError } from '../src/update-error.ts'
|
||||
import { writeCrashReport } from '../src/crash-report.ts'
|
||||
|
||||
@@ -57,6 +57,10 @@ const harness = await vi.hoisted(async () => {
|
||||
const platformDispose = vi.fn(() => platformDisposeDeferred?.promise ?? Promise.resolve())
|
||||
let platformCloseDeferred: ReturnType<typeof deferred> | undefined
|
||||
const platformCloseAndWait = vi.fn(() => platformCloseDeferred?.promise ?? Promise.resolve())
|
||||
const readLoginShell = async (base: NodeJS.ProcessEnv) => ({
|
||||
environment: { ...base, DSH_TEST_LOGIN_SHELL: 'login' }, failures: [{ shell: '/account/shell', reason: 'timeout' }],
|
||||
})
|
||||
const loginShell = vi.fn((base: NodeJS.ProcessEnv, _config: unknown, _options: { signal?: AbortSignal }) => readLoginShell(base))
|
||||
const updateCheck = vi.fn(async (_manual?: boolean): Promise<DesktopUpdateState> => updateState)
|
||||
const updateDownload = vi.fn(async (_version: string): Promise<DesktopUpdateState> => updateState)
|
||||
const updateInstall = vi.fn(async (_version: string): Promise<DesktopUpdateState> => updateState)
|
||||
@@ -189,7 +193,7 @@ const harness = await vi.hoisted(async () => {
|
||||
failWindow(error: Error) { windowFailure = error },
|
||||
windows, hosts, handlers, app, FakeWindow, FakeHost, powerMonitor, nativeTheme, analytics,
|
||||
trays, FakeTray, backgroundNotice, shellDialog,
|
||||
menu, popup, socketHeaders: vi.fn(), updateCheck, updateDownload, updateInstall,
|
||||
menu, popup, socketHeaders: vi.fn(), loginShell, readLoginShell, updateCheck, updateDownload, updateInstall,
|
||||
platformDispose,
|
||||
platformCloseAndWait,
|
||||
|
||||
@@ -247,6 +251,7 @@ const harness = await vi.hoisted(async () => {
|
||||
publishUpdate = undefined
|
||||
updateState = { phase: 'idle' }
|
||||
updateCheck.mockReset().mockImplementation(async () => updateState)
|
||||
loginShell.mockReset().mockImplementation(base => readLoginShell(base))
|
||||
updateDownload.mockReset().mockImplementation(async () => updateState)
|
||||
updateInstall.mockReset().mockImplementation(async () => updateState)
|
||||
nativeTheme.themeSource = 'system'; nativeTheme.shouldUseDarkColors = false
|
||||
@@ -307,6 +312,10 @@ vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
return encoding === undefined ? original.readFile(path) : original.readFile(path, encoding)
|
||||
}) }
|
||||
})
|
||||
vi.mock('../src/login-shell-environment.ts', async importOriginal => ({
|
||||
...await importOriginal<typeof import('../src/login-shell-environment.ts')>(),
|
||||
readDesktopLoginShellEnvironment: harness.loginShell,
|
||||
}))
|
||||
vi.mock('../src/runtime-tree.ts', () => ({ readDesktopRuntime: () => ({ release: { version: '1.0.0' } }) }))
|
||||
vi.mock('../src/paths.ts', () => ({ resolveDesktopPaths: () => ({ profile: 'desktop-test-profile' }) }))
|
||||
vi.mock('../src/project-manager.ts', () => ({
|
||||
@@ -399,6 +408,7 @@ beforeEach(() => {
|
||||
})
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
vi.spyOn(console, 'info').mockImplementation(() => {})
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
vi.stubEnv('DSH_DESKTOP_PNPM_ENTRY', 'test-pnpm')
|
||||
vi.stubEnv('DSH_DESKTOP_DSH_DIR', 'test-runtime')
|
||||
vi.stubEnv('DSH_DESKTOP_PRIMARY_RUNTIME_DIR', 'test-primary-runtime')
|
||||
@@ -835,7 +845,7 @@ describe('desktop main startup', () => {
|
||||
expect(() => handler(event, 'application', NaN, 34)).toThrow('invalid popup request')
|
||||
const application = handler(event, 'application', 48, 34)
|
||||
expect(harness.menu.buildFromTemplate.mock.lastCall![0].map(item => item.label ?? item.type)).toEqual([
|
||||
'关于 DeepSeek Harness', 'separator', '检查更新…', 'separator', '退出',
|
||||
'关于 DeepSeek Harness', 'separator', '检查更新…', '管理 dsh 命令…', 'separator', '退出',
|
||||
])
|
||||
expect(harness.popup.mock.lastCall![0]).toMatchObject({ window, x: 48, y: 34 })
|
||||
expect(harness.popup.mock.lastCall![0].callback).toBeTypeOf('function')
|
||||
@@ -872,12 +882,12 @@ describe('desktop main startup', () => {
|
||||
: ['Application', 'editMenu'])
|
||||
const application = template[0]!.submenu as MenuItemConstructorOptions[]
|
||||
expect(application.filter(item => item.visible !== false).map(describeItem)).toEqual(platform === 'darwin'
|
||||
? ['about', 'separator', en.checkUpdatesMenu, 'separator', 'hide', 'hideOthers', 'unhide', 'separator', 'quit']
|
||||
? ['about', 'separator', en.checkUpdatesMenu, en.cliCommandMenu, 'separator', 'hide', 'hideOthers', 'unhide', 'separator', 'quit']
|
||||
: ['about', 'separator', en.checkUpdatesMenu, 'separator', 'quit'])
|
||||
expect(harness.menu.setApplicationMenu).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it.each(['en-US', 'zh-CN'])('localizes macOS visibility and quit commands without changing the application name (%s)', async (locale) => {
|
||||
it.each(['en-US', 'zh-CN'])('localizes macOS application commands without changing the application name (%s)', async (locale) => {
|
||||
vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
|
||||
vi.spyOn(harness.app, 'getLocale').mockReturnValue(locale)
|
||||
vi.spyOn(harness.app, 'getPreferredSystemLanguages').mockReturnValue([locale])
|
||||
@@ -887,7 +897,9 @@ describe('desktop main startup', () => {
|
||||
await import('../src/main.ts')
|
||||
await harness.preparing.promise
|
||||
const commands = applicationMenuItems().filter(item =>
|
||||
item.role === 'hide' || item.role === 'hideOthers' || item.role === 'unhide' || item.role === 'quit')
|
||||
item.role === 'hide' || item.role === 'hideOthers' || item.role === 'unhide' || item.role === 'quit'
|
||||
|| item.label === en.checkUpdatesMenu || item.label === zh.checkUpdatesMenu
|
||||
|| item.label === en.cliCommandMenu || item.label === zh.cliCommandMenu)
|
||||
await expect(JSON.stringify(commands, null, 2) + '\n')
|
||||
.toMatchFileSnapshot(`./expected/application-menu-${locale}.json`)
|
||||
expect(harness.app.name).toBe('@deepseek-ai/dsh-desktop')
|
||||
@@ -2042,6 +2054,27 @@ describe('desktop main startup', () => {
|
||||
expect(harness.windows[0]!.urls).toEqual(['dsh-app://app/'])
|
||||
})
|
||||
|
||||
it('creates the Host only after the login-shell read and ends the read on quit', async () => {
|
||||
let finishRead!: () => void
|
||||
const read = new Promise<void>((resolve) => { finishRead = resolve })
|
||||
harness.loginShell.mockImplementation(async (base) => { await read; return harness.readLoginShell(base) })
|
||||
await import('../src/main.ts')
|
||||
await harness.preparing.promise
|
||||
const boot = invoke(DESKTOP_IPC.boot)
|
||||
harness.prepared.resolve()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(harness.hosts).toHaveLength(0)
|
||||
finishRead()
|
||||
await harness.hostStarted.promise
|
||||
expect(harness.hosts[0]!.environment?.DSH_TEST_LOGIN_SHELL).toBe('login')
|
||||
harness.hosts[0]!.ready.resolve()
|
||||
await Promise.all([boot, harness.navigated.promise])
|
||||
const { signal } = harness.loginShell.mock.calls[0]![2]
|
||||
expect(signal?.aborted).toBe(false)
|
||||
harness.app.emit('will-quit')
|
||||
expect(signal?.aborted).toBe(true)
|
||||
})
|
||||
|
||||
it('prepares recovery offscreen and starts one Host before choosing the first visible window', async () => {
|
||||
await import('../src/main.ts')
|
||||
await harness.preparing.promise
|
||||
@@ -2068,6 +2101,8 @@ describe('desktop main startup', () => {
|
||||
})
|
||||
expect(harness.hosts[0]!.environment).not.toBe(process.env)
|
||||
expect(harness.hosts[0]!.environment?.DSH_CLIENT_VERSION).toBe('1.2.3')
|
||||
expect(harness.hosts[0]!.environment?.DSH_TEST_LOGIN_SHELL).toBe('login')
|
||||
expect(console.warn).toHaveBeenCalledWith('desktop login shell: /account/shell failed (timeout)')
|
||||
expect(harness.analytics).toHaveBeenCalledExactlyOnceWith({ eventName: 'desktop_app_launch', timestamp: Date.now(), attributes: {} })
|
||||
expect(harness.hosts[0]!.start).toHaveBeenCalledTimes(1)
|
||||
expect(harness.windows).toHaveLength(1)
|
||||
|
||||
@@ -78,9 +78,10 @@ describe('desktop package-set selection', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('requires the Desktop Host entry', () => {
|
||||
it('requires both Desktop Host and public CLI entries', () => {
|
||||
const files = [
|
||||
'package/lib/index.js',
|
||||
'package/lib/cli.js',
|
||||
]
|
||||
expect(() => {
|
||||
assertDesktopHostPackageFiles(files)
|
||||
@@ -88,5 +89,6 @@ describe('desktop package-set selection', () => {
|
||||
expect(() => {
|
||||
assertDesktopHostPackageFiles(files.slice(1))
|
||||
}).toThrow(/lib\/index\.js/u)
|
||||
expect(() => { assertDesktopHostPackageFiles(files.slice(0, 1)) }).toThrow(/lib\/cli\.js/u)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createDesktopUploadPlan, type DesktopUploadPlan } from '../scripts/desktop-upload-plan.ts'
|
||||
import { loadDesktopPackageEnvironment } from '../scripts/desktop-package-environment.mjs'
|
||||
import { tagDesktopRelease } from '../scripts/desktop-release-tag.ts'
|
||||
import { uploadDesktopRelease } from '../scripts/desktop-upload-run.ts'
|
||||
import { uploadDesktopTarget } from '../scripts/upload-target.ts'
|
||||
|
||||
vi.mock('../scripts/desktop-package-environment.mjs', () => ({ loadDesktopPackageEnvironment: vi.fn() }))
|
||||
vi.mock('../scripts/desktop-upload-plan.ts', () => ({ createDesktopUploadPlan: vi.fn() }))
|
||||
vi.mock('../scripts/desktop-cos.ts', () => ({ createDesktopCos: vi.fn() }))
|
||||
vi.mock('../scripts/desktop-upload-run.ts', () => ({ uploadDesktopRelease: vi.fn() }))
|
||||
vi.mock('../scripts/desktop-release-tag.ts', async importOriginal => ({
|
||||
...await importOriginal<typeof import('../scripts/desktop-release-tag.ts')>(),
|
||||
tagDesktopRelease: vi.fn(),
|
||||
}))
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.resetAllMocks()
|
||||
})
|
||||
|
||||
describe('desktop upload command', () => {
|
||||
it.each([
|
||||
{ environment: 'production', latest: true, tagged: false },
|
||||
{ environment: 'production', latest: false, tagged: true },
|
||||
{ environment: 'test', latest: true, tagged: false },
|
||||
{ environment: 'test', latest: false, tagged: false },
|
||||
] as const)('uploads $environment latest=$latest with tagged=$tagged', async ({ environment, latest, tagged }) => {
|
||||
vi.spyOn(process.stdout, 'write').mockReturnValue(true)
|
||||
const credentials = { DOWNLOAD_TEST_COS_SECRET_ID: 'fixture-id', DOWNLOAD_TEST_COS_SECRET_KEY: 'fixture-key' }
|
||||
const plan: DesktopUploadPlan = {
|
||||
environment, target: 'win-x64', version: '1.2.3-alpha.4',
|
||||
publicUrl: 'https://desktop-updates.example.com/desktop/dsh-latest-windows-x64.exe',
|
||||
bucket: 'fixture-bucket', secretIdEnvName: 'DOWNLOAD_TEST_COS_SECRET_ID',
|
||||
secretKeyEnvName: 'DOWNLOAD_TEST_COS_SECRET_KEY', artifacts: [], commit: '0'.repeat(40), dirty: false,
|
||||
}
|
||||
vi.mocked(loadDesktopPackageEnvironment).mockReturnValue(credentials)
|
||||
vi.mocked(createDesktopUploadPlan).mockResolvedValue(plan)
|
||||
vi.mocked(uploadDesktopRelease).mockResolvedValue('fixture-records')
|
||||
vi.mocked(tagDesktopRelease).mockReturnValue({ status: 'created', tag: 'desktop-v1.2.3-alpha.4' })
|
||||
|
||||
await uploadDesktopTarget(['win-x64', ...latest ? ['--latest'] : []])
|
||||
|
||||
expect(createDesktopUploadPlan).toHaveBeenCalledWith('win-x64', { environment: credentials, latest })
|
||||
expect(uploadDesktopRelease).toHaveBeenCalledOnce()
|
||||
expect(vi.mocked(uploadDesktopRelease).mock.calls[0]![0]).toBe(plan)
|
||||
if (tagged) {
|
||||
expect(tagDesktopRelease).toHaveBeenCalledWith(expect.objectContaining({ version: plan.version, commit: plan.commit }))
|
||||
expect(vi.mocked(tagDesktopRelease).mock.invocationCallOrder[0])
|
||||
.toBeGreaterThan(vi.mocked(uploadDesktopRelease).mock.invocationCallOrder[0]!)
|
||||
}
|
||||
else {
|
||||
expect(tagDesktopRelease).not.toHaveBeenCalled()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -12,13 +12,13 @@ const launcher = fileURLToPath(new URL('../scripts/upload-with-credentials.ps1',
|
||||
const roots: string[] = []
|
||||
const quote = (value: string): string => `'${value.replaceAll("'", "''")}'`
|
||||
|
||||
async function check(mode: 'valid' | 'plaintext' | 'blank' | 'missing' | 'upload' | 'upload-failure', deployment = 'production') {
|
||||
async function check(mode: 'valid' | 'plaintext' | 'blank' | 'missing' | 'upload' | 'upload-latest' | 'upload-failure', deployment = 'production') {
|
||||
const root = await mkdtemp(join(tmpdir(), 'desktop-credentials-'))
|
||||
roots.push(root)
|
||||
const credentialPath = join(root, 'credential.clixml')
|
||||
let entry = launcher
|
||||
let uploadArguments = ''
|
||||
if (mode === 'upload' || mode === 'upload-failure') {
|
||||
if (mode === 'upload' || mode === 'upload-latest' || mode === 'upload-failure') {
|
||||
const scripts = join(root, 'apps/desktop/scripts')
|
||||
await mkdir(scripts, { recursive: true })
|
||||
entry = join(scripts, 'upload-with-credentials.ps1')
|
||||
@@ -34,12 +34,12 @@ async function check(mode: 'valid' | 'plaintext' | 'blank' | 'missing' | 'upload
|
||||
assert.equal(process.env.DSH_DESKTOP_WINDOWS_TOKEN_PIN, undefined)
|
||||
assert.equal(process.env.NODE_OPTIONS, undefined)
|
||||
assert.equal(process.argv[2], 'win-x64')
|
||||
assert.deepEqual(process.argv.slice(3), ['--credential-launcher', '--environment', 'production', '--bucket', 'fixture-bucket'])
|
||||
assert.deepEqual(process.argv.slice(3), ['--credential-launcher', '--environment', 'production', '--bucket', 'fixture-bucket'${mode === 'upload-latest' ? ", '--latest'" : ''}])
|
||||
console.log('fixture-id fixture-secret')
|
||||
console.error('private service details fixture-secret')
|
||||
process.exit(${mode === 'upload-failure' ? 17 : 0})
|
||||
`)
|
||||
uploadArguments = ' -Upload -Target win-x64 -Bucket fixture-bucket'
|
||||
uploadArguments = ` -Upload -Target win-x64 -Bucket fixture-bucket${mode === 'upload-latest' ? ' -Latest' : ''}`
|
||||
}
|
||||
const setup = mode === 'missing' ? '' : `
|
||||
[pscustomobject]@{
|
||||
@@ -134,9 +134,9 @@ describe.skipIf(process.platform !== 'win32')('Windows upload credentials', () =
|
||||
expect(result.output).not.toContain('child environment verified')
|
||||
})
|
||||
|
||||
it.each(['upload', 'upload-failure'] as const)('isolates the %s child and sanitizes both output streams', async (mode) => {
|
||||
it.each(['upload', 'upload-latest', 'upload-failure'] as const)('isolates the %s child and sanitizes both output streams', async (mode) => {
|
||||
const result = await check(mode)
|
||||
expect(result.code, result.output).toBe(mode === 'upload' ? 0 : 1)
|
||||
expect(result.code, result.output).toBe(mode === 'upload-failure' ? 1 : 0)
|
||||
expect(result.output).toContain('[REDACTED] [REDACTED]')
|
||||
expect(result.output).not.toContain('fixture-id')
|
||||
expect(result.output).not.toContain('fixture-secret')
|
||||
|
||||
@@ -102,6 +102,10 @@ vi.mock('electron', () => ({
|
||||
vi.mock('../src/tray.ts', () => ({ DesktopTray: class { relabel() {} dispose() {} } }))
|
||||
|
||||
vi.mock('../src/paths.ts', () => ({ resolveDesktopPaths: () => ({ profile: '/profile' }) }))
|
||||
vi.mock('../src/login-shell-environment.ts', async importOriginal => ({
|
||||
...await importOriginal<typeof import('../src/login-shell-environment.ts')>(),
|
||||
readDesktopLoginShellEnvironment: async (base: NodeJS.ProcessEnv) => ({ environment: base, failures: [] }),
|
||||
}))
|
||||
vi.mock('../src/project-manager.ts', () => ({ DesktopProjectManager: class {
|
||||
applyRelease = vi.fn(async () => {})
|
||||
canRecoverProfile = vi.fn(() => true)
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/** Real console events reach asynchronous JavaScript shutdown in Electron Node mode. */
|
||||
|
||||
import { execFile } from 'node:child_process'
|
||||
import { createRequire } from 'node:module'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { promisify } from 'node:util'
|
||||
import ts from 'typescript'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
|
||||
const execute = promisify(execFile)
|
||||
const require = createRequire(import.meta.url)
|
||||
let root: string | undefined
|
||||
let probe: string
|
||||
let electron: string
|
||||
let entry: string
|
||||
|
||||
describe.skipIf(process.platform !== 'win32')('Windows Electron console signals', () => {
|
||||
beforeAll(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'dsh-cli-console-'))
|
||||
electron = require('electron') as string
|
||||
const programFiles = process.env['ProgramFiles(x86)']
|
||||
if (programFiles === undefined || process.env.ComSpec === undefined) throw new Error('Windows compiler environment is unavailable')
|
||||
const vswhere = join(programFiles, 'Microsoft Visual Studio', 'Installer', 'vswhere.exe')
|
||||
const vs = (await execute(vswhere, ['-latest', '-products', '*', '-requires',
|
||||
'Microsoft.VisualStudio.Component.VC.Tools.x86.x64', '-property', 'installationPath'], { windowsHide: true })).stdout.trim()
|
||||
const quote = (value: string) => '"' + value.replaceAll('%', '%%') + '"'
|
||||
probe = join(root, 'console-probe.exe')
|
||||
const compile = join(root, 'compile.cmd')
|
||||
await writeFile(compile, ['@echo off', 'call ' + quote(join(vs, 'VC/Auxiliary/Build/vcvars64.bat')) + ' >nul',
|
||||
'if errorlevel 1 exit /b %errorlevel%',
|
||||
'cl /nologo /std:c++17 /EHsc /MT /W4 /WX ' + quote(join(import.meta.dirname, 'fixtures/cli-console-probe.cpp'))
|
||||
+ ' /Fo' + quote(join(root, 'console.obj')) + ' /Fe' + quote(probe), '',
|
||||
].join('\r\n'))
|
||||
await execute(process.env.ComSpec, ['/d', '/v:off', '/c', compile], { windowsHide: true })
|
||||
// Only TypeScript is erased; the child has no source-loader hook that could change signal handling.
|
||||
const source = await readFile(new URL('../../desktop-host/src/windows-cli-signals.ts', import.meta.url), 'utf8')
|
||||
const code = ts.transpileModule(source, {
|
||||
compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
|
||||
}).outputText
|
||||
.replace(/(['"])koffi\1/u, JSON.stringify(pathToFileURL(require.resolve('koffi')).href))
|
||||
await writeFile(join(root, 'signals.mjs'), code)
|
||||
entry = join(root, 'entry.mjs')
|
||||
await writeFile(entry, [
|
||||
"import { writeFileSync } from 'node:fs'",
|
||||
"import { writeFile } from 'node:fs/promises'",
|
||||
"import { installWindowsCliSignals } from './signals.mjs'",
|
||||
'await installWindowsCliSignals()',
|
||||
'const [marker, ready] = process.argv.slice(2)',
|
||||
"for (const [signal, code] of [['SIGINT',130],['SIGBREAK',131]]) process.on(signal, async () => {",
|
||||
' await writeFile(marker, signal); process.exit(code)',
|
||||
'})',
|
||||
"writeFileSync(ready, 'ready')",
|
||||
'setInterval(() => {}, 1000)', '',
|
||||
].join('\n'))
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
if (root === undefined) return
|
||||
await chmod(root, 0o700)
|
||||
await rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 })
|
||||
})
|
||||
|
||||
it.each([['SIGINT', '0'], ['SIGBREAK', '1']] as const)('delivers %s before process exit', async (signal, event) => {
|
||||
if (root === undefined) throw new Error('Console fixture is not prepared')
|
||||
const marker = join(root, signal + '.txt')
|
||||
const ready = join(root, signal + '.ready')
|
||||
const report = join(root, signal + '.report')
|
||||
let failure: unknown
|
||||
try { await execute(probe, [electron, entry, marker, ready, report, event], { windowsHide: true }) }
|
||||
catch (error) { failure = error }
|
||||
const observed = existsSync(report) ? await readFile(report, 'utf8') : 'Console probe exited before reporting.'
|
||||
expect(failure, observed).toBeUndefined()
|
||||
expect(await readFile(marker, 'utf8')).toBe(signal)
|
||||
})
|
||||
})
|
||||
@@ -33,6 +33,19 @@ if (clientVersion === undefined) throw new Error('desktop build: the client envi
|
||||
const clientVersionDefine = { 'process.env.DSH_CLIENT_VERSION': JSON.stringify(clientVersion) }
|
||||
|
||||
export default defineConfig([
|
||||
{
|
||||
entry: ['lib/types/command-manager-entry.js'],
|
||||
plugins: [packagedImportsPlugin({ packages: new Set(), nodeBuiltins: true })],
|
||||
outDir: 'lib',
|
||||
format: ['esm'],
|
||||
platform: 'node',
|
||||
target: 'es2024',
|
||||
fixedExtension: false,
|
||||
dts: false,
|
||||
clean: false,
|
||||
codeSplitting: false,
|
||||
deps: { alwaysBundle: ['@deepseek-ai/dsh-atomic-write'] },
|
||||
},
|
||||
{
|
||||
entry: ['lib/types/main.js'],
|
||||
plugins: [packagedImportsPlugin(mainProcessImports)],
|
||||
|
||||
@@ -366,7 +366,7 @@ describe('web e2e: agent-preset selection', () => {
|
||||
await dialog.getByRole('button', { name: 'New task default: Minimal mode' }).waitFor({ timeout: 10_000 })
|
||||
await expect.poll(() => livePreset(scaffold), { timeout: 15_000 }).toBe('minimal')
|
||||
await dialog.getByRole('button', { name: 'General', exact: true }).click()
|
||||
const developerTools = dialog.getByRole('switch', { name: 'Coding Tools' })
|
||||
const developerTools = dialog.getByRole('switch', { name: 'Show coding view' })
|
||||
await developerTools.click()
|
||||
await expect.poll(() => developerTools.getAttribute('aria-checked')).toBe('false')
|
||||
await dialog.getByRole('button', { name: 'Close' }).last().click()
|
||||
@@ -393,7 +393,7 @@ describe('web e2e: agent-preset selection', () => {
|
||||
await openSettings(page, 'en')
|
||||
const reopened = page.getByRole('dialog', { name: 'Settings' })
|
||||
await reopened.getByRole('button', { name: 'General', exact: true }).click()
|
||||
const reopenedDeveloperTools = reopened.getByRole('switch', { name: 'Coding Tools' })
|
||||
const reopenedDeveloperTools = reopened.getByRole('switch', { name: 'Show coding view' })
|
||||
await expect.poll(() => reopenedDeveloperTools.getAttribute('aria-checked')).toBe('false')
|
||||
await reopenedDeveloperTools.click()
|
||||
await expect.poll(() => reopenedDeveloperTools.getAttribute('aria-checked')).toBe('true')
|
||||
|
||||
@@ -175,8 +175,8 @@ describe('web e2e: a git workspace turn ends with its changed files', () => {
|
||||
expect(compactHeight).toBeLessThan(60)
|
||||
await openSettings(page, 'zh')
|
||||
const settings = page.getByRole('dialog', { name: '设置' })
|
||||
await settings.getByRole('switch', { name: '代码工作工具' }).click()
|
||||
await expect.poll(() => settings.getByRole('switch', { name: '代码工作工具' }).getAttribute('aria-checked')).toBe('true')
|
||||
await settings.getByRole('switch', { name: '显示代码工作视图' }).click()
|
||||
await expect.poll(() => settings.getByRole('switch', { name: '显示代码工作视图' }).getAttribute('aria-checked')).toBe('true')
|
||||
await settings.getByRole('button', { name: '关闭', exact: true }).click()
|
||||
await header.getByRole('tablist').waitFor({ state: 'visible' })
|
||||
expect(await header.evaluate(element => element.getBoundingClientRect().height)).toBeGreaterThan(compactHeight)
|
||||
|
||||
@@ -20,7 +20,7 @@ it('persists Coding Tools and limits the built-in PTC and Minimal choices in ses
|
||||
.items.map(item => item.projections?.values.agentPreset)
|
||||
await page.goto(scaffold.authenticatedUrl)
|
||||
await openSettings(page, 'en')
|
||||
const toggle = page.getByRole('switch', { name: 'Coding Tools' })
|
||||
const toggle = page.getByRole('switch', { name: 'Show coding view' })
|
||||
expect(await toggle.getAttribute('aria-checked')).toBe('true')
|
||||
expect(await readFile(join(scaffold.harnessHome, 'profiles', 'scaffold', 'cordis.patch.yml'), 'utf8')).not.toMatch(/id: ui-settings(?:\r?\n|$)/)
|
||||
await toggle.click()
|
||||
|
||||
@@ -852,8 +852,8 @@ else process.exit(1);
|
||||
].join('\n'))
|
||||
await openSettings(page, 'en')
|
||||
const settings = page.getByRole('dialog', { name: 'Settings' })
|
||||
await settings.getByRole('switch', { name: 'Coding Tools' }).click()
|
||||
await expect.poll(() => settings.getByRole('switch', { name: 'Coding Tools' }).getAttribute('aria-checked')).toBe('true')
|
||||
await settings.getByRole('switch', { name: 'Show coding view' }).click()
|
||||
await expect.poll(() => settings.getByRole('switch', { name: 'Show coding view' }).getAttribute('aria-checked')).toBe('true')
|
||||
await successShot(page, 'developer-tools-setting')
|
||||
await settings.getByRole('button', { name: 'Close', exact: true }).click()
|
||||
await expect.poll(() => iframe.getAttribute('sandbox')).toBe('allow-scripts')
|
||||
|
||||
@@ -20,8 +20,8 @@
|
||||
- button "减小字号"
|
||||
- text: px 工作步骤展示 选择希望看到多少工具调用细节
|
||||
- button "标准"
|
||||
- text: 代码工作工具 开启后显示轨迹、本轮代码差异,并提供更多 Agent 预设选项
|
||||
- switch "代码工作工具" [checked]
|
||||
- text: 显示代码工作视图 开启后,显示轨迹、本轮代码差异,可选择完整的 Agent 预设切换
|
||||
- switch "显示代码工作视图" [checked]
|
||||
- text: 快捷键
|
||||
- paragraph: 查看和编辑当前可用的快捷键和输入操作
|
||||
- button "编辑快捷键"
|
||||
|
||||
@@ -20,8 +20,8 @@
|
||||
- button "Decrease font size"
|
||||
- text: px Work details Choose how much detail to show for tool calls
|
||||
- button "Standard"
|
||||
- text: Coding Tools Shows trajectory and code diffs, and offers more agent preset options
|
||||
- switch "Coding Tools" [checked]
|
||||
- text: Show coding view Shows trajectory, code diffs, and all Agent presets
|
||||
- switch "Show coding view" [checked]
|
||||
- text: Keyboard shortcuts
|
||||
- paragraph: View and edit available shortcuts and input actions
|
||||
- button "Edit shortcuts"
|
||||
|
||||
@@ -20,8 +20,8 @@
|
||||
- button "Decrease font size"
|
||||
- text: px Work details Choose how much detail to show for tool calls
|
||||
- button "Detailed"
|
||||
- text: Coding Tools Shows trajectory and code diffs, and offers more agent preset options
|
||||
- switch "Coding Tools"
|
||||
- text: Show coding view Shows trajectory, code diffs, and all Agent presets
|
||||
- switch "Show coding view"
|
||||
- text: Keyboard shortcuts
|
||||
- paragraph: View and edit available shortcuts and input actions
|
||||
- button "Edit shortcuts"
|
||||
|
||||
@@ -20,8 +20,8 @@
|
||||
- button "Decrease font size"
|
||||
- text: px Work details Choose how much detail to show for tool calls
|
||||
- button "Detailed"
|
||||
- text: Coding Tools Shows trajectory and code diffs, and offers more agent preset options
|
||||
- switch "Coding Tools"
|
||||
- text: Show coding view Shows trajectory, code diffs, and all Agent presets
|
||||
- switch "Show coding view"
|
||||
- text: Keyboard shortcuts
|
||||
- paragraph: View and edit available shortcuts and input actions
|
||||
- button "Edit shortcuts"
|
||||
|
||||
@@ -20,8 +20,8 @@
|
||||
- button "减小字号"
|
||||
- text: px 工作步骤展示 选择希望看到多少工具调用细节
|
||||
- button "详细"
|
||||
- text: 代码工作工具 开启后显示轨迹、本轮代码差异,并提供更多 Agent 预设选项
|
||||
- switch "代码工作工具"
|
||||
- text: 显示代码工作视图 开启后,显示轨迹、本轮代码差异,可选择完整的 Agent 预设切换
|
||||
- switch "显示代码工作视图"
|
||||
- text: 快捷键
|
||||
- paragraph: 查看和编辑当前可用的快捷键和输入操作
|
||||
- button "编辑快捷键"
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
- text: {{cwd}}/keyboard-project
|
||||
- button "Reload"
|
||||
- button "Open in Finder"
|
||||
- button "More ways to open"
|
||||
- list:
|
||||
- listitem: Empty directory
|
||||
@@ -857,7 +857,7 @@ describe('web e2e: plugin manager', () => {
|
||||
await field.fill('@fixture/bundle')
|
||||
await install.click()
|
||||
await dialog.getByRole('alert').waitFor({ timeout: 5_000 })
|
||||
expect(await dialog.getByRole('alert').textContent()).toBe('该插件已安装')
|
||||
expect(await dialog.getByRole('alert').textContent()).toBe('该插件已安装。如需升级,请卸载后重新安装')
|
||||
// A path the Host cannot read as a package is refused with its reason, and the spec stays editable.
|
||||
await field.fill(join(scaffold.harnessHome, 'no-such-plugin'))
|
||||
await install.click()
|
||||
|
||||
@@ -65,39 +65,58 @@ it('shows completed paragraph first lines across blank lines with a right-edge f
|
||||
const reasoning = page.locator('[data-variant="think"][data-state="running"]')
|
||||
await expandOwningTurnProcess(page, reasoning)
|
||||
await reasoning.waitFor()
|
||||
const whale = page.locator('[data-chat-running] svg')
|
||||
const animatedWhale = whale.locator('path:has(animate)')
|
||||
const restingWhale = whale.locator('path:not(:has(animate))')
|
||||
await animatedWhale.locator('animate').waitFor({ state: 'attached' })
|
||||
const positions = await animatedWhale.evaluate(async (element) => {
|
||||
const path = element as SVGPathElement
|
||||
const svg = path.ownerSVGElement!
|
||||
const time = svg.getCurrentTime()
|
||||
svg.pauseAnimations()
|
||||
try {
|
||||
const positions: number[][] = []
|
||||
for (const at of [0.3, 0.9, 3.3]) {
|
||||
svg.setCurrentTime(at)
|
||||
await new Promise<void>(resolve => requestAnimationFrame(() => { resolve() }))
|
||||
const point = path.getPointAtLength(path.getTotalLength() * 0.4)
|
||||
positions.push([point.x, point.y])
|
||||
}
|
||||
return positions
|
||||
} finally {
|
||||
svg.setCurrentTime(time)
|
||||
svg.unpauseAnimations()
|
||||
}
|
||||
})
|
||||
expect(positions[1]).not.toEqual(positions[0])
|
||||
expect(positions[2]).toEqual(positions[0])
|
||||
expect(await animatedWhale.evaluate(element => getComputedStyle(element).display)).not.toBe('none')
|
||||
expect(await restingWhale.evaluate(element => getComputedStyle(element).display)).toBe('none')
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' })
|
||||
await expect.poll(() => animatedWhale.evaluate(element => getComputedStyle(element).display)).toBe('none')
|
||||
await expect.poll(() => restingWhale.evaluate(element => getComputedStyle(element).display)).not.toBe('none')
|
||||
await page.emulateMedia({ reducedMotion: 'no-preference' })
|
||||
await expect.poll(() => animatedWhale.evaluate(element => getComputedStyle(element).display)).not.toBe('none')
|
||||
await expect.poll(() => restingWhale.evaluate(element => getComputedStyle(element).display)).toBe('none')
|
||||
const whale = page.locator('[data-chat-running] span[aria-hidden="true"]:has(> svg)')
|
||||
const animatedWhale = whale.locator(':scope > span')
|
||||
const restingWhale = whale.locator(':scope > svg')
|
||||
await whale.waitFor()
|
||||
expect(await whale.locator('animate').count()).toBe(0)
|
||||
const expectStaticWhale = async () => {
|
||||
await expect.poll(() => animatedWhale.isVisible()).toBe(false)
|
||||
await expect.poll(() => restingWhale.isVisible()).toBe(true)
|
||||
expect(await animatedWhale.evaluate(element => getComputedStyle(element).maskImage)).toBe('none')
|
||||
const colors = await restingWhale.locator('path').evaluate((path) => {
|
||||
const probe = document.createElement('span')
|
||||
probe.style.cssText = 'color: Canvas; forced-color-adjust: none'
|
||||
document.body.append(probe)
|
||||
try {
|
||||
const style = getComputedStyle(path)
|
||||
return {
|
||||
stroke: style.stroke.toLowerCase() === 'currentcolor' ? style.color : style.stroke,
|
||||
canvas: getComputedStyle(probe).color,
|
||||
length: (path as SVGPathElement).getTotalLength(),
|
||||
}
|
||||
} finally { probe.remove() }
|
||||
})
|
||||
expect(colors.stroke).not.toBe('none')
|
||||
expect(colors.stroke).not.toBe('transparent')
|
||||
expect(colors.stroke).not.toBe(colors.canvas)
|
||||
expect(colors.length).toBeGreaterThan(0)
|
||||
}
|
||||
try {
|
||||
await page.emulateMedia({ reducedMotion: 'no-preference', forcedColors: 'none' })
|
||||
await expect.poll(() => animatedWhale.isVisible()).toBe(true)
|
||||
expect(await animatedWhale.evaluate(element => getComputedStyle(element).maskMode)).toBe('alpha')
|
||||
const imageSize = await animatedWhale.evaluate(async (element) => {
|
||||
const source = getComputedStyle(element).maskImage.match(/^url\("?(data:image\/png;base64,[A-Za-z0-9+/=]+)"?\)$/)?.[1]
|
||||
if (source === undefined) throw new Error('Running whale mask must use the bundled PNG data URL')
|
||||
const image = new Image()
|
||||
image.src = source
|
||||
await image.decode()
|
||||
return [image.naturalWidth, image.naturalHeight]
|
||||
})
|
||||
expect(imageSize).toEqual([28, 28])
|
||||
expect(await restingWhale.isVisible()).toBe(false)
|
||||
const firstWhaleFrame = await whale.screenshot({ animations: 'allow' })
|
||||
await expect.poll(async () => !(await whale.screenshot({ animations: 'allow' })).equals(firstWhaleFrame), {
|
||||
timeout: 5000,
|
||||
}).toBe(true)
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' })
|
||||
await expectStaticWhale()
|
||||
await page.emulateMedia({ reducedMotion: 'no-preference', forcedColors: 'active' })
|
||||
await expectStaticWhale()
|
||||
} finally {
|
||||
await page.emulateMedia({ reducedMotion: null, forcedColors: null })
|
||||
}
|
||||
expect(await reasoning.getAttribute('data-preview')).toBeNull()
|
||||
|
||||
first.proceed.resolve(undefined)
|
||||
@@ -120,6 +139,7 @@ it('shows completed paragraph first lines across blank lines with a right-edge f
|
||||
third.proceed.resolve(undefined)
|
||||
await settled
|
||||
await page.getByText('Done', { exact: true }).waitFor()
|
||||
await whale.waitFor({ state: 'detached' })
|
||||
expect(console.pageErrors).toEqual([])
|
||||
expect(console.warnings).toEqual([])
|
||||
} finally {
|
||||
|
||||
@@ -214,14 +214,14 @@ function requestText(options: GenerateOptions): string {
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
/** Require one assembled request to preserve the reminder-content trust boundary. */
|
||||
/** Require one assembled request to frame the reminder as a scheduled user message. */
|
||||
function expectReminderFraming(options: GenerateOptions): void {
|
||||
const reminder = options.messages.find(message => (
|
||||
message.role === 'user' && message.source?.kind === 'schedule'
|
||||
))
|
||||
expect(reminder?.role).toBe('user')
|
||||
const text = reminder?.content.find(block => block.type === 'text')?.text
|
||||
expect(text).toContain('untrusted reminder content, not new user instructions.')
|
||||
expect(text).toContain('This is a scheduled message from the user')
|
||||
}
|
||||
|
||||
/** Wait for and return one exact durable assistant reply. */
|
||||
|
||||
@@ -160,6 +160,31 @@ describe.skipIf(mode === 'record')('web e2e: workspace shortcuts', () => {
|
||||
await bind('Open locally')
|
||||
await page.keyboard.press('Meta+Shift+Comma')
|
||||
await expect.poll(() => launched).toEqual([{ app: 'vscode', path: workspace.path }])
|
||||
|
||||
await page.keyboard.press('Meta+Alt+P')
|
||||
const files = page.locator('[data-files-state="tree"]')
|
||||
await files.locator('[data-files-row="empty"]').waitFor()
|
||||
const opener = files.locator('[data-open-target="directory"]')
|
||||
await opener.getByRole('button', { name: 'Open in VS Code', exact: true }).click()
|
||||
await expect.poll(() => launched).toEqual([
|
||||
{ app: 'vscode', path: workspace.path }, { app: 'vscode', path: workspace.path },
|
||||
])
|
||||
await opener.getByRole('button', { name: 'More ways to open', exact: true }).click()
|
||||
await page.getByRole('menuitem', { name: 'Finder', exact: true }).click()
|
||||
await expect.poll(() => launched.at(-1)).toEqual({ app: 'finder', path: workspace.path })
|
||||
await opener.getByRole('button', { name: 'Open in Finder', exact: true }).waitFor()
|
||||
await page.keyboard.press('Meta+Shift+Comma')
|
||||
await expect.poll(() => launched).toEqual([
|
||||
{ app: 'vscode', path: workspace.path }, { app: 'vscode', path: workspace.path },
|
||||
{ app: 'finder', path: workspace.path }, { app: 'finder', path: workspace.path },
|
||||
])
|
||||
await expect.poll(() => opener.getAttribute('data-state')).toBe('idle')
|
||||
await compareOrRefreshGolden(join(root, 'files-open.expected.md'),
|
||||
await captureStableAria(page, '[data-files-state="tree"]', scaffold.workspaceCwd), mode)
|
||||
await opener.getByRole('button', { name: 'More ways to open', exact: true }).click()
|
||||
await page.getByRole('menuitem', { name: 'Finder (default)', exact: true }).waitFor()
|
||||
await page.keyboard.press('Escape')
|
||||
await page.getByRole('menu').waitFor({ state: 'hidden' })
|
||||
expect(console.pageErrors).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -458,11 +458,15 @@ describe('web e2e: empty-draft Cmd+Enter steers the whole queue', () => {
|
||||
await input.fill(STEER_TWO)
|
||||
await input.press('Enter')
|
||||
const dock = page.locator('[data-queue-dock]')
|
||||
// Both messages queued: the two-row dock shows a collapsed count header,
|
||||
// and Playwright text matching skips the hidden rows — expand the list,
|
||||
// then assert each row's content.
|
||||
await dock.getByText('2 queued messages').waitFor({ timeout: 10_000 })
|
||||
await dock.getByRole('button').click()
|
||||
// Both messages queued: the two-row dock is collapsed, so it renders the
|
||||
// count header and no rows. Expand it through the header, then assert each
|
||||
// row's content. Target the header by its accessible name: every visible row
|
||||
// renders its own Edit/Remove/Steer buttons, so a bare button role query also
|
||||
// matches those while a row is rendered.
|
||||
const queueHeader = dock.getByRole('button', { name: '2 queued messages' })
|
||||
await queueHeader.waitFor({ timeout: 10_000 })
|
||||
await expect.poll(() => queueHeader.getAttribute('aria-expanded'), { timeout: 10_000 }).toBe('false')
|
||||
await queueHeader.click()
|
||||
await dock.getByText(STEER_ONE, { exact: true }).waitFor({ timeout: 10_000 })
|
||||
await dock.getByText(STEER_TWO, { exact: true }).waitFor({ timeout: 10_000 })
|
||||
expect(await page.locator('[data-pending-steering]').count()).toBe(0)
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
en: 334b4f390b6e376b
|
||||
zh: ce094f24c6162be3
|
||||
/deepseek-harness-architecture/desktop-application:
|
||||
en: 8df40913a953f497
|
||||
zh: c46278a7813661ce
|
||||
en: 9f08587366c03567
|
||||
zh: bf6abf664869dd88
|
||||
/deepseek-harness-architecture/core-packages:
|
||||
en: b4a221ac2910600a
|
||||
zh: 341046d3c9a9c9cd
|
||||
|
||||
@@ -50,7 +50,7 @@ The Python SDK follows the same application architecture. Its runtime wheel pack
|
||||
|
||||
## Desktop application
|
||||
|
||||
The [Electron desktop application](../apps/desktop/README.md) carries its exact dsh production runtime in signed resources and owns the reserved `$DSH_HOME/profiles/desktop`. Shared profile helpers initialize its files, reconcile installed bundles, and resolve installation and bundle dependencies without replacing pnpm-owned packages. CLI and Desktop share product data, while executable packages, activation choices, and lockfiles remain separate. The public CLI cannot manage Desktop’s profile.
|
||||
The [Electron desktop application](../apps/desktop/README.md) ships its exact dsh production runtime in signed resources and owns the reserved `$DSH_HOME/profiles/desktop`. Shared helpers initialize profile files, reconcile bundles, and resolve installation and bundle dependencies without replacing pnpm-owned packages. Desktop and npm CLI share product data but keep packages, activation, and lockfiles separate. Desktop’s bundled CLI manages its initialized plugins.
|
||||
|
||||
Electron starts the private Desktop Host in Electron Node mode. The Host invokes the shared CLI profile runner and complete Web application. The window immediately loads packaged Web assets and waits for boot injections before activating client plugins in the same document. Web owns RPC and streams; the desktop carrier connects the local page to the authenticated Host. Node IPC carries boot injections, readiness, fatal errors, and shutdown. Desktop defaults to port `19387`; profile configuration can override it. Shell-owned UI runs plugin transactions through bundled pnpm with normal user and profile configuration.
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ Python SDK 遵循相同的应用架构。其运行时 wheel 把普通 `dsh` CLI
|
||||
|
||||
## 桌面应用
|
||||
|
||||
[Electron 桌面应用](../apps/desktop/README.zh.md)在签名资源中携带精确匹配的 dsh 生产运行时,并拥有保留的 `$DSH_HOME/profiles/desktop`。共享 profile helper 初始化其文件、协调已安装 bundle,并解析安装与 bundle 的依赖而不替换 pnpm 拥有的包。CLI 与 Desktop 共享产品数据,可执行包、启用选择与锁文件保持独立。公开 CLI 不能管理 Desktop profile。
|
||||
[Electron 桌面应用](../apps/desktop/README.zh.md)在签名资源中携带精确匹配的 dsh 生产运行时,并拥有保留的 `$DSH_HOME/profiles/desktop`。共享 helper 初始化 profile 文件、协调 bundle,并解析安装与 bundle 的依赖而不替换 pnpm 拥有的包。Desktop 与 npm CLI 共享产品数据,但包、启用选择与锁文件保持独立。Desktop 内置 CLI 管理其已初始化的插件。
|
||||
|
||||
Electron 使用 Electron Node 模式启动私有 Desktop Host。Host 调用共享 CLI profile runner 与完整 Web 应用。窗口立即加载打包 Web 资源,等待启动注入后在同一文档中激活客户端插件。Web 负责 RPC 与流;桌面载体将本地页面连接到已认证的 Host。Node IPC 承载启动注入、就绪、致命错误与关闭。Desktop 默认端口为 `19387`,profile 配置可覆盖。壳拥有的 UI 通过内置 pnpm 执行插件事务,并遵循正常用户与 profile 配置。
|
||||
|
||||
|
||||
@@ -184,7 +184,7 @@ export interface Config {
|
||||
## `@deepseek-ai/dsh-api-gateway`
|
||||
|
||||
- `inject`: `typert`
|
||||
- `source`: [`packages/api/gateway/src/index.ts:145`](../packages/api/gateway/src/index.ts)
|
||||
- `source`: [`packages/api/gateway/src/index.ts:146`](../packages/api/gateway/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Gateway transport configuration. */
|
||||
@@ -754,6 +754,8 @@ export interface ToolResultPruneConfig {
|
||||
export interface Config {
|
||||
/** Maximum synchronous VM evaluation time in milliseconds. */
|
||||
vmTimeoutMs?: number
|
||||
/** Maximum wait for a valid Client inspect response in milliseconds. */
|
||||
clientInspectTimeoutMs?: number
|
||||
}
|
||||
```
|
||||
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-cordis-host-runner -->
|
||||
@@ -3714,7 +3716,7 @@ export interface Config {
|
||||
## `@deepseek-ai/dsh-tool-pwsh-persistent`
|
||||
|
||||
- `inject`: `tools` · `terminals`
|
||||
- `source`: [`packages/shell/tool-pwsh-persistent/src/index.ts:456`](../packages/shell/tool-pwsh-persistent/src/index.ts)
|
||||
- `source`: [`packages/shell/tool-pwsh-persistent/src/index.ts:457`](../packages/shell/tool-pwsh-persistent/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Configuration for the persistent pwsh tool. */
|
||||
|
||||
Loaded 100 of 283 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user