Merge origin/master into feat/account-bonus-notices
This commit is contained in:
commit
50293e4dd4
553 files changed
+5292
-1020
No files matched your search
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.md
|
||||
2026-09-19-remote-duplex-stream.md: 0f1f6a575f6871d37822c5829fe4531261b8e95b
|
||||
2026-09-19-remote-duplex-stream.zh.md: d3982aa433aa3ce84b945bb5831cef8064472853
|
||||
2026-09-19-remote-duplex-stream.md: ea8e8cebcc8285c97ebdb70f4e37805d3ce8bfe9
|
||||
2026-09-19-remote-duplex-stream.zh.md: 2e119bf054e20d076e22758178b5e0168092be18
|
||||
@@ -276,6 +276,12 @@ Parsing rules: `item` has exactly the keys `type` and `streamId` plus an optiona
|
||||
| `item` received after `end` | Any | The stream fails with a `gateway/protocol` error frame and aborts; the socket stays open |
|
||||
| `item` / `end` with an unknown `streamId` | Any | Ignored, like `cancel`: after the Host ends a stream and deletes its id, uplink frames still in flight from the Client are normal and must not take down the other streams on the same socket |
|
||||
|
||||
### Cancellation wait lifetime
|
||||
|
||||
`cancellableStream`, `UplinkDecoder.next()`, and the Client uplink pump each create a cancellation or stop promise per read. `Promise.race` does not remove the losing input's reaction when a read completes: reusing a stream-wide pending promise retains completed races and their item results until that promise settles or becomes unreachable. Read-local promises keep retained cancellation state independent of the number of delivered items.
|
||||
|
||||
The Host downlink clears its current reject callback after each read and checks cancellation before reading again. The uplink decoder retains only pending reads: abort rejects them, while normal close resolves all of them with `done`, including overlapping reads. The Client pump drops its stop promise after each read and does not start another read after stopping. Source iterators retain their existing release ownership; closing the Host uplink still precedes returning the method iterator.
|
||||
|
||||
### Ordering guarantees
|
||||
|
||||
Frames on one socket arrive in total order: a logical stream's `open`, `item`…, `end` reach the Host in send order, and uplink items enter `uplink()` in the order of `send`. There is no cross-direction ordering guarantee between uplink and downlink; a protocol that needs request-response pairing carries its own sequence numbers.
|
||||
@@ -538,6 +544,10 @@ for await (const reply of stream) replies.push(reply) // ['> a', '> b']
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**A shared cancellation controller for all three reading loops.** Each loop already has an owner with distinct close behavior. Read-local promises bound retained state without moving those responsibilities into a new abstraction.
|
||||
|
||||
**Requiring source iterators to cancel their own `next()`.** Gateway accepts arbitrary iterables, whose pending reads need not respond to cancellation or `return()`. Gateway must still wake its own waiters independently of source cleanup.
|
||||
|
||||
**A separate `mode: 'duplex'` plus a reserved parameter `uplink: AsyncIterable<In>` alongside `signal`.** Special logic everywhere: the analyzer must recognize a second reserved parameter and its position rules, the generated signature gains a parameter, load time must validate `duplex ⟺ uplink`, and because the mux does not know the method mode, the Gateway must reject uplink frames on behalf of `'stream'` methods. Duplex is an inherent property of the transport-layer stream, not a mode of the method: the carrier already has `streamId`, `open`, and `cancel`; the uplink adds just two more frames, which any stream can receive and which are simply not sent when unused.
|
||||
|
||||
**Declaring the uplink type in a decorator option, `@Remote({ mode: 'stream', uplink: 'JobInputFrame' })`.** A string reference to a type name is not checked by the compiler and breaks on rename. The return-type alias puts both types in one compiler-visible place.
|
||||
@@ -572,6 +582,8 @@ for await (const reply of stream) replies.push(reply) // ['> a', '> b']
|
||||
|
||||
## Testing
|
||||
|
||||
Retention cases in `gateway-stream.host.spec.ts` and `gateway.client.spec.ts` count live iterator results after a full GC while the streams remain open. Lifecycle cases cover normal close waking multiple pending uplink reads, cancellation between reads, synchronous abort followed by a source exception, and termination during a Client send.
|
||||
|
||||
- `packages/api/gateway/tests/echo-stream.host.spec.ts`: the same echo method passes through the WebSocket carrier and through the in-process carrier.
|
||||
- Gateway Host: exact-key validation in frame parsing; `UplinkInbox` buffering during opening, `item` after `end`, overflow, ignored unknown `streamId`, and cleanup on socket closure; `UplinkDecoder` delivery with and without a codec and abort on decode failure; `uplink()` taken only once; the `extend({ invocation })` view and the origin of `peer`; `cancellableStream` closing the uplink before `return()`.
|
||||
- Gateway Client: the handle's `send` (JSON validation, queued before open, throws after termination), idempotent `end`, `dispose` driving the `cancel` frame and ending later reads; the pump's three exits; the pump stopping as soon as a terminal frame arrives.
|
||||
|
||||
@@ -276,6 +276,12 @@ export type RemoteStreamServerMessage =
|
||||
| `end` 之后又收到 `item` | 任意 | 该流以 `gateway/protocol` 错误帧失败并中止;socket 不关闭 |
|
||||
| 未知 `streamId` 的 `item` / `end` | 任意 | 忽略,与 `cancel` 相同:Host 结束流并删除 id 后,客户端在途的上行帧属正常现象,不能连累同一 socket 上的其他流 |
|
||||
|
||||
### 取消等待的生命周期
|
||||
|
||||
`cancellableStream`、`UplinkDecoder.next()` 和 Client 上行泵各自为单次读取创建取消或停止 Promise。读取完成后,`Promise.race` 不会移除另一输入上的 reaction:复用流级未完成 Promise 会保留已完成的竞争及其读取结果,直到该 Promise 完成或不可达。单次读取的 Promise 使取消状态的保留量不随已交付项数增长。
|
||||
|
||||
Host 下行在每次读取后清除当前拒绝回调,并在再次读取前检查取消状态。上行解码器只保留尚未完成的读取:取消时拒绝这些读取,正常关闭则以 `done` 唤醒全部读取,包括重叠的读取。Client 泵在每次读取后移除停止 Promise,停止后不再发起读取。源迭代器的释放仍由原有所有者负责;Host 仍先关闭上行,再返回方法迭代器。
|
||||
|
||||
### 顺序保证
|
||||
|
||||
同一条 socket 上的帧全序到达:一条逻辑流的 `open`、`item`…、`end` 按发送顺序到达 Host;上行项进入 `uplink()` 的顺序就是 `send` 的顺序。上行与下行之间没有跨方向顺序保证;需要请求应答配对的协议自带序号。
|
||||
@@ -538,6 +544,10 @@ for await (const reply of stream) replies.push(reply) // ['> a', '> b']
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**三处读取循环共用一个取消控制器。** 每处循环已有自己的所有者和不同的关闭行为。单次读取的 Promise 能限制保留状态,不需要把这些职责迁入新抽象。
|
||||
|
||||
**要求源迭代器自行取消 `next()`。** Gateway 接受任意 iterable,其待处理读取不一定响应取消或 `return()`。Gateway 仍需独立于源清理来唤醒自己的等待者。
|
||||
|
||||
**独立的 `mode: 'duplex'` 加一个与 `signal` 同级的保留参数 `uplink: AsyncIterable<In>`。** 每一处都是特殊逻辑:analyzer 要认第二个保留参数及其位置规则,生成签名多一个参数,加载期要校验 `duplex ⟺ uplink`,mux 不知道方法模式所以网关要替 `'stream'` 方法拒绝上行帧。双工是传输层流的固有性质,不是方法的一种模式:载体本来就有 `streamId`、`open`、`cancel`,上行只是再加两种帧,任何流都能收,不用就不发。
|
||||
|
||||
**用装饰器选项声明上行类型,`@Remote({ mode: 'stream', uplink: 'JobInputFrame' })`。** 字符串引用类型名不受编译器约束,重命名即断。返回类型别名把两个类型写在同一个编译器可见的位置。
|
||||
@@ -572,6 +582,8 @@ for await (const reply of stream) replies.push(reply) // ['> a', '> b']
|
||||
|
||||
## 测试
|
||||
|
||||
`gateway-stream.host.spec.ts` 和 `gateway.client.spec.ts` 中的保留对象用例在流保持打开时进行全量 GC,再统计存活的迭代结果。生命周期用例覆盖正常关闭唤醒多个待处理上行读取、两次读取之间的取消、源同步取消后抛错,以及 Client 发送期间流结束。
|
||||
|
||||
- `packages/api/gateway/tests/echo-stream.host.spec.ts`:同一个 echo 方法分别穿过 WebSocket 载体与进程内载体。
|
||||
- 网关 Host:帧解析的精确键校验;`UplinkInbox` 的 opening 缓冲、`end` 后 `item`、超限、未知 `streamId` 忽略、socket 关闭时的收尾;`UplinkDecoder` 有无 codec 两种交付与解码失败中止;`uplink()` 只能取一次;`extend({ invocation })` 视图与 `peer` 的来源;`cancellableStream` 先关 uplink 再 `return()`。
|
||||
- 网关 Client:句柄的 `send`(JSON 校验、open 前入队、终止后抛错)、`end` 幂等、`dispose` 驱动 `cancel` 帧并结束后续读取;泵的三种退出;终止帧到达即停泵。
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-22-tool-call-three-phases.md
|
||||
2026-09-22-tool-call-three-phases.md: e7b42a4ed9e340b73257f7053bddd5221ccc0d20
|
||||
2026-09-22-tool-call-three-phases.zh.md: 5a68575fd8e2a68199cbedf1ac180a22254b7c54
|
||||
2026-09-22-tool-call-three-phases.md: f43eaf1a685358f251a446889b6ac25f5fdf0144
|
||||
2026-09-22-tool-call-three-phases.zh.md: accbd2e966595ae7f124138147d191f97cb763ee
|
||||
@@ -18,7 +18,7 @@ The [Tool Definition](../../../../packages/client/ui-chat/src/client/conversatio
|
||||
|
||||
| Stage | Creation or update evidence | Available data and presentation |
|
||||
|---|---|---|
|
||||
| `preparing` | A live delta with a call ID and tool name | Call identity, name, and time; no argument body, and one non-expandable row. |
|
||||
| `preparing` | A live delta with a call ID and tool name | Call identity, name, and time; no complete arguments, and one non-expandable row. An opt-in Hook can read the raw argument prefix. |
|
||||
| `start` | `tool/call` | Complete arguments; enables the tool's existing call presentation. |
|
||||
| `result` | `tool/result` | The result and any complete arguments paired within the loaded window. |
|
||||
|
||||
@@ -30,7 +30,7 @@ The [Conversation Definition](../../../../packages/client/ui-conversation/src/cl
|
||||
|
||||
A named tool delta and `tool/call` are both initialization candidates. A live delta can create preparation; a historical `tool/call` creates the dispatched stage directly. Matching still reads only the current event, without Context lookup, additional roles, registries, or separate caches.
|
||||
|
||||
When stream settlement removes temporary deltas, the Assembler selects the start from the remaining Matches, refreshes predecessor indexes, and recomputes State. A published preparation node hides when no call evidence remains; a later dispatch retains the same callId. Undispatched calls do not receive synthetic execution results.
|
||||
Successful stream settlement publishes the final Assistant message while retaining transient deltas until `step/end`. Each `tool/call` updates its existing callId; the Assistant keeps its live ordering anchor until the same cleanup. Failed, interrupted, or abandoned streams retire their deltas immediately. On retirement the Assembler reselects starts and recomputes State from remaining Matches. Undispatched preparations hide without synthetic execution results.
|
||||
|
||||
### Converge on final state without replaying preparation
|
||||
|
||||
@@ -40,7 +40,7 @@ Groups include Tool nodes from preparation onward and classify and count them by
|
||||
|
||||
### Shared rows reuse their existing presentation
|
||||
|
||||
[ToolRow](../../../../packages/client/ui-tool/src/client/tool/components/ToolRow.tsx) reuses the existing icon, title, and row component across all stages. The shared row model selects the tool title and combines any generic tool-name prefix with the available argument summary. Preparation has no arguments or result, and the shared argument parser returns null without parsing JSON. ToolRow prevents preparation rows from expanding.
|
||||
[ToolRow](../../../../packages/client/ui-tool/src/client/tool/components/ToolRow.tsx) reuses the existing icon, title, and row component across all stages. The shared row model selects the tool title and combines any generic tool-name prefix with the available argument summary. Preparation has no complete arguments or result, and the shared argument parser returns null without parsing JSON. The existing slot Hook binding optionally exposes a call-scoped raw prefix; it adds no preparation registration mechanism. ToolRow prevents preparation rows from expanding.
|
||||
|
||||
Read, read_image, write/edit, search, web, todo, question, details, and the generic fallback use this path. Custom rows such as Bash, Skill, Present, and Cordis keep their own preparation branches. No automatic/custom registration declaration or second tool-classification list in ToolTree is introduced.
|
||||
|
||||
@@ -62,6 +62,6 @@ This decision replaces the [business-node assembly](2026-08-09-client-conversati
|
||||
|
||||
- A tool may appear before its arguments are complete, but preparation neither implies execution nor exposes interactions requiring arguments.
|
||||
- Multiple starts with one ID belong to one lifecycle. Independent calls must use distinct IDs; a second-start error no longer detects identity reuse.
|
||||
- Shared rows do not replace the whole row component when preparation becomes dispatch. Custom rows may still switch their internal components by stage.
|
||||
- Partial JSON parsing and `useToolCallDelta` are not provided; argument-driven early presentation is outside this implementation.
|
||||
- The shared row primitive is reused across stages. Write/edit separate preparing and dispatched components so only the former subscribes to raw arguments; custom rows may also switch internal components by stage.
|
||||
- Partial JSON parsing and `useToolCallDelta` are not provided. `useToolCallArgumentsPartial` exposes the existing Step's raw prefix without a second accumulator; write/edit use only its length for preparation progress.
|
||||
- Assembly tests separately cover live preparation, withdrawal, active-stream reconstruction, durable history, and paged convergence. Component tests cover argument-free presentation and stage changes; a recorded session covers browser preparation and reload results.
|
||||
@@ -18,7 +18,7 @@ Status: implemented
|
||||
|
||||
| 阶段 | 创建或更新依据 | 可用数据与展示 |
|
||||
|---|---|---|
|
||||
| `preparing` | 带调用 ID 和工具名的实时 delta | 调用身份、名称和时间;不提供参数正文,只显示不可展开的一行。 |
|
||||
| `preparing` | 带调用 ID 和工具名的实时 delta | 调用身份、名称和时间;不提供完整参数,只显示不可展开的一行。可选钩子可读取原始参数前缀。 |
|
||||
| `start` | `tool/call` | 完整参数;启用工具原有的调用展示。 |
|
||||
| `result` | `tool/result` | 调用结果,以及当前窗口内可配对的完整参数。 |
|
||||
|
||||
@@ -30,7 +30,7 @@ Status: implemented
|
||||
|
||||
Tool 的具名 delta 和 `tool/call` 都是创建入口。实时先收到 delta,就从 preparing 创建;历史先收到 `tool/call`,就从 start 创建。匹配函数仍然只读取当前事件,不查询 Context,不增加角色、注册表或旁路缓存。
|
||||
|
||||
流结束移除临时 delta 后,Assembler 从剩余 Match 重新选择 start,刷新前序索引并重算 State。没有剩余调用证据时,已发布的准备节点隐藏;后续正式调用仍使用相同 callId。未派发的调用不合成执行结果。
|
||||
成功流结束时发布最终 Assistant 消息,瞬态 delta 保留到 `step/end`。每个 `tool/call` 更新已有 callId;Assistant 的实时排序锚点也保留到同一次清理。失败、中断或废弃的流立即移除 delta。清理时,Assembler 从剩余 Match 重新选择 start 并重算 State;未派发的准备节点隐藏,不合成执行结果。
|
||||
|
||||
### 最终状态收敛,不重现准备过程
|
||||
|
||||
@@ -40,7 +40,7 @@ Group 从准备阶段起就聚合 Tool 节点,按工具名分类和计数。As
|
||||
|
||||
### 通用行复用现有外壳
|
||||
|
||||
[ToolRow](../../../../packages/client/ui-tool/src/client/tool/components/ToolRow.tsx) 在三个阶段复用既有图标、标题和行组件。公共行模型选择工具标题,并组合通用工具名前缀与已有参数摘要。准备阶段没有参数或结果,共享参数解析入口直接返回 null,不解析 JSON;ToolRow 禁止准备行展开。
|
||||
[ToolRow](../../../../packages/client/ui-tool/src/client/tool/components/ToolRow.tsx) 在三个阶段复用既有图标、标题和行组件。公共行模型选择工具标题,并组合通用工具名前缀与已有参数摘要。准备阶段没有完整参数或结果,共享参数解析入口直接返回 null,不解析 JSON。既有 slot 钩子绑定可选地提供当前调用的原始前缀,不增加准备态注册机制;ToolRow 禁止准备行展开。
|
||||
|
||||
read、read_image、write/edit、search、web、todo、question、details 和通用回退使用这条路径。Bash、Skill、Present、Cordis 等自定义外壳保留自己的准备态分支。不增加自动/自管注册声明,不在 ToolTree 维护另一份工具分类名单。
|
||||
|
||||
@@ -62,6 +62,6 @@ read、read_image、write/edit、search、web、todo、question、details 和通
|
||||
|
||||
- 工具可以在参数完整前出现,但准备展示不表示工具已经执行,也不暴露依赖参数的交互。
|
||||
- 同一 ID 的多个 start 被视为同一个生命周期;业务必须为独立调用提供不同 ID,不能再依赖第二个 start 报错检测身份复用。
|
||||
- 通用行不因准备/派发分支替换整行组件;自定义外壳仍可按阶段切换内部组件。
|
||||
- 不提供部分 JSON 解析或 `useToolCallDelta`,参数驱动的提前展示不属于本次实现。
|
||||
- 各阶段复用通用行基础组件。write/edit 分离准备态与派发后组件,只有前者订阅原始参数;自定义外壳也可按阶段切换内部组件。
|
||||
- 不提供部分 JSON 解析或 `useToolCallDelta`。`useToolCallArgumentsPartial` 暴露既有 Step 的原始前缀,不另建累积器;write/edit 只使用其长度展示准备进度。
|
||||
- 组装测试分别覆盖实时准备态、撤回、活跃流重建、持久历史与分页收敛;组件测试检查无参数展示和阶段切换,录制会话覆盖浏览器中的准备态与重载结果。
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write apps/cli/README.md
|
||||
README.md: 4eb113a6155433cb69f949d9e461ccfbd0142e7c
|
||||
README.zh.md: 6a0e8a38a70d2ad551b4e91619545b3def09f2ec
|
||||
README.md: 765440438175083d2dbd765592731df5ec148bcf
|
||||
README.zh.md: 08db105b160199cb9a64276f273b8b00f0d21c09
|
||||
@@ -36,6 +36,8 @@ dsh --help # the launcher's own help
|
||||
|
||||
A profile directory holds a `package.json` (out-of-tree plugin dependencies plus the profile manifest `dsh.profile` with its ordered `bundles` list) and a `cordis.patch.yml` (the user's own patch layer). `dsh-hmr`, when enabled in YAML, watches the profile manifest and both profile and home patch files, then recomposes all layers through one serialized reload. Without HMR, changes apply on restart. Edits arriving during watcher registration use the same nonfatal reload reporting as later edits. [Plugin Manager](../../packages/boot/plugin-manager/README.md) shares package operations and the profile write lock with `dsh plugin`; package updates retain disabled bundle selections. CLI package commands inherit authentication variables and terminal descriptors, including interactive build approval; service calls retain their scrubbed environment and captured diagnostics.
|
||||
|
||||
Installation and profile startup enforce declared DSH peer ranges against the same runtime version shown by `dsh --version`. Incompatible plugins require an explicitly acknowledged exact-version exemption. The [plugin manager's compatibility reference](../../packages/boot/plugin-manager/README.md#version-compatibility-and-exemptions) documents `version-exemptions`, `allow-version`, `revoke-version`, persistence, and risks.
|
||||
|
||||
The tree composes over an empty root:
|
||||
- each bundle's patch in `dsh.profile.bundles` order
|
||||
- then the profile's `cordis.patch.yml`, then the home-level `$DSH_HOME/cordis.patch.yml`
|
||||
|
||||
@@ -36,6 +36,8 @@ dsh --help # the launcher's own help
|
||||
|
||||
profile 目录包含一个 `package.json`,其中记录树外插件依赖,以及 profile manifest(元数据清单)`dsh.profile`、其中按顺序排列的 `bundles` 列表;还包含一个 `cordis.patch.yml`,其中保存用户自己的 patch 层。在 YAML 中启用的 `dsh-hmr` 监视 profile manifest、profile 与 home 级 patch 文件,再通过统一串行重载重新组合所有层。未启用 HMR 时,更改在重启后生效。监听器注册期间发生的编辑与后续编辑使用相同的非致命重载错误报告。[插件管理器](../../packages/boot/plugin-manager/README.zh.md) 与 `dsh plugin` 共享包操作和 profile 写锁;更新依赖会保留已停用的组合包选择。CLI 包操作继承认证环境和终端描述符,支持交互式构建批准;service 调用保留清理后的环境并捕获诊断。
|
||||
|
||||
安装和 profile 启动会按声明的 DSH peer 范围,检查与 `dsh --version` 显示值相同的运行时版本。不兼容插件需要用户明确确认精确版本豁免。[插件管理器的兼容性参考](../../packages/boot/plugin-manager/README.zh.md#version-compatibility-and-exemptions)说明 `version-exemptions`、`allow-version`、`revoke-version`、持久化规则与风险。
|
||||
|
||||
配置树以空根为起点,依次叠加以下配置层:
|
||||
- `dsh.profile.bundles` 中各组合包的 patch
|
||||
- profile 自身的 `cordis.patch.yml`,然后是 home 级的 `$DSH_HOME/cordis.patch.yml`
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh",
|
||||
"description": "dsh CLI: profile launch, plugin management, and configuration inspection",
|
||||
"version": "0.1.7-alpha.2",
|
||||
"version": "0.1.7-rc.1",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
+2
-14
@@ -6,29 +6,17 @@
|
||||
|
||||
/* v8 ignore file -- built-bin acceptance exercises this self-executing dispatch. */
|
||||
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { loadLayeredEnv, StartupError } from '@deepseek-ai/dsh-app-boot'
|
||||
import { getDshRuntimeVersion, loadLayeredEnv, StartupError } from '@deepseek-ai/dsh-app-boot'
|
||||
import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
|
||||
import { parseDshArgs } from './args.ts'
|
||||
import { reportStartupFailure } from './startup-diagnostics.ts'
|
||||
|
||||
// Both the source tree (apps/cli/src) and the bundled bin (apps/cli/lib) sit
|
||||
// one directory under apps/cli, so the checked-in manifest resolves with the
|
||||
// same relative hop from either artifact.
|
||||
function readVersion(): string {
|
||||
const manifest = JSON.parse(
|
||||
readFileSync(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8'),
|
||||
) as { version?: unknown }
|
||||
return typeof manifest.version === 'string' ? manifest.version : '0.0.0'
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the public dsh command-line interface.
|
||||
* @returns a promise that settles when the selected command mode finishes.
|
||||
*/
|
||||
export async function runCli(): Promise<void> {
|
||||
const version = readVersion()
|
||||
const version = getDshRuntimeVersion()
|
||||
const invocation = parseDshArgs(process.argv.slice(2), version)
|
||||
|
||||
switch (invocation.mode) {
|
||||
|
||||
+66
-6
@@ -1,23 +1,83 @@
|
||||
/** dsh plugin forwards pnpm through the shared profile package operations. */
|
||||
import { runPluginCommand } from '@deepseek-ai/dsh-plugin-manager/operations'
|
||||
/** Profile package management and explicit, exact-version compatibility approvals. */
|
||||
import { runPluginCommand, setProfileVersionExemption } from '@deepseek-ai/dsh-plugin-manager/operations'
|
||||
import { INSTALL_ANCHOR } from './profile-boot.ts'
|
||||
import { resolveProfileDir } from '@deepseek-ai/dsh-app-boot'
|
||||
import { DEFAULT_PROFILE_BUNDLES, initProfile, PROFILE_TEMPLATES, readProfileCompatibility, resolveProfileDir } 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'
|
||||
|
||||
/** 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
|
||||
if (command !== 'allow-version' && command !== 'revoke-version' && command !== 'version-exemptions') return undefined
|
||||
try {
|
||||
let packageVersion: string | undefined
|
||||
let runtimeVersion: string | undefined
|
||||
let acceptRisk = false
|
||||
const argumentsIterator = rest.values()
|
||||
for (const argument of argumentsIterator) {
|
||||
if (argument === '--accept-risk' && command === 'allow-version' && !acceptRisk) acceptRisk = true
|
||||
else if (argument === '--dsh-version' && runtimeVersion === undefined) runtimeVersion = argumentsIterator.next().value
|
||||
else if (argument.startsWith('--dsh-version=') && runtimeVersion === undefined) runtimeVersion = argument.slice('--dsh-version='.length)
|
||||
else if (!argument.startsWith('-') && packageVersion === undefined) packageVersion = argument
|
||||
else throw new Error(`unexpected argument ${JSON.stringify(argument)}`)
|
||||
}
|
||||
if (command === 'version-exemptions' && rest.length > 0) throw new Error('usage: dsh plugin version-exemptions')
|
||||
let request: { packageVersion: string; runtimeVersion: string } | undefined
|
||||
if (command !== 'version-exemptions') {
|
||||
if (packageVersion === undefined || runtimeVersion === undefined) {
|
||||
throw new Error(`usage: dsh plugin ${command} <package@version> --dsh-version <exact>${command === 'allow-version' ? ' --accept-risk' : ''}`)
|
||||
}
|
||||
request = { packageVersion, runtimeVersion }
|
||||
}
|
||||
if (command === 'allow-version') {
|
||||
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 })
|
||||
await withFileLock(join(dir, 'package.json'), async () => {
|
||||
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`)
|
||||
process.stdout.write(JSON.stringify(exemptions, undefined, 2) + '\n')
|
||||
} else {
|
||||
await setProfileVersionExemption(dir, request.packageVersion, request.runtimeVersion, command === 'allow-version', acceptRisk)
|
||||
process.stdout.write(`dsh: ${command === 'allow-version' ? 'allowed' : 'revoked'} ${request.packageVersion} for DSH ${request.runtimeVersion}\n`)
|
||||
}
|
||||
}, { waitMs: 120000 })
|
||||
return 0
|
||||
} catch (error) {
|
||||
process.stderr.write(`dsh: ${String(error)}\n`)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
/** Run package management for a profile.
|
||||
* @param profile Profile name.
|
||||
* @param args Pnpm arguments relative to the invoking directory.
|
||||
* @returns Pnpm exit code.
|
||||
* @param args DSH exemption command or pnpm arguments relative to the invoking directory.
|
||||
* @returns Zero on success; nonzero on invalid approval or package-manager failure.
|
||||
*/
|
||||
export async function runPlugin(profile: string, args: readonly string[]): Promise<number> {
|
||||
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, {
|
||||
execution: 'cli',
|
||||
outputBytes: 16384,
|
||||
lockWaitMs: 120000,
|
||||
lookupTimeoutMs: 120000,
|
||||
onOutput: (text, stream) => { process[stream].write(text) },
|
||||
})
|
||||
if (result.exitCode === 127) process.stderr.write('dsh: pnpm was not found; install pnpm and make it available on PATH.\n')
|
||||
if (result.exitCode !== 0) process.stderr.write(`dsh: pnpm failed; diagnostics: ${result.logPath}\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`)
|
||||
}
|
||||
if (result.exitCode !== 0) process.stderr.write(`dsh: plugin command failed; diagnostics: ${result.logPath}\n`)
|
||||
if (result.exitCode !== 0 && args.some(argument => /^git\+|^github:|\.git(?:#|$)/.test(argument))) {
|
||||
process.stderr.write(`dsh: git-hosted plugins build on install via their prepare script, which pnpm blocks until allowed — add the exact key pnpm printed above under allowBuilds in ${join(resolveProfileDir(profile), 'pnpm-workspace.yaml')}, then re-run\n`)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/** Published CLI acceptance for actual pnpm installation, restart checks, and explicit exemptions. */
|
||||
import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { execa } from 'execa'
|
||||
import { expect, it, onTestFinished } from 'vitest'
|
||||
|
||||
const bin = fileURLToPath(new URL('../lib/bin.js', import.meta.url))
|
||||
const version = (JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { version: string }).version
|
||||
|
||||
it('refuses incompatible installation and startup until an exact risk exemption is granted', async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'dsh-compatibility-cli-'))
|
||||
onTestFinished(() => { rmSync(home, { recursive: true, force: true }) })
|
||||
const profile = join(home, 'profiles', 'compatibility')
|
||||
const source = join(home, 'fixture-plugin')
|
||||
mkdirSync(profile, { recursive: true })
|
||||
mkdirSync(source)
|
||||
const profileFile = join(profile, 'package.json')
|
||||
const initial = { name: 'compatibility-profile', private: true, dependencies: {}, dsh: { profile: { bundles: [] } } }
|
||||
writeFileSync(profileFile, JSON.stringify(initial))
|
||||
writeFileSync(join(profile, 'pnpm-workspace.yaml'), 'packages:\n - .\nnodeLinker: hoisted\nautoInstallPeers: false\n')
|
||||
writeFileSync(join(source, 'package.json'), JSON.stringify({
|
||||
name: 'compatibility-fixture', version: '1.0.0', type: 'module',
|
||||
peerDependencies: { '@deepseek-ai/dsh': '999.0.0' }, dsh: { bundle: { patch: './cordis.patch.yml' } },
|
||||
}))
|
||||
writeFileSync(join(source, 'cordis.patch.yml'), '- insert:\n - id: compatibility-fixture\n name: ./index.mjs\n')
|
||||
writeFileSync(join(source, 'index.mjs'), 'process.stdout.write("PLUGIN_IMPORTED\\n"); export function apply() { process.stdout.write("PLUGIN_STARTED\\n") }\n')
|
||||
const run = async (args: string[]) => {
|
||||
const result = await execa(process.execPath, [bin, ...args], {
|
||||
cwd: home, env: { ...process.env, DSH_HOME: home }, extendEnv: false, reject: false, timeout: 60_000,
|
||||
})
|
||||
expect(result.timedOut).toBe(false)
|
||||
expect(result.signal).toBeUndefined()
|
||||
return result
|
||||
}
|
||||
const plugin = (args: string[]) => run(['plugin', '--profile', 'compatibility', ...args])
|
||||
const refused = await plugin(['add', source, '--offline', '--ignore-scripts'])
|
||||
expect(refused.exitCode).toBe(1)
|
||||
expect(refused.stderr).toContain('crashes or data loss')
|
||||
expect(JSON.parse(readFileSync(profileFile, 'utf8'))).toEqual(initial)
|
||||
expect(existsSync(join(profile, 'compatibility.json'))).toBe(false)
|
||||
// The named package is checked before installation, so a refused one never lands on disk.
|
||||
expect(refused.stderr).toContain('nothing was installed')
|
||||
expect(existsSync(join(profile, 'node_modules', 'compatibility-fixture'))).toBe(false)
|
||||
|
||||
// A package installed outside DSH still goes through the independent startup check.
|
||||
cpSync(source, join(profile, 'node_modules', 'compatibility-fixture'), { recursive: true })
|
||||
writeFileSync(profileFile, JSON.stringify({ ...initial,
|
||||
dependencies: { 'compatibility-fixture': `file:${source}` }, dsh: { profile: { bundles: ['compatibility-fixture'] } },
|
||||
}))
|
||||
const deniedStartup = await run(['--profile', 'compatibility'])
|
||||
expect(deniedStartup.stdout).not.toContain('PLUGIN_IMPORTED')
|
||||
expect(deniedStartup.stderr).toContain('incompatible')
|
||||
const missingConsent = await plugin(['allow-version', '[email protected]', '--dsh-version', version])
|
||||
expect(missingConsent.exitCode).toBe(1)
|
||||
const granted = await plugin(['allow-version', '[email protected]', '--dsh-version', version, '--accept-risk'])
|
||||
expect(granted.exitCode).toBe(0)
|
||||
expect(granted.stderr).toContain('break the application or corrupt data')
|
||||
// The exemption is profile metadata of its own; the dependency manifest and bundle list stay untouched.
|
||||
expect(JSON.parse(readFileSync(join(profile, 'compatibility.json'), 'utf8'))).toEqual({ '[email protected]': [version] })
|
||||
expect(JSON.parse(readFileSync(profileFile, 'utf8'))).not.toHaveProperty('dsh.profile.versionExemptions')
|
||||
expect((await plugin(['add', source, '--offline', '--ignore-scripts'])).exitCode).toBe(0)
|
||||
const admittedStartup = await run(['--profile', 'compatibility'])
|
||||
expect(admittedStartup.exitCode).toBe(0)
|
||||
expect(admittedStartup.stdout).toContain('PLUGIN_STARTED')
|
||||
expect((await plugin(['revoke-version', '[email protected]', '--dsh-version', version])).exitCode).toBe(0)
|
||||
expect((await run(['--profile', 'compatibility'])).stdout).not.toContain('PLUGIN_IMPORTED')
|
||||
})
|
||||
@@ -0,0 +1,104 @@
|
||||
/** Explicit CLI exemptions use the profile lock and never become pnpm arguments. */
|
||||
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 { 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(),
|
||||
}))
|
||||
|
||||
function fixture() {
|
||||
const home = mkdtempSync(join(tmpdir(), 'cli-version-exemptions-'))
|
||||
vi.stubEnv('DSH_HOME', home)
|
||||
const stdout = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
|
||||
const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
|
||||
onTestFinished(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllEnvs()
|
||||
vi.mocked(runPluginCommand).mockReset()
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
})
|
||||
return { dir: join(home, 'profiles', 'test'), stdout, stderr }
|
||||
}
|
||||
|
||||
it('requires explicit acknowledgement, grants only the exact pair, lists and revokes it without pnpm', async () => {
|
||||
const { dir, stdout, stderr } = fixture()
|
||||
const runtime = getDshRuntimeVersion()
|
||||
expect(await runPlugin('test', ['allow-version', '@example/[email protected]', '--dsh-version', runtime, '--accept-risk'])).toBe(0)
|
||||
expect(stderr.mock.calls.map(call => call[0]).join('')).toMatchInlineSnapshot(`
|
||||
"dsh: warning: allowing incompatible plugin versions can break the application or corrupt data. Approval applies only to the exact package and DSH versions.
|
||||
"
|
||||
`)
|
||||
expect(stderr.mock.invocationCallOrder[0]).toBeLessThan(stdout.mock.invocationCallOrder[0]!)
|
||||
expect(readProfileVersionExemptions(dir)).toEqual({ '@example/[email protected]': [runtime] })
|
||||
expect(JSON.parse(readFileSync(join(dir, 'compatibility.json'), 'utf8'))).toEqual({ '@example/[email protected]': [runtime] })
|
||||
expect(await runPlugin('test', ['version-exemptions'])).toBe(0)
|
||||
expect(stdout).toHaveBeenLastCalledWith(JSON.stringify({ '@example/[email protected]': [runtime] }, undefined, 2) + '\n')
|
||||
expect(await runPlugin('test', ['revoke-version', '@example/[email protected]', `--dsh-version=${runtime}`])).toBe(0)
|
||||
expect(readProfileVersionExemptions(dir)).toEqual({})
|
||||
expect(runPluginCommand).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['allow-version', '[email protected]', '--dsh-version', 'CURRENT'],
|
||||
['allow-version', '[email protected]', '--accept-risk'],
|
||||
['allow-version', 'plugin@^1.2.3', '--dsh-version', 'CURRENT', '--accept-risk'],
|
||||
['allow-version', '[email protected]', '--dsh-version', '999.0.0', '--accept-risk'],
|
||||
['allow-version', '[email protected]', '--dsh-version', 'CURRENT', '--accept-risk=false'],
|
||||
['allow-version', '[email protected]', '--dsh-version', 'CURRENT', '--accept-risk', '--accept-risk'],
|
||||
['revoke-version', '[email protected]'],
|
||||
['version-exemptions', 'extra'],
|
||||
])('rejects malformed or unacknowledged exemption command %j', async (...arguments_) => {
|
||||
const { dir, stderr } = fixture()
|
||||
const args = arguments_.map(value => value === 'CURRENT' ? getDshRuntimeVersion() : value)
|
||||
expect(await runPlugin('test', args)).toBe(1)
|
||||
expect(stderr).toHaveBeenCalled()
|
||||
expect(existsSync(join(dir, 'compatibility.json'))).toBe(false)
|
||||
expect(runPluginCommand).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses shipped profile defaults when listing a missing profile', async () => {
|
||||
fixture()
|
||||
expect(await runPlugin('web', ['version-exemptions'])).toBe(0)
|
||||
expect(runPluginCommand).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([0, 1])('forwards ordinary pnpm output and exit status %s', async (exitCode) => {
|
||||
const { stdout, stderr } = fixture()
|
||||
vi.mocked(runPluginCommand).mockImplementation(async (_context, _args, options) => {
|
||||
options.onOutput?.('pnpm output', 'stdout')
|
||||
return { exitCode, output: '', truncated: false, logPath: '/profile/log' }
|
||||
})
|
||||
expect(await runPlugin('test', ['list'])).toBe(exitCode)
|
||||
expect(stdout).toHaveBeenCalledWith('pnpm output')
|
||||
if (exitCode === 0) expect(stderr).not.toHaveBeenCalled()
|
||||
else expect(stderr).toHaveBeenCalledWith('dsh: plugin command failed; diagnostics: /profile/log\n')
|
||||
})
|
||||
|
||||
it('names the exact grant command for each package a compatibility check refused', async () => {
|
||||
const { stderr } = fixture()
|
||||
vi.mocked(runPluginCommand).mockResolvedValue({
|
||||
exitCode: 1, output: '', truncated: false, logPath: '/profile/log',
|
||||
incompatible: [{ name: '@example/plugin', version: '1.2.3', runtimeVersion: '0.1.0', peers: { '@deepseek-ai/dsh': '^9.0.0' } }],
|
||||
})
|
||||
expect(await runPlugin('test', ['add', '@example/plugin'])).toBe(1)
|
||||
expect(stderr.mock.calls.map(call => call[0])).toEqual([
|
||||
'dsh: to accept the risk, run: dsh plugin --profile test allow-version @example/[email protected] --dsh-version 0.1.0 --accept-risk\n',
|
||||
'dsh: plugin command failed; diagnostics: /profile/log\n',
|
||||
])
|
||||
})
|
||||
|
||||
it('forwards all other commands unchanged and retains package diagnostics', async () => {
|
||||
const { stderr } = fixture()
|
||||
vi.mocked(runPluginCommand).mockResolvedValue({ exitCode: 127, output: '', truncated: false, logPath: '/profile/log' })
|
||||
const args = ['add', 'github:example/plugin']
|
||||
expect(await runPlugin('test', args)).toBe(127)
|
||||
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')
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-desktop-host",
|
||||
"description": "Private Node-mode host process for the Electron desktop application",
|
||||
"version": "0.1.7-alpha.2",
|
||||
"version": "0.1.7-rc.1",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write apps/desktop/README.md
|
||||
README.md: 29a0c417d296a1d62661a20326b9717b6e0c050d
|
||||
README.zh.md: 2b9e08c234433797f38d3a6a1181b3a7b8a8f94e
|
||||
README.md: ef5b786a335dd86aad2ce68629ef4957e948c51a
|
||||
README.zh.md: 64d47d6cd750cfb8ffdd294994fbf61eca15c5fc
|
||||
@@ -399,7 +399,7 @@ The downloaded bytes are inert, and the installation call is recorded rather tha
|
||||
|
||||
## Low-level development overrides
|
||||
|
||||
An unpackaged Electron process uses `.desktop-build/development/project` under its application directory as its development project. `DSH_DESKTOP_PNPM_ENTRY` and `DSH_DESKTOP_DSH_DIR` select explicit runtime resources. Packaged applications ignore these variables, resolve signed resources from `process.resourcesPath`, and use the managed Desktop profile.
|
||||
An unpackaged Electron process uses `.desktop-build/development/project` under its application directory as its development project. `DSH_DESKTOP_PNPM_ENTRY` and `DSH_DESKTOP_DSH_DIR` are optional overrides with application-path defaults. `DSH_DESKTOP_PRIMARY_RUNTIME_DIR` is required for every unpackaged launch: the development launchers (`dev:desktop`, `start:desktop`, and the workspace-update qualification runner) set it to the primary-runtime directory of the target they prepared, and a launch without it fails with the fatal startup dialog. The launcher must set it because the shell cannot derive that directory from `process.arch`: the build target fixes Windows to x64 while the host may be arm64. Packaged applications ignore these variables, resolve signed resources from `process.resourcesPath`, and use the managed Desktop profile.
|
||||
|
||||
## Known limitations
|
||||
|
||||
@@ -408,6 +408,7 @@ An unpackaged Electron process uses `.desktop-build/development/project` under i
|
||||
- Release signing, notarization, update hosting, and previous-version installed-artifact qualification require the production release environment.
|
||||
- Dependency lifecycle scripts follow pnpm’s build permissions; Desktop provides no separate approval dialog.
|
||||
- The desktop shell shares sessions, settings, credentials, workspaces, and storage under `$DSH_HOME` with CLI dsh, while executable packages, plugin activation, and lockfiles remain separate.
|
||||
- Unpackaged startup on an Electron win32-arm64 host now succeeds, but the payload remains x64: the architecture check in `packages/skill/tool-workspace-dependencies/src/index.ts` compares the recorded payload architecture against the host `process.arch`, so the `load_workspace_dependencies` tool can still reject the primary runtime.
|
||||
|
||||
Sign in opens the configured platform page in the system browser. The Host owns PKCE and a temporary loopback callback, saves the credential before entering the workspace, and redirects the browser to the platform completion page. Opened and copied authorization links carry the effective Desktop theme as `theme=light` or `theme=dark`; `system` resolves at the time of the action. Cancel withdraws the local attempt even if the platform page later approves it. Settings offers Account sign-out; without a separate API key, sign-out returns to the welcome window. Packaged applications register dsh://open to show the window without passing credentials. On macOS, the development launcher prepares an ad-hoc-signed `Harness Dev.app` under `.desktop-build/development`, declares `dsh` in its Info.plist, and registers it with Launch Services. It loads the current workspace and records the selected development home, browser-data path, and debug settings for cold starts. Starting this bundle makes it the default `dsh://` handler; starting the packaged application registers the packaged handler again. The generated bundle does not contain account tokens and requires the workspace and prepared runtime to remain available.
|
||||
|
||||
|
||||
@@ -399,7 +399,7 @@ node apps/desktop/node_modules/pnpm/bin/pnpm.mjs --dir apps/desktop run test:upd
|
||||
|
||||
## 底层开发覆盖项
|
||||
|
||||
未打包的 Electron 进程使用应用目录下的 `.desktop-build/development/project` 作为开发项目。`DSH_DESKTOP_PNPM_ENTRY` 和 `DSH_DESKTOP_DSH_DIR` 用于选择明确的运行时资源。打包应用会忽略这些变量,从 `process.resourcesPath` 解析签名资源,并使用受管 Desktop profile。
|
||||
未打包的 Electron 进程使用应用目录下的 `.desktop-build/development/project` 作为开发项目。`DSH_DESKTOP_PNPM_ENTRY` 和 `DSH_DESKTOP_DSH_DIR` 是带应用路径默认值的可选覆盖项。每次未打包启动都必须设置 `DSH_DESKTOP_PRIMARY_RUNTIME_DIR`:开发启动器(`dev:desktop`、`start:desktop` 及工作区更新验证运行器)会把它设置为自己已准备目标的 primary-runtime 目录;缺少该变量的启动会以致命启动对话框失败。启动器必须设置它,因为壳无法从 `process.arch` 推导该目录:构建目标将 Windows 固定为 x64,而宿主可能是 arm64。打包应用会忽略这些变量,从 `process.resourcesPath` 解析签名资源,并使用受管 Desktop profile。
|
||||
|
||||
## 已知限制
|
||||
|
||||
@@ -408,6 +408,7 @@ node apps/desktop/node_modules/pnpm/bin/pnpm.mjs --dir apps/desktop run test:upd
|
||||
- 发布签名、公证、更新托管和跨上一版本的已安装产物验证需要生产发布环境。
|
||||
- 依赖的生命周期脚本遵循 pnpm 的构建权限;Desktop 不提供单独的审批对话框。
|
||||
- 桌面壳与 CLI dsh 共享 `$DSH_HOME` 下的会话、设置、凭据、工作区和存储,但可执行包、插件激活和锁文件彼此隔离。
|
||||
- 在 Electron win32-arm64 宿主上,未打包启动现在可以成功,但载荷仍为 x64:`packages/skill/tool-workspace-dependencies/src/index.ts` 的架构校验会把载荷记录的架构与宿主 `process.arch` 比较,因此 `load_workspace_dependencies` 工具仍可能拒绝 primary runtime。
|
||||
|
||||
登录会在系统浏览器中打开配置的平台页面。Host 负责 PKCE 和临时本机回调,在进入工作区前保存凭证,再将浏览器跳转到平台完成页。打开和复制的授权链接通过 `theme=light` 或 `theme=dark` 携带当前生效的 Desktop 主题;`system` 在执行操作时解析。即使平台页面随后批准,取消仍会撤销本地尝试。设置中的账号页面提供退出;没有独立 API Key 时,退出后返回欢迎窗。打包应用注册 dsh://open,只显示窗口而不传递凭证。macOS 开发启动器在 `.desktop-build/development` 下准备经临时签名的 `Harness Dev.app`,在 Info.plist 中声明 `dsh` 并注册到 Launch Services。它加载当前工作区,并记录选定的开发 home、浏览器数据路径和调试设置,以供冷启动使用。启动此应用会将其设为 `dsh://` 默认处理程序;启动打包应用会重新注册打包版处理程序。生成的应用包不包含账号 token,依赖工作区和已准备的运行环境继续存在。
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-desktop",
|
||||
"description": "Electron desktop shell for a bundled dsh runtime and external plugins",
|
||||
"version": "0.1.7-alpha.2",
|
||||
"version": "0.1.7-rc.1",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
@@ -36,6 +36,17 @@ export function resolveDesktopBuildTarget(
|
||||
*/
|
||||
export function desktopTargetBuildPaths(target: DesktopAutoUpdateTarget): DesktopTargetBuildPaths
|
||||
|
||||
/**
|
||||
* Return the platform and architecture of the payload one release target prepares.
|
||||
* Windows is prepared as x64 only, so this differs from the build host on an arm64 Windows machine.
|
||||
* @param target - Supported Desktop target name.
|
||||
* @returns Platform and architecture of the prepared payload.
|
||||
*/
|
||||
export function desktopTargetPlatform(target: DesktopAutoUpdateTarget): {
|
||||
readonly platform: 'darwin' | 'win32'
|
||||
readonly arch: 'arm64' | 'x64'
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the paths owned by the target selected in a packaging environment.
|
||||
* @param env - Packaging environment.
|
||||
@@ -48,3 +59,16 @@ export function resolveDesktopTargetBuildPaths(
|
||||
hostPlatform?: NodeJS.Platform,
|
||||
hostArch?: string,
|
||||
): DesktopTargetBuildPaths
|
||||
|
||||
/**
|
||||
* Resolve the primary-runtime directory an unpackaged development launch uses.
|
||||
* @param env - Packaging environment.
|
||||
* @param hostPlatform - Build-host platform used when no target override exists.
|
||||
* @param hostArch - Build-host architecture used when no target override exists.
|
||||
* @returns Primary-runtime directory prepared for the selected target.
|
||||
*/
|
||||
export function developmentRuntimeDirectory(
|
||||
env?: NodeJS.ProcessEnv,
|
||||
hostPlatform?: NodeJS.Platform,
|
||||
hostArch?: string,
|
||||
): string
|
||||
@@ -29,15 +29,19 @@ export function resolveDesktopBuildTarget(
|
||||
return /** @type {'mac-arm64' | 'mac-x64' | 'win-x64'} */ (target)
|
||||
}
|
||||
|
||||
function assertSupportedTarget(target) {
|
||||
if (!SUPPORTED_TARGETS.has(target)) {
|
||||
throw new Error(`desktop build paths: unsupported target ${String(target)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the mutable preparation and artifact directories owned by one release target.
|
||||
* @param {'mac-arm64' | 'mac-x64' | 'win-x64'} target - Supported Desktop target name.
|
||||
* @returns {{ root: string, artifacts: string, unsignedArtifacts: string, runtime: string, packageSet: string, dsh: string, dshPnpm: string, electron: string, packedDsh: string, packedVendor: string, packedLandlock: string, downloads: string }} Target paths plus the shared immutable download cache.
|
||||
*/
|
||||
export function desktopTargetBuildPaths(target) {
|
||||
if (!SUPPORTED_TARGETS.has(target)) {
|
||||
throw new Error(`desktop build paths: unsupported target ${String(target)}`)
|
||||
}
|
||||
assertSupportedTarget(target)
|
||||
const root = join(BUILD_ROOT, 'targets', target)
|
||||
const packed = join(root, 'packed')
|
||||
return {
|
||||
@@ -56,6 +60,20 @@ export function desktopTargetBuildPaths(target) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the platform and architecture of the payload one release target prepares.
|
||||
* Windows is prepared as x64 only, so this differs from the build host on an arm64 Windows machine.
|
||||
* @param {'mac-arm64' | 'mac-x64' | 'win-x64'} target - Supported Desktop target name.
|
||||
* @returns {{ platform: 'darwin' | 'win32', arch: 'arm64' | 'x64' }} Platform and architecture of the prepared payload.
|
||||
*/
|
||||
export function desktopTargetPlatform(target) {
|
||||
assertSupportedTarget(target)
|
||||
return {
|
||||
platform: /** @type {'darwin' | 'win32'} */ (target === 'win-x64' ? 'win32' : 'darwin'),
|
||||
arch: /** @type {'arm64' | 'x64'} */ (target === 'mac-arm64' ? 'arm64' : 'x64'),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the paths owned by the target selected in a packaging environment.
|
||||
* @param {NodeJS.ProcessEnv} env - Packaging environment.
|
||||
@@ -70,3 +88,20 @@ export function resolveDesktopTargetBuildPaths(
|
||||
) {
|
||||
return desktopTargetBuildPaths(resolveDesktopBuildTarget(env, hostPlatform, hostArch))
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the primary-runtime directory an unpackaged development launch uses.
|
||||
* The build target fixes Windows to x64, so the shell cannot derive this directory from
|
||||
* the architecture of the process that launched it.
|
||||
* @param {NodeJS.ProcessEnv} env - Packaging environment.
|
||||
* @param {NodeJS.Platform} hostPlatform - Build-host platform used when no target override exists.
|
||||
* @param {string} hostArch - Build-host architecture used when no target override exists.
|
||||
* @returns {string} Primary-runtime directory prepared for the selected target.
|
||||
*/
|
||||
export function developmentRuntimeDirectory(
|
||||
env = process.env,
|
||||
hostPlatform = process.platform,
|
||||
hostArch = process.arch,
|
||||
) {
|
||||
return join(resolveDesktopTargetBuildPaths(env, hostPlatform, hostArch).runtime, 'primary-runtime')
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { join, resolve } from 'node:path'
|
||||
import { parseArgs } from 'node:util'
|
||||
import { DESKTOP_HOST_PROTOCOL_VERSION } from '../src/host-protocol.ts'
|
||||
import type { DesktopRelease } from '../src/release.ts'
|
||||
import { developmentRuntimeDirectory, resolveDesktopBuildTarget } from './desktop-build-paths.mjs'
|
||||
import { prepareDevelopmentProject } from './development-project.ts'
|
||||
import { prepareDevelopmentApp } from './development-app.ts'
|
||||
import { preparePrimaryRuntime } from './prepare-primary-runtime.ts'
|
||||
@@ -67,6 +68,7 @@ async function launchElectron(): Promise<void> {
|
||||
const environment: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
DSH_HOME: home,
|
||||
DSH_DESKTOP_PRIMARY_RUNTIME_DIR: process.env.DSH_DESKTOP_PRIMARY_RUNTIME_DIR ?? developmentRuntimeDirectory(),
|
||||
DSH_DESKTOP_HOST_INSPECT_PORT: String(hostPort),
|
||||
DSH_DESKTOP_OPEN_DEVTOOLS: process.env.DSH_DESKTOP_OPEN_DEVTOOLS ?? '1',
|
||||
ELECTRON_ENABLE_LOGGING: process.env.ELECTRON_ENABLE_LOGGING ?? '1',
|
||||
@@ -115,6 +117,7 @@ async function main(): Promise<void> {
|
||||
hostDir: join(REPOSITORY_ROOT, 'apps', 'desktop-host'),
|
||||
dependencyDir: join(REPOSITORY_ROOT, 'node_modules', '.pnpm', 'node_modules'),
|
||||
release,
|
||||
target: resolveDesktopBuildTarget(),
|
||||
})
|
||||
await preparePrimaryRuntime()
|
||||
await launchElectron()
|
||||
|
||||
@@ -16,6 +16,8 @@ import { dirname, join } from 'node:path'
|
||||
import { createDevelopmentProjectMetadata } from '../src/project-manager.ts'
|
||||
import type { DesktopRelease } from '../src/release.ts'
|
||||
import { DESKTOP_RUNTIME_FILE, type DesktopRuntimeDescriptor } from '../src/runtime-tree.ts'
|
||||
import type { DesktopAutoUpdateTarget } from './desktop-auto-update-environment.mjs'
|
||||
import { desktopTargetPlatform } from './desktop-build-paths.mjs'
|
||||
|
||||
interface PackageManifest {
|
||||
readonly name?: string
|
||||
@@ -35,6 +37,8 @@ export interface DevelopmentProjectOptions {
|
||||
readonly dependencyDir: string
|
||||
/** Release identity written into the disposable project metadata. */
|
||||
readonly release: DesktopRelease
|
||||
/** Build target whose prepared payload the disposable project runs against. */
|
||||
readonly target: DesktopAutoUpdateTarget
|
||||
}
|
||||
|
||||
function readManifest(path: string): PackageManifest {
|
||||
@@ -115,7 +119,7 @@ function mirrorWorkspaceDependencies(roots: readonly string[], destinationRoot:
|
||||
|
||||
/**
|
||||
* Replace one disposable project with links to the current built workspace.
|
||||
* @param options - Project destination, CLI package, and release identity.
|
||||
* @param options - Project destination, CLI package, release identity, and build target.
|
||||
* @returns the absolute project directory supplied by the caller.
|
||||
*/
|
||||
export function prepareDevelopmentProject(options: DevelopmentProjectOptions): string {
|
||||
@@ -159,7 +163,7 @@ export function prepareDevelopmentProject(options: DevelopmentProjectOptions): s
|
||||
return typeof manifest.version === 'string' ? [{ name, version: manifest.version, path: `node_modules/${name}` }] : []
|
||||
})
|
||||
const runtime: DesktopRuntimeDescriptor = { schemaVersion: 1, release: options.release,
|
||||
platform: process.platform, arch: process.arch, sharedPackages, files: [] }
|
||||
...desktopTargetPlatform(options.target), sharedPackages, files: [] }
|
||||
writeFileSync(join(options.projectDir, DESKTOP_RUNTIME_FILE), `${JSON.stringify(runtime, undefined, 2)}\n`)
|
||||
return options.projectDir
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { fileURLToPath } from 'node:url'
|
||||
import { createDevelopmentProjectMetadata, createPluginProfile } from '../src/project-manager.ts'
|
||||
import { removeOwnedDirectory } from '../src/owned-directory.ts'
|
||||
import { DESKTOP_HOST_PROTOCOL_VERSION } from '../src/host-protocol.ts'
|
||||
import { desktopTargetPlatform, developmentRuntimeDirectory, resolveDesktopBuildTarget } from './desktop-build-paths.mjs'
|
||||
|
||||
const repo = resolve(import.meta.dirname, '../../..')
|
||||
const interactive = process.argv.includes('--interactive')
|
||||
@@ -27,10 +28,11 @@ try {
|
||||
nodeVersion: execFileSync(electron, ['-p', 'process.versions.node'],
|
||||
{ encoding: 'utf8', env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }, windowsHide: true }).trim(),
|
||||
pnpmVersion: pnpm.version, hostProtocolVersion: DESKTOP_HOST_PROTOCOL_VERSION }
|
||||
const target = resolveDesktopBuildTarget()
|
||||
createDevelopmentProjectMetadata(project, release)
|
||||
createPluginProfile(profile)
|
||||
await writeFile(join(project, 'desktop-runtime.json'), JSON.stringify({
|
||||
schemaVersion: 1, release, platform: process.platform, arch: process.arch, files: [],
|
||||
schemaVersion: 1, release, ...desktopTargetPlatform(target), files: [],
|
||||
sharedPackages: ['@deepseek-ai/dsh', '@deepseek-ai/dsh-desktop-host']
|
||||
.map(name => ({ name, version: manifest.version, path: `node_modules/${name}` })),
|
||||
}))
|
||||
@@ -43,7 +45,6 @@ try {
|
||||
for (const owner of [application, project]) {
|
||||
await symlink(join(repo, 'node_modules/.pnpm/node_modules'), join(owner, 'node_modules'), process.platform === 'win32' ? 'junction' : 'dir')
|
||||
}
|
||||
const target = `${process.platform === 'darwin' ? 'mac' : 'win'}-${process.arch}`
|
||||
const targetRoot = join(application, '.desktop-build/targets', target)
|
||||
await mkdir(targetRoot, { recursive: true })
|
||||
await symlink(join(repo, 'apps/desktop/.desktop-build/targets', target, 'runtime'), join(targetRoot, 'runtime'),
|
||||
@@ -61,7 +62,7 @@ try {
|
||||
...(interactive ? ['--interactive'] : [])], {
|
||||
cwd: root, env: { ...environment, DSH_HOME: join(root, 'home'), USERPROFILE: root, HOME: root,
|
||||
TEMP: root, TMP: root, TMPDIR: root, DSH_WORKSPACE_UPDATE_ROOT: root, DSH_WORKSPACE_UPDATE_TOKEN: randomUUID(),
|
||||
DSH_DESKTOP_OPEN_DEVTOOLS: '0' },
|
||||
DSH_DESKTOP_PRIMARY_RUNTIME_DIR: developmentRuntimeDirectory(), DSH_DESKTOP_OPEN_DEVTOOLS: '0' },
|
||||
// Hiding the GUI process suppresses its first window and can suspend renderer frame callbacks.
|
||||
stdio: 'inherit', windowsHide: false,
|
||||
})
|
||||
|
||||
@@ -140,6 +140,14 @@ function runtimeResources(): RuntimeResources {
|
||||
return { node, nodeBin, pnpm, dsh }
|
||||
}
|
||||
|
||||
function developmentPrimaryRuntime(): string {
|
||||
const directory = process.env.DSH_DESKTOP_PRIMARY_RUNTIME_DIR
|
||||
if (directory === undefined || directory === '') {
|
||||
throw new Error('dsh desktop: DSH_DESKTOP_PRIMARY_RUNTIME_DIR is required for an unpackaged launch')
|
||||
}
|
||||
return directory
|
||||
}
|
||||
|
||||
function developmentHostInspectPort(enabled: boolean): number | undefined {
|
||||
const configured = process.env.DSH_DESKTOP_HOST_INSPECT_PORT
|
||||
if (!enabled || configured === undefined || configured === '') return undefined
|
||||
@@ -290,6 +298,9 @@ async function main(): Promise<void> {
|
||||
const resources = runtimeResources()
|
||||
const paths = resolveDesktopPaths()
|
||||
const development = !app.isPackaged
|
||||
const primaryRuntime = development
|
||||
? developmentPrimaryRuntime()
|
||||
: join(process.resourcesPath, 'runtime', 'primary-runtime')
|
||||
const activeProject = paths.profile
|
||||
const manager = new DesktopProjectManager(paths, resources)
|
||||
let quitting = false
|
||||
@@ -371,8 +382,7 @@ async function main(): Promise<void> {
|
||||
const hostInspectPort = developmentHostInspectPort(development)
|
||||
const host = new DesktopHostProcess(resources.node, resources.dsh, activeProject,
|
||||
hostInspectPort, process.env, onFailure,
|
||||
development ? join(app.getAppPath(), '.desktop-build', 'targets', `${process.platform === 'darwin' ? 'mac' : 'win'}-${process.arch}`, 'runtime', 'primary-runtime')
|
||||
: join(process.resourcesPath, 'runtime', 'primary-runtime'),
|
||||
primaryRuntime,
|
||||
resources, (next) => { platformView.setSession(next) })
|
||||
return {
|
||||
start: async () => {
|
||||
|
||||
@@ -2,6 +2,8 @@ import { join, sep } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
desktopTargetBuildPaths,
|
||||
desktopTargetPlatform,
|
||||
developmentRuntimeDirectory,
|
||||
resolveDesktopBuildTarget,
|
||||
} from '../scripts/desktop-build-paths.mjs'
|
||||
|
||||
@@ -39,6 +41,22 @@ describe('desktop build paths', () => {
|
||||
expect(arm64.downloads).not.toContain(`${sep}targets${sep}`)
|
||||
})
|
||||
|
||||
it('resolves the development primary runtime from the build target rather than the host architecture', () => {
|
||||
expect(developmentRuntimeDirectory({}, 'darwin', 'arm64'))
|
||||
.toContain(join('targets', 'mac-arm64', 'runtime', 'primary-runtime'))
|
||||
expect(developmentRuntimeDirectory({}, 'darwin', 'x64'))
|
||||
.toContain(join('targets', 'mac-x64', 'runtime', 'primary-runtime'))
|
||||
expect(developmentRuntimeDirectory({}, 'win32', 'arm64'))
|
||||
.toContain(join('targets', 'win-x64', 'runtime', 'primary-runtime'))
|
||||
})
|
||||
|
||||
it('maps every target to the platform and architecture of the payload it prepares', () => {
|
||||
expect(desktopTargetPlatform('mac-arm64')).toEqual({ platform: 'darwin', arch: 'arm64' })
|
||||
expect(desktopTargetPlatform('mac-x64')).toEqual({ platform: 'darwin', arch: 'x64' })
|
||||
expect(desktopTargetPlatform('win-x64')).toEqual({ platform: 'win32', arch: 'x64' })
|
||||
expect(() => desktopTargetPlatform('linux-x64' as 'mac-x64')).toThrow(/unsupported target/u)
|
||||
})
|
||||
|
||||
it('resolves environment overrides and rejects unsupported targets', () => {
|
||||
expect(resolveDesktopBuildTarget({
|
||||
DSH_DESKTOP_TARGET_PLATFORM: 'darwin',
|
||||
|
||||
@@ -46,9 +46,10 @@ describe('desktop development project', () => {
|
||||
writeFileSync(join(host, 'lib/index.js'), '')
|
||||
writeFileSync(join(dependency, 'package.json'), JSON.stringify({ name: 'unhoisted', version: '1.2.3' }))
|
||||
symlinkSync(dependency, join(cli, 'node_modules/unhoisted'), process.platform === 'win32' ? 'junction' : 'dir')
|
||||
const project = prepareDevelopmentProject({ projectDir: join(root, 'runtime'), cliDir: cli, hostDir: host, dependencyDir: hoisted, release: release() })
|
||||
const project = prepareDevelopmentProject({ projectDir: join(root, 'runtime'), cliDir: cli, hostDir: host, dependencyDir: hoisted, release: release(), target: 'mac-arm64' })
|
||||
expect(realpathSync(join(project, 'node_modules/unhoisted'))).toBe(realpathSync(dependency))
|
||||
const descriptor = JSON.parse(readFileSync(join(project, 'desktop-runtime.json'), 'utf8')) as { sharedPackages: unknown[] }
|
||||
const descriptor = JSON.parse(readFileSync(join(project, 'desktop-runtime.json'), 'utf8')) as { platform: string; arch: string; sharedPackages: unknown[] }
|
||||
expect(descriptor).toMatchObject({ platform: 'darwin', arch: 'arm64' })
|
||||
expect(descriptor.sharedPackages).toContainEqual({ name: 'unhoisted', version: '1.2.3', path: 'node_modules/unhoisted' })
|
||||
})
|
||||
|
||||
@@ -76,6 +77,7 @@ describe('desktop development project', () => {
|
||||
hostDir: host,
|
||||
dependencyDir: dependencies,
|
||||
release: release(),
|
||||
target: 'win-x64',
|
||||
})
|
||||
expect(realpathSync(join(project, 'node_modules', '@deepseek-ai', 'dsh'))).toBe(realpathSync(cli))
|
||||
expect(realpathSync(join(project, 'node_modules', '@deepseek-ai', 'dsh-desktop-host'))).toBe(realpathSync(host))
|
||||
@@ -88,6 +90,8 @@ describe('desktop development project', () => {
|
||||
}
|
||||
expect(manifest.dependencies['@deepseek-ai/dsh']).toBe('1.2.3')
|
||||
expect(manifest.dependencies['@deepseek-ai/dsh-desktop-host']).toBe('1.2.3')
|
||||
const descriptor = JSON.parse(readFileSync(join(project, 'desktop-runtime.json'), 'utf8')) as { platform: string; arch: string }
|
||||
expect(descriptor).toMatchObject({ platform: 'win32', arch: 'x64' })
|
||||
const manager = new DesktopProjectManager(resolveDesktopPaths(join(root, 'home')), {
|
||||
dsh: project,
|
||||
})
|
||||
@@ -115,6 +119,7 @@ describe('desktop development project', () => {
|
||||
hostDir: host,
|
||||
dependencyDir: dependencies,
|
||||
release: release(),
|
||||
target: 'mac-x64',
|
||||
})).toThrow(/must be @deepseek-ai\/dsh@1\.2\.3/u)
|
||||
})
|
||||
})
|
||||
@@ -333,6 +333,7 @@ beforeEach(() => {
|
||||
vi.spyOn(console, 'info').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')
|
||||
vi.stubGlobal('process', { ...process, platform: 'win32', arch: 'x64', resourcesPath: 'desktop-test-resources' })
|
||||
vi.stubEnv('DSH_DESKTOP_HOST_INSPECT_PORT', undefined)
|
||||
vi.stubEnv('DSH_DESKTOP_DEV_PROJECT_DIR', undefined)
|
||||
@@ -1626,13 +1627,24 @@ describe('desktop main startup', () => {
|
||||
harness.prepared.resolve()
|
||||
await harness.hostStarted.promise
|
||||
const project = join(harness.app.getAppPath(), '.desktop-build', 'development', 'project')
|
||||
expect(harness.hosts[0]).toMatchObject({ node: process.execPath, runtime: project, profile: 'desktop-test-profile' })
|
||||
expect(harness.hosts[0]).toMatchObject({ node: process.execPath, runtime: project,
|
||||
primaryRuntime: 'test-primary-runtime', profile: 'desktop-test-profile' })
|
||||
expect(harness.applyRelease).toHaveBeenCalledOnce()
|
||||
harness.hosts[0]!.ready.resolve()
|
||||
await harness.navigated.promise
|
||||
expect(harness.dialog.showErrorBox).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fails an unpackaged launch that receives no primary runtime directory', async () => {
|
||||
harness.app.isPackaged = false
|
||||
vi.stubEnv('DSH_DESKTOP_PRIMARY_RUNTIME_DIR', undefined)
|
||||
await import('../src/main.ts')
|
||||
await harness.dialogShown.promise
|
||||
const options = harness.dialog.showMessageBox.mock.calls[0]![0] as MessageBoxOptions
|
||||
expect(options.detail).toContain('DSH_DESKTOP_PRIMARY_RUNTIME_DIR is required for an unpackaged launch')
|
||||
expect(harness.hosts).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('keeps startup errors in the existing window without a retry handler', async () => {
|
||||
await import('../src/main.ts')
|
||||
await harness.preparing.promise
|
||||
|
||||
@@ -108,6 +108,9 @@ describe.skipIf(!existsSync(builtHost))('built Desktop welcome flow', () => {
|
||||
nodeVersion: process.versions.node,
|
||||
hostProtocolVersion: DESKTOP_HOST_PROTOCOL_VERSION,
|
||||
},
|
||||
// This suite assembles a synthetic project on hosts that prepare no Desktop target, and
|
||||
// nothing it exercises compares the descriptor's platform/arch outside packaging.
|
||||
target: 'mac-x64',
|
||||
})
|
||||
cpSync(join(repository, 'packages/skill/skill-office/assets'), join(root, 'runtime/office-skills'), { recursive: true })
|
||||
const nodeBin = join(root, 'runtime/primary-runtime/dependencies/node/bin')
|
||||
|
||||
@@ -148,6 +148,7 @@ it('starts the Host for welcome onboarding and opens the workspace on skip witho
|
||||
vi.stubEnv('DSH_DESKTOP_NODE_BINARY', '/runtime/node')
|
||||
vi.stubEnv('DSH_DESKTOP_PNPM_ENTRY', '/runtime/pnpm')
|
||||
vi.stubEnv('DSH_DESKTOP_DSH_DIR', '/runtime/dsh')
|
||||
vi.stubEnv('DSH_DESKTOP_PRIMARY_RUNTIME_DIR', '/runtime/primary-runtime')
|
||||
vi.stubEnv('DSH_DESKTOP_HOST_INSPECT_PORT', undefined)
|
||||
vi.stubEnv('DSH_DESKTOP_OPEN_DEVTOOLS', '0')
|
||||
vi.stubEnv('DSH_DESKTOP_MANDATORY_UPDATE_CONFIG', undefined)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-web-frontend",
|
||||
"description": "Web application entry: vite build over the @deepseek-ai/dsh-client-web shell library; dist/ served by apps/cli's dsh web",
|
||||
"version": "0.1.7-alpha.2",
|
||||
"version": "0.1.7-rc.1",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
fixtureUserPrompts, launchWebScaffold, recordFixture, watchConsole,
|
||||
webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { openSettings, connectFreshWorkspaceZh, ZH_BROWSER_LOCALE } from './support.ts'
|
||||
import { openSettings, connectFreshWorkspaceZh, expandOwningTurnProcess, ZH_BROWSER_LOCALE } from './support.ts'
|
||||
|
||||
const DIR = fileURLToPath(new URL('../../../snapshots/web/changed-files-turn', import.meta.url))
|
||||
const FIXTURE = join(DIR, 'session.v3.jsonl')
|
||||
@@ -62,6 +62,7 @@ describe('web e2e: a git workspace turn ends with its changed files', () => {
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
let cwd: string
|
||||
let replayRoot: string | undefined
|
||||
let releasePreparations: (() => void) | undefined
|
||||
|
||||
beforeAll(async () => {
|
||||
let replayOverride: string | undefined
|
||||
@@ -92,6 +93,7 @@ describe('web e2e: a git workspace turn ends with its changed files', () => {
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
releasePreparations?.()
|
||||
try {
|
||||
await browser?.close()
|
||||
} finally {
|
||||
@@ -106,10 +108,45 @@ describe('web e2e: a git workspace turn ends with its changed files', () => {
|
||||
it('records the edited, created, and shell-appended files with their line counts', async () => {
|
||||
if (MODE !== 'record') expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT])
|
||||
const settled = scaffold.whenTurnSettled()
|
||||
const preparations = ['edit', 'write'].map(name => ({
|
||||
name, ready: Promise.withResolvers<{ callId: string; kilobytes: number }>(),
|
||||
release: Promise.withResolvers<undefined>(), held: false,
|
||||
}))
|
||||
const names = new Map<string, string>()
|
||||
const dispose = scaffold.ctx.on('llm/stream', async function* (_options, next) {
|
||||
for await (const chunk of next()) {
|
||||
yield chunk
|
||||
if (chunk.type !== 'tool-call-delta') continue
|
||||
if (chunk.name !== undefined) names.set(chunk.id, chunk.name)
|
||||
const preparation = preparations.find(value => value.name === names.get(chunk.id))
|
||||
if (preparation === undefined || preparation.held || chunk.argumentsDelta.length === 0) continue
|
||||
preparation.held = true
|
||||
preparation.ready.resolve({ callId: chunk.id, kilobytes: Math.ceil(chunk.argumentsDelta.length / 1024) })
|
||||
await preparation.release.promise
|
||||
}
|
||||
}, { prepend: true })
|
||||
releasePreparations = () => {
|
||||
for (const preparation of preparations) preparation.release.resolve(undefined)
|
||||
dispose()
|
||||
}
|
||||
const input = page.locator('[data-composer-input]').first()
|
||||
await input.fill(PROMPT)
|
||||
await input.press('Enter')
|
||||
const sessionId = await settled
|
||||
const observations = preparations.map(async (preparation) => {
|
||||
const { callId, kilobytes } = await Promise.race([
|
||||
preparation.ready.promise,
|
||||
settled.then(() => { throw new Error(`No ${preparation.name} argument prefix was streamed`) }),
|
||||
])
|
||||
const row = page.locator(`[data-chat-call-id="${callId}"] [data-state="preparing"]`)
|
||||
await row.waitFor({ state: 'attached' })
|
||||
await expandOwningTurnProcess(page, row)
|
||||
await row.getByText(`正在准备内容 ${kilobytes}KB`, { exact: true }).waitFor()
|
||||
expect(await row.getByRole('button').count()).toBe(0)
|
||||
expect(await row.locator('pre').count()).toBe(0)
|
||||
await compareOrRefreshGolden(join(DIR, `preparing-${preparation.name}.expected.md`), await row.ariaSnapshot(), MODE)
|
||||
preparation.release.resolve(undefined)
|
||||
})
|
||||
const [sessionId] = await Promise.all([settled, ...observations]).finally(() => { releasePreparations?.() })
|
||||
const session = scaffold.ctx.agents.get(sessionId)?.session
|
||||
if (session?.header.cwd === undefined) throw new Error('changed-files Session has no workspace')
|
||||
cwd = session.header.cwd
|
||||
|
||||
@@ -77,6 +77,18 @@ async function canvasColor(canvas: Locator): Promise<string> {
|
||||
})
|
||||
}
|
||||
|
||||
/** Wait for device resolution, subject to the page bitmap allocation limit. */
|
||||
async function expectPdfResolution(canvas: Locator): Promise<void> {
|
||||
await expect.poll(() => canvas.evaluate((node) => {
|
||||
const bitmap = node as HTMLCanvasElement
|
||||
const width = Number.parseFloat(bitmap.style.getPropertyValue('--pdf-page-width'))
|
||||
const height = Number.parseFloat(bitmap.style.getPropertyValue('--pdf-page-height'))
|
||||
const expected = Math.min(bitmap.getBoundingClientRect().width * window.devicePixelRatio,
|
||||
Math.sqrt(16_777_216 * width / height))
|
||||
return Math.abs(bitmap.width - expected)
|
||||
})).toBeLessThanOrEqual(1)
|
||||
}
|
||||
|
||||
/** Select a workspace file through the Files tab and wait for its preview identity. */
|
||||
async function openPreviewFile(column: Locator, filesTab: Locator, preview: Locator, name: string): Promise<void> {
|
||||
await filesTab.click()
|
||||
@@ -238,6 +250,16 @@ it.skipIf(MODE === 'record').each(['en-US', 'zh-CN'])('fills the spreadsheet pan
|
||||
expect(await selection.innerText()).toBe('A1')
|
||||
await expect.poll(() => excel.locator('.fortune-sheettab-scroll').count()).toBe(width === 360 ? 2 : 0)
|
||||
}
|
||||
const gridOffset = await excel.locator('.luckysheet-scrollbar-x').evaluate(node => node.scrollLeft)
|
||||
await tabScroller.hover()
|
||||
await page.mouse.wheel(180, 0)
|
||||
await expect.poll(() => tabScroller.evaluate(node => node.scrollLeft)).toBeGreaterThan(0)
|
||||
await page.mouse.wheel(-1000, 0)
|
||||
await expect.poll(() => tabScroller.evaluate(node => node.scrollLeft)).toBe(0)
|
||||
expect(await activeSheet.innerText()).toBe('会议信息')
|
||||
expect(await selection.innerText()).toBe('A1')
|
||||
expect(await zoom.innerText()).toBe('100%')
|
||||
expect(await excel.locator('.luckysheet-scrollbar-x').evaluate(node => node.scrollLeft)).toBe(gridOffset)
|
||||
for (const direction of ['right', 'left'] as const) {
|
||||
const arrow = excel.locator(`#fortune-sheettab-${direction}scroll`)
|
||||
for (let step = 0; step < 8; step += 1) {
|
||||
@@ -277,6 +299,9 @@ it.skipIf(MODE === 'record').each(['en-US', 'zh-CN'])('fills the spreadsheet pan
|
||||
await openPreviewFile(column, filesTab, preview, name)
|
||||
await excel.locator('.fortune-sheet-overlay').waitFor()
|
||||
await expectExcelLayout(excel)
|
||||
expect(await selection.evaluate(node => ({
|
||||
text: getComputedStyle(node).color, background: getComputedStyle(node).backgroundColor,
|
||||
}))).toEqual({ text: 'rgb(0, 0, 0)', background: 'rgb(255, 255, 255)' })
|
||||
if (name === 'values.csv') {
|
||||
await excel.locator('.fortune-sheet-overlay').click({ position: { x: 70, y: 30 } })
|
||||
await expect.poll(() => formula.textContent()).toBe(longCellText)
|
||||
@@ -306,6 +331,38 @@ it.skipIf(MODE === 'record').each(['en-US', 'zh-CN'])('fills the spreadsheet pan
|
||||
}
|
||||
})
|
||||
|
||||
/** Pan with native pixel wheel input in every direction and along the sheet edges. */
|
||||
async function expectExcelPanning(page: Page, excel: Locator): Promise<void> {
|
||||
// Chromium's CDP wheel distances scale with the emulated device pixel ratio.
|
||||
const scale = await page.evaluate(() => window.devicePixelRatio)
|
||||
const wheel = async (x: number, y: number) => { await page.mouse.wheel(x * scale, y * scale) }
|
||||
const offset = async () => await excel.evaluate(node => ({
|
||||
x: node.querySelector('.luckysheet-scrollbar-x')!.scrollLeft,
|
||||
y: node.querySelector('.luckysheet-scrollbar-y')!.scrollTop,
|
||||
}))
|
||||
const selection = await excel.locator('.fortune-name-box').innerText()
|
||||
await excel.evaluate((node) => {
|
||||
node.querySelector('.luckysheet-scrollbar-x')!.scrollLeft = 240
|
||||
node.querySelector('.luckysheet-scrollbar-y')!.scrollTop = 240
|
||||
})
|
||||
await expect.poll(offset).toEqual({ x: 240, y: 240 })
|
||||
await excel.locator('.fortune-sheet-overlay').hover({ position: { x: 260, y: 160 } })
|
||||
for (const [dx, dy] of [[37, 19], [-13, 27], [-19, -11], [23, -17], [11, 0], [0, 13], [-9, 0], [0, -7]] as const) {
|
||||
const before = await offset()
|
||||
await wheel(dx, dy)
|
||||
await expect.poll(offset).toEqual({ x: before.x + dx, y: before.y + dy })
|
||||
}
|
||||
expect(await excel.locator('.fortune-name-box').innerText()).toBe(selection)
|
||||
await wheel(-10000, -10000)
|
||||
await expect.poll(offset).toEqual({ x: 0, y: 0 })
|
||||
await wheel(-20, 20)
|
||||
await expect.poll(offset).toEqual({ x: 0, y: 20 })
|
||||
await wheel(20, -20)
|
||||
await expect.poll(offset).toEqual({ x: 20, y: 0 })
|
||||
await wheel(-20, 0)
|
||||
await expect.poll(offset).toEqual({ x: 0, y: 0 })
|
||||
}
|
||||
|
||||
/** Sample the pixel band before the divider center, excluding the adjacent cell grid line. */
|
||||
async function freezeDividerInk(excel: Locator, axis: 'x' | 'y'): Promise<number> {
|
||||
return await excel.evaluate((node, direction) => {
|
||||
@@ -374,6 +431,7 @@ it.skipIf(MODE === 'record').each([1, 2])('keeps frozen headings without divider
|
||||
await expect.poll(() => freezeDividerInk(excel, axis)).toBe(0)
|
||||
}
|
||||
}
|
||||
await expectExcelPanning(page, excel)
|
||||
await successShot(page, `excel-freeze-${sheet}-${ratio}-${deviceScaleFactor}`)
|
||||
}
|
||||
await excel.locator('.luckysheet-scrollbar-x').evaluate((node) => { node.scrollLeft = 240 })
|
||||
@@ -415,8 +473,15 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', ()
|
||||
let outsideRoot: string | undefined
|
||||
let nativeRoot: string | undefined
|
||||
let openLog = ''
|
||||
let launchLog = ''
|
||||
let appsCatalog = ''
|
||||
let linuxMimeDefault = ''
|
||||
let launchTarget = ''
|
||||
const opened = async (): Promise<Array<{ path: string; action: 'open' | 'reveal' | 'application' }>> =>
|
||||
(await readFile(openLog, 'utf8')).split('\n').filter(Boolean).map(line => JSON.parse(line) as { path: string; action: 'open' | 'reveal' | 'application' })
|
||||
/** The application each `open -a` gesture named; the opened log keeps only the file path. */
|
||||
const launched = async (): Promise<Array<{ app: string; path: string }>> =>
|
||||
(await readFile(launchLog, 'utf8')).split('\n').filter(Boolean).map(line => JSON.parse(line) as { app: string; path: string })
|
||||
|
||||
beforeAll(async () => {
|
||||
outsideRoot = await mkdtemp(join(tmpdir(), 'dsh-preview-outside-'))
|
||||
@@ -424,35 +489,49 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', ()
|
||||
// Exercise the built Host through its actual OS command, replacing only the desktop application.
|
||||
nativeRoot = await mkdtemp(join(tmpdir(), 'dsh-preview-native-'))
|
||||
openLog = join(nativeRoot, 'opened.jsonl')
|
||||
launchLog = join(nativeRoot, 'launched.jsonl')
|
||||
appsCatalog = join(nativeRoot, 'applications.json')
|
||||
await writeFile(openLog, '')
|
||||
await writeFile(launchLog, '')
|
||||
const command = process.platform === 'darwin' ? 'open' : 'xdg-open'
|
||||
await writeFile(join(nativeRoot, command), `#!/usr/bin/env node
|
||||
const fs = require('node:fs');
|
||||
const path = process.argv[2] === '-a' ? process.argv[4] : process.argv[2] === '-R' ? process.argv[3] : process.argv[2];
|
||||
const action = process.argv[2] === '-a' ? 'application' : process.argv[2] === '-R' || fs.statSync(path).isDirectory() ? 'reveal' : 'open';
|
||||
fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action }) + '\\n');
|
||||
if (process.argv[2] === '-a') fs.appendFileSync(${JSON.stringify(launchLog)}, JSON.stringify({ app: process.argv[3], path: process.argv[4] }) + '\\n');
|
||||
`, { mode: 0o700 })
|
||||
if (process.platform === 'darwin') {
|
||||
const apps = [
|
||||
await writeFile(appsCatalog, JSON.stringify([
|
||||
{ id: '/Applications/Test Player.app', name: 'Test Player', default: true, icon: `data:image/png;base64,${TINY_PNG.toString('base64')}` },
|
||||
{ id: '/Applications/Other Player.app', name: 'Other Player', default: false, icon: null },
|
||||
]
|
||||
await writeFile(join(nativeRoot, 'osascript'), `#!/usr/bin/env node\nprocess.stdout.write(${JSON.stringify(JSON.stringify(apps))});\n`, { mode: 0o700 })
|
||||
]))
|
||||
await writeFile(join(nativeRoot, 'osascript'), `#!/usr/bin/env node\nprocess.stdout.write(require('node:fs').readFileSync(${JSON.stringify(appsCatalog)}, 'utf8'));\n`, { mode: 0o700 })
|
||||
launchTarget = '/Applications/Test Player.app'
|
||||
}
|
||||
if (process.platform === 'linux') {
|
||||
const data = join(nativeRoot, 'data')
|
||||
await mkdir(join(data, 'applications'), { recursive: true })
|
||||
const icon = join(nativeRoot, 'icon.png')
|
||||
await writeFile(icon, TINY_PNG)
|
||||
await writeFile(join(data, 'applications', 'test.desktop'), `[Desktop Entry]\nName=Test Player\nIcon=${icon}\n`)
|
||||
const testDesktop = join(data, 'applications', 'test.desktop')
|
||||
await writeFile(testDesktop, `[Desktop Entry]\nName=Test Player\nIcon=${icon}\n`)
|
||||
await writeFile(join(data, 'applications', 'other.desktop'), '[Desktop Entry]\nName=Other Player\n')
|
||||
// `gio mime` owns the OS default here, so the marker file is what flips a run to "no default".
|
||||
linuxMimeDefault = join(nativeRoot, 'mime-default')
|
||||
await writeFile(linuxMimeDefault, 'test.desktop\n')
|
||||
await writeFile(join(nativeRoot, 'gio'), `#!/usr/bin/env node
|
||||
const fs = require('node:fs');
|
||||
const preferred = fs.readFileSync(${JSON.stringify(linuxMimeDefault)}, 'utf8').trim();
|
||||
if (process.argv[2] === 'info') process.stdout.write('standard::content-type: video/mp4');
|
||||
else if (process.argv[2] === 'mime') process.stdout.write('Default application for video/mp4: test.desktop\\nRegistered applications:\\n test.desktop\\n other.desktop\\n');
|
||||
else if (process.argv[2] === 'launch') fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path: process.argv[4], action: 'application' }) + '\\n');
|
||||
else if (process.argv[2] === 'mime') process.stdout.write((preferred.length > 0 ? 'Default application for video/mp4: ' + preferred + '\\n' : '') + 'Registered applications:\\n test.desktop\\n other.desktop\\n');
|
||||
else if (process.argv[2] === 'launch') {
|
||||
fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path: process.argv[4], action: 'application' }) + '\\n');
|
||||
fs.appendFileSync(${JSON.stringify(launchLog)}, JSON.stringify({ app: process.argv[3], path: process.argv[4] }) + '\\n');
|
||||
}
|
||||
else process.exit(1);
|
||||
`, { mode: 0o700 })
|
||||
launchTarget = testDesktop
|
||||
vi.stubEnv('XDG_DATA_HOME', data)
|
||||
vi.stubEnv('XDG_DATA_DIRS', '')
|
||||
}
|
||||
@@ -808,10 +887,12 @@ else process.exit(1);
|
||||
await pdfZoom.click()
|
||||
await page.getByRole('menuitem', { name: '150%', exact: true }).click()
|
||||
await expect.poll(async () => (await canvas.boundingBox())!.width / pdfIntrinsicWidth).toBeCloseTo(1.5, 1)
|
||||
await expectPdfResolution(canvas)
|
||||
pdfZoom = await revealDocumentZoom(page, preview)
|
||||
await pdfZoom.click()
|
||||
await page.getByRole('menuitem', { name: 'Fit width', exact: true }).click()
|
||||
await expect.poll(async () => (await canvas.boundingBox())!.width).toBeCloseTo(pdfWidth, 0)
|
||||
await expectPdfResolution(canvas)
|
||||
expect(await preview.locator('[data-pdf-page]').count()).toBe(2)
|
||||
await expect.poll(() => canvasColor(canvas), { timeout: 30_000 }).toBe('red')
|
||||
const firstColor = await canvasColor(canvas)
|
||||
@@ -846,6 +927,7 @@ else process.exit(1);
|
||||
`- Continuous pages: ${await preview.locator('[data-pdf-page]').count()}`,
|
||||
'- Zoom reveal: hidden -> bottom hover -> delayed hidden',
|
||||
'- Zoom modes: fit width -> 100% -> 150% -> fit width',
|
||||
'- Settled zoom redraws the page at device resolution',
|
||||
`- Horizontal overflow: ${String(await body.evaluate(node => node.scrollWidth > node.clientWidth))}`,
|
||||
`- Canvas fills: ${[firstColor, secondColor, restoredColor].join(' -> ')}`,
|
||||
`- Same tab: ${String(await pdfTab.getAttribute('data-dockkit-tab') === pdfTabId)}`,
|
||||
@@ -1326,6 +1408,42 @@ else process.exit(1);
|
||||
await headerOpen.click()
|
||||
await expect.poll(async () => (await opened()).length).toBe(4)
|
||||
expect((await opened())[3]).toEqual({ path: clip, action: 'open' })
|
||||
// The reported defect: the Shell lists applications but marks none as the OS default.
|
||||
// Clear the OS default marker for this platform: LaunchServices reports it inside the
|
||||
// catalog on macOS, while `gio mime` owns it on Linux.
|
||||
if (process.platform === 'linux') await writeFile(linuxMimeDefault, '')
|
||||
else await writeFile(appsCatalog, JSON.stringify([
|
||||
{ id: '/Applications/Test Player.app', name: 'Test Player', default: false, icon: `data:image/png;base64,${TINY_PNG.toString('base64')}` },
|
||||
{ id: '/Applications/Other Player.app', name: 'Other Player', default: false, icon: null },
|
||||
]))
|
||||
// Leave the file and come back: the shared association state is discarded when the
|
||||
// last control for a path unmounts, so the marker is read again instead of reused.
|
||||
await openPreviewFile(column, filesTab, preview, 'notes.unknown')
|
||||
await openPreviewFile(column, filesTab, preview, 'clip.mp4')
|
||||
await unsupported.waitFor({ timeout: 15_000 })
|
||||
await emptyOpen.waitFor({ timeout: 15_000 })
|
||||
// Opening the menu settles the re-read association before the labels are compared.
|
||||
await prominent.getByRole('button', { name: 'More ways to open' }).click()
|
||||
await page.getByRole('menuitem', { name: 'Test Player (default)', exact: true }).waitFor()
|
||||
await page.keyboard.press('Escape')
|
||||
expect(await emptyOpen.innerText()).toBe('Open')
|
||||
expect(await headerOpen.getAttribute('aria-label')).toBe('Open in Test Player')
|
||||
await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'applications-no-default.expected.md'), await prominent.ariaSnapshot(), MODE)
|
||||
const gesturesBefore = (await opened()).length
|
||||
const launchesBefore = (await launched()).length
|
||||
await emptyOpen.click()
|
||||
await expect.poll(async () => (await opened()).length).toBe(gesturesBefore + 1)
|
||||
// Without an OS-marked default the main action opens the application this control
|
||||
// names, rather than the OS association (which can prompt or pick another app).
|
||||
expect((await opened()).at(-1)).toEqual({ path: clip, action: 'application' })
|
||||
await expect.poll(async () => (await launched()).length).toBe(launchesBefore + 1)
|
||||
expect((await launched()).at(-1)?.app).toBe(launchTarget)
|
||||
await prominent.getByRole('button', { name: 'More ways to open' }).click()
|
||||
await page.getByRole('menuitem', { name: 'Test Player (default)', exact: true }).click()
|
||||
await expect.poll(async () => (await launched()).length).toBe(launchesBefore + 2)
|
||||
expect((await launched()).at(-1)?.app).toBe(launchTarget)
|
||||
await expect.poll(async () => (await opened()).length).toBe(gesturesBefore + 2)
|
||||
expect((await opened()).at(-1)).toEqual({ path: clip, action: 'application' })
|
||||
}
|
||||
// Gesture facts stay out of the golden: the stub does not run on Windows.
|
||||
expect(await page.getByRole('alert').count()).toBe(0)
|
||||
@@ -1333,7 +1451,7 @@ else process.exit(1);
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
await compareOrRefreshGolden(EXPECTED, sections.join('\n\n'), MODE)
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['document.expected.md', 'applications.expected.md', 'paging.patch.yml'])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['document.expected.md', 'applications.expected.md', 'applications-no-default.expected.md', 'paging.patch.yml'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1354,7 +1472,8 @@ describe.skipIf(MODE === 'record')('web e2e: Host Office preview', () => {
|
||||
],
|
||||
})
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, deviceScaleFactor: 2,
|
||||
locale: 'en-US', timezoneId: 'Asia/Shanghai' })
|
||||
const tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd)
|
||||
@@ -1430,6 +1549,7 @@ describe.skipIf(MODE === 'record')('web e2e: Host Office preview', () => {
|
||||
await page.getByRole('menuitem', { name: '150%', exact: true }).click()
|
||||
await expect.poll(() => zoomMenu.innerText()).toBe('150%')
|
||||
await expect.poll(async () => (await canvas.boundingBox())!.width / intrinsicWidth).toBeCloseTo(1.5, 1)
|
||||
await expectPdfResolution(canvas)
|
||||
const zoomScrollport = preview.locator('[data-document-zoom-scrollport]')
|
||||
await zoomScrollport.evaluate((node) => {
|
||||
const bounds = node.getBoundingClientRect()
|
||||
@@ -1438,10 +1558,31 @@ describe.skipIf(MODE === 'record')('web e2e: Host Office preview', () => {
|
||||
})
|
||||
await expect.poll(() => zoomMenu.innerText()).toBe('166%')
|
||||
await expect.poll(async () => (await canvas.boundingBox())!.width / intrinsicWidth).toBeCloseTo(1.66, 1)
|
||||
await expectPdfResolution(canvas)
|
||||
await zoomScrollport.evaluate((node) => { node.scrollTop = 0; node.scrollLeft = 0 })
|
||||
await copyPdfText(page, preview, '中文文档')
|
||||
await successShot(page, 'office-zoom-redrawn')
|
||||
await zoomMenu.click()
|
||||
await page.getByRole('menuitem', { name: 'Fit width', exact: true }).click()
|
||||
await expect.poll(() => zoomMenu.innerText()).toBe(fitPercent)
|
||||
await expectPdfResolution(canvas)
|
||||
await zoomScrollport.evaluate((node) => { node.scrollTop = 0; node.scrollLeft = 0 })
|
||||
await zoomScrollport.evaluate(async (node) => {
|
||||
const bounds = node.getBoundingClientRect()
|
||||
for (let step = 0; step < 8; step++) {
|
||||
node.dispatchEvent(new WheelEvent('wheel', { bubbles: true, cancelable: true, ctrlKey: true,
|
||||
deltaY: -40, clientX: bounds.left + bounds.width / 2, clientY: bounds.top + bounds.height / 2 }))
|
||||
await new Promise<void>(resolve => requestAnimationFrame(() => { resolve() }))
|
||||
}
|
||||
})
|
||||
await expect.poll(() => zoomMenu.innerText()).toBe('400%')
|
||||
expect(await zoomScrollport.evaluate(node => node.scrollLeft > node.clientWidth)).toBe(true)
|
||||
await expectPdfResolution(canvas)
|
||||
await zoomScrollport.evaluate((node) => { node.scrollTop = 0; node.scrollLeft = 0 })
|
||||
await successShot(page, 'office-pinch-400')
|
||||
await zoomMenu.click()
|
||||
await page.getByRole('menuitem', { name: 'Fit width', exact: true }).click()
|
||||
await expectPdfResolution(canvas)
|
||||
expect(convert).toHaveBeenCalledTimes(1)
|
||||
await preview.getByRole('button', { name: 'Read the file again', exact: true }).click()
|
||||
await canvas.waitFor({ state: 'visible' })
|
||||
@@ -1494,6 +1635,8 @@ describe.skipIf(MODE === 'record')('web e2e: Host Office preview', () => {
|
||||
'- Escape restores focus to the warning: true',
|
||||
'- Closing details preserves the warning and document position: true',
|
||||
'- Fit width, presets, and pinch resize the Office PDF continuously: true',
|
||||
'- Settled zoom redraws the Office PDF at device resolution: true',
|
||||
'- Continuous pinch to 400% redraws the page after horizontal panning: true',
|
||||
'- Pinch updates the displayed percentage during the gesture: 166%',
|
||||
`- Document top inset: ${topInset}px`,
|
||||
].join('\n'), MODE)
|
||||
@@ -1502,6 +1645,10 @@ describe.skipIf(MODE === 'record')('web e2e: Host Office preview', () => {
|
||||
await openPreviewFile(column, filesTab, preview, `chinese.${extension}`)
|
||||
await preview.getByRole('img', { name: 'PDF page 1', exact: true }).waitFor({ state: 'visible', timeout: 60_000 })
|
||||
await expect.poll(async () => (await preview.locator('[data-pdf-text]').allTextContents()).join(''), { timeout: 30_000 }).toContain('中文文档')
|
||||
const officeZoom = await revealDocumentZoom(page, preview)
|
||||
await officeZoom.click()
|
||||
await page.getByRole('menuitem', { name: '150%', exact: true }).click()
|
||||
await expectPdfResolution(canvas)
|
||||
if (['doc', 'ppt'].includes(extension)) expect(await warning.count()).toBe(0)
|
||||
await successShot(page, `office-${extension}`)
|
||||
}
|
||||
|
||||
@@ -137,7 +137,8 @@
|
||||
- code: notes.md
|
||||
- text: untouched.
|
||||
- paragraph:
|
||||
- img "Token preview"
|
||||
- 'button "View full image: Token preview"':
|
||||
- img "Token preview"
|
||||
- text: css
|
||||
- button "Wrap lines" [pressed]
|
||||
- button "Copy"
|
||||
|
||||
@@ -14,9 +14,9 @@
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [expanded]
|
||||
- button "Called tools" [expanded]
|
||||
- button "Think"
|
||||
- button "Think I will inspect the current Session's dynamic Cordis Plugins before defining the snapshot Package."
|
||||
- button "Inspect plugins 0 dynamic plugins"
|
||||
- button "Think"
|
||||
- button "Think No dynamic Plugins are present, so I will define the requested Host and Client Package."
|
||||
- button "Register Cordis Plugin snapshot noop does nothing, for the snapshot Ready" [expanded]
|
||||
- tablist "Plugin source":
|
||||
- tab "Client"
|
||||
@@ -28,11 +28,11 @@
|
||||
- code: "return { name: \"snapshot-noop\", apply(ctx) {} }"
|
||||
- text: Result Defined snap-1/pkg-1 (snapshot noop); it is not running yet. Use cordis_run to activate this Package. Run controls live in the Cordis panel above Settings
|
||||
- button "Inspect"
|
||||
- button "Think"
|
||||
- button "Think The Host returned snap-1/pkg-1, so I will request its first activation."
|
||||
- text: Run Cordis Plugin snap-1 · pkg-1 Ready
|
||||
- button "Inspect"
|
||||
- text: snap-1/pkg-1 is awaiting user approval (run-1).
|
||||
- button "Think"
|
||||
- button "Think The activation request has been submitted, so I will return the requested readiness marker."
|
||||
- paragraph: CORDIS_UI_READY
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
- text: 字号大小 仅影响会话内容的字号 14
|
||||
- button "增大字号"
|
||||
- button "减小字号"
|
||||
- text: px 工作过程展示 控制轮次和步骤的默认展开方式
|
||||
- button "简洁"
|
||||
- text: px 工作步骤展示 选择希望看到多少工具调用细节
|
||||
- button "标准"
|
||||
- text: 性能与用量 选择性能与用量信息展示的详细程度
|
||||
- button "详细"
|
||||
- text: 开发者工具 显示用于调试和排查问题的工具与信息
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
- text: Font size Only affects conversation content 14
|
||||
- button "Increase font size"
|
||||
- button "Decrease font size"
|
||||
- text: px Work details Controls how turns and steps expand by default
|
||||
- button "Compact"
|
||||
- text: px Work details Choose how much detail to show for tool calls
|
||||
- button "Standard"
|
||||
- text: Performance & usage Choose how much performance and usage information to show
|
||||
- button "Detailed"
|
||||
- text: Developer tools Show tools and information for debugging and troubleshooting
|
||||
|
||||
@@ -11,15 +11,28 @@
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- heading "Markdown images" [level=2]
|
||||
- paragraph:
|
||||
- img "Remote test image"
|
||||
- paragraph: Local test image
|
||||
- 'button "View full image: Remote test image"':
|
||||
- img "Remote test image"
|
||||
- paragraph:
|
||||
- img "Workspace test image"
|
||||
- paragraph: Oversized image
|
||||
- 'button "View full image: Local test image"':
|
||||
- img "Local test image"
|
||||
- paragraph:
|
||||
- img "Outside workspace image"
|
||||
- paragraph: Missing image
|
||||
- paragraph: {{cwd}}/corrupt.png
|
||||
- 'button "View full image: Workspace test image"':
|
||||
- img "Workspace test image"
|
||||
- paragraph:
|
||||
- button "View comparison"
|
||||
- paragraph:
|
||||
- 'button "View full image: Space path"':
|
||||
- img "Space path"
|
||||
- paragraph:
|
||||
- 'button "View full image: Encoded path"':
|
||||
- img "Encoded path"
|
||||
- paragraph: Image preview unavailable · Oversized image
|
||||
- paragraph:
|
||||
- 'button "View full image: Outside workspace image"':
|
||||
- img "Outside workspace image"
|
||||
- paragraph: Image preview unavailable · Missing image
|
||||
- paragraph: Image preview unavailable · {{cwd}}/corrupt.png
|
||||
- paragraph: REMOTE_IMAGE_DONE
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
|
||||
@@ -7,5 +7,7 @@
|
||||
- Escape restores focus to the warning: true
|
||||
- Closing details preserves the warning and document position: true
|
||||
- Fit width, presets, and pinch resize the Office PDF continuously: true
|
||||
- Settled zoom redraws the Office PDF at device resolution: true
|
||||
- Continuous pinch to 400% redraws the page after horizontal panning: true
|
||||
- Pinch updates the displayed percentage during the gesture: 166%
|
||||
- Document top inset: 0px
|
||||
@@ -18,8 +18,8 @@
|
||||
- text: Font size Only affects conversation content 14
|
||||
- button "Increase font size"
|
||||
- button "Decrease font size"
|
||||
- text: px Work details Controls how turns and steps expand by default
|
||||
- button "Compact"
|
||||
- text: px Work details Choose how much detail to show for tool calls
|
||||
- button "Standard"
|
||||
- text: Performance & usage Choose how much performance and usage information to show
|
||||
- button "Detailed"
|
||||
- text: Developer tools Show tools and information for debugging and troubleshooting
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
- text: Font size Only affects conversation content 14
|
||||
- button "Increase font size"
|
||||
- button "Decrease font size"
|
||||
- text: px Work details Controls how turns and steps expand by default
|
||||
- button "Compact"
|
||||
- text: px Work details Choose how much detail to show for tool calls
|
||||
- button "Standard"
|
||||
- text: Performance & usage Choose how much performance and usage information to show
|
||||
- button "Detailed"
|
||||
- text: Developer tools Show tools and information for debugging and troubleshooting
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
- text: 字号大小 仅影响会话内容的字号 14
|
||||
- button "增大字号"
|
||||
- button "减小字号"
|
||||
- text: px 工作过程展示 控制轮次和步骤的默认展开方式
|
||||
- button "简洁"
|
||||
- text: px 工作步骤展示 选择希望看到多少工具调用细节
|
||||
- button "标准"
|
||||
- text: 性能与用量 选择性能与用量信息展示的详细程度
|
||||
- button "详细"
|
||||
- text: 开发者工具 显示用于调试和排查问题的工具与信息
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
- button "Copy"
|
||||
- status: Deep diving...
|
||||
- button "Deep diving for {{duration}}" [disabled] [expanded]
|
||||
- button "Analyzing the request"
|
||||
- button "Analyzing the request · The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that."
|
||||
- text: "Interjection: include the word BANANA in your final reply."
|
||||
- button "Copy"
|
||||
- text: "Interjection: include the word ORANGE in your final reply."
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- button "Asked questions" [expanded]
|
||||
- button "Think"
|
||||
- button "Think The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that."
|
||||
- button "Ask question 1/1 answered"
|
||||
- text: "Interjection: include the word BANANA in your final reply. {{clock}}"
|
||||
- button "Copy"
|
||||
|
||||
@@ -1 +1 @@
|
||||
Prefer showing the primary results within your final response alongside a brief explanation. Markdown file links such as [Report](path/to/report.html) open the file in the sidebar preview. For images, you can add an inline preview such as ; include a file link as well so the image remains accessible in clients that cannot display it inline. Do not call present just to list edited source files, or run commands to check whether a diff view will appear. Use present when a separate file card helps the user open the complete deliverable, especially Office documents, spreadsheets, and slide decks. Each presented file adds a card below the reply, with preview and native-open actions. Usually select the 1-2 most important deliverables; include more when needed, but at most 4 files in a single present call. Avoid repeating results already shown inline unless the separate card adds useful access. Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. Use the filename or a clear alias as the label, adding only enough parent directories to distinguish files; keep full paths out of labels. Default to the name alone; when precise locations matter, append :24 or :24–30, with no # or L in the line suffix.
|
||||
Prefer showing the primary results within your final response alongside a brief explanation. Use  when an image supports an explanation or comparison. Use [Description](<path/to/image.png>) when referring to an image or listing files. Enclose Markdown file destinations in angle brackets, especially paths containing spaces. Do not call present just to list edited source files, or run commands to check whether a diff view will appear. Use present when a separate file card helps the user open the complete deliverable, including images, Office documents, spreadsheets, and slide decks. Each presented file adds a card below the reply, with preview and native-open actions. Usually select the 1-2 most important deliverables; include more when needed, but at most 4 files in a single present call. Avoid repeating results already shown inline unless the separate card adds useful access. Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. Use the filename or a clear alias as the label, adding only enough parent directories to distinguish files; keep full paths out of labels. Default to the name alone; when precise locations matter, append :24 or :24–30, with no # or L in the line suffix.
|
||||
@@ -1,5 +1,5 @@
|
||||
// Real browser image loading and failure fallbacks through the shipped Web composition.
|
||||
import { open, writeFile } from 'node:fs/promises'
|
||||
import { mkdir, open, writeFile } from 'node:fs/promises'
|
||||
import { createServer, type Server } from 'node:http'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
@@ -112,6 +112,12 @@ function markdownImageFixture(remoteUrl: string, outsidePath: string): string {
|
||||
'',
|
||||
``,
|
||||
'',
|
||||
'[View comparison](./valid.png)',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
``,
|
||||
@@ -164,6 +170,9 @@ describe('web e2e: Markdown image rendering', () => {
|
||||
imageOrigin = await startImageOrigin()
|
||||
scaffold = await launchWebScaffold({})
|
||||
await writeFile(join(scaffold.workspaceCwd, 'valid.png'), PNG)
|
||||
await writeFile(join(scaffold.workspaceCwd, 'local-image.png'), PNG)
|
||||
await mkdir(join(scaffold.workspaceCwd, 'test workspace'))
|
||||
await writeFile(join(scaffold.workspaceCwd, 'test workspace/测试图.png'), PNG)
|
||||
await writeFile(join(scaffold.workspaceCwd, 'corrupt.png'), 'invalid image')
|
||||
const oversized = await open(join(scaffold.workspaceCwd, 'oversized.png'), 'w')
|
||||
try {
|
||||
@@ -237,11 +246,13 @@ describe('web e2e: Markdown image rendering', () => {
|
||||
borderRadius: '8px',
|
||||
decoding: 'async',
|
||||
loading: 'lazy',
|
||||
maxWidth: '100%',
|
||||
maxWidth: 'min(100%, 640px)',
|
||||
referrerPolicy: 'no-referrer',
|
||||
})
|
||||
expect(await page.getByRole('img', { name: LOCAL_ALT }).count()).toBe(0)
|
||||
expect(await page.getByText(LOCAL_ALT, { exact: true }).count()).toBe(1)
|
||||
await expect.poll(() => page.getByRole('img', { name: LOCAL_ALT }).evaluate(element => (element as HTMLImageElement).naturalWidth)).toBe(1)
|
||||
for (const name of ['Space path', 'Encoded path']) {
|
||||
await expect.poll(() => page.getByRole('img', { name, exact: true }).evaluate(element => (element as HTMLImageElement).naturalWidth)).toBe(1)
|
||||
}
|
||||
expect(imageOrigin.requests).toEqual([{ path: '/image.png', referer: undefined }])
|
||||
|
||||
const workspaceImage = page.getByRole('img', { name: WORKSPACE_ALT })
|
||||
@@ -253,23 +264,59 @@ describe('web e2e: Markdown image rendering', () => {
|
||||
const outsideImage = page.getByRole('img', { name: 'Outside workspace image' })
|
||||
await expect.poll(() => outsideImage.evaluate(element => (element as HTMLImageElement).naturalWidth)).toBe(1)
|
||||
for (const alt of ['Oversized image', 'Missing image']) {
|
||||
await page.getByText(alt, { exact: true }).waitFor()
|
||||
await page.getByText(`Image preview unavailable · ${alt}`, { exact: true }).waitFor()
|
||||
expect(await page.getByRole('img', { name: alt }).count()).toBe(0)
|
||||
}
|
||||
await page.getByText(join(scaffold.workspaceCwd, 'corrupt.png'), { exact: true }).waitFor()
|
||||
await page.getByText(`Image preview unavailable · ${join(scaffold.workspaceCwd, 'corrupt.png')}`, { exact: true }).waitFor()
|
||||
expect(mediaResponses).toEqual(new Map([
|
||||
[join(scaffold.workspaceCwd, 'valid.png'), 200],
|
||||
[`${scaffold.workspaceCwd}/./local-image.png`, 200],
|
||||
[join(scaffold.workspaceCwd, 'test workspace/测试图.png'), 200],
|
||||
[join(scaffold.workspaceCwd, 'oversized.png'), 413],
|
||||
[join(scaffold.persistenceRoot, 'outside.png'), 200],
|
||||
[join(scaffold.workspaceCwd, 'missing.png'), 404],
|
||||
[join(scaffold.workspaceCwd, 'corrupt.png'), 200],
|
||||
]))
|
||||
|
||||
await page.getByRole('button', { name: `View full image: ${WORKSPACE_ALT}`, exact: true }).click()
|
||||
const lightbox = page.getByRole('dialog', { name: 'Image preview', exact: true })
|
||||
await lightbox.waitFor()
|
||||
await lightbox.getByRole('button', { name: 'Close image preview' }).click()
|
||||
await lightbox.waitFor({ state: 'detached' })
|
||||
const link = page.getByRole('button', { name: 'View comparison', exact: true })
|
||||
await link.hover()
|
||||
const hoverImage = page.getByRole('img', { name: './valid.png', exact: true })
|
||||
await hoverImage.waitFor()
|
||||
await expect.poll(() => hoverImage.evaluate(element => (element as HTMLImageElement).naturalWidth)).toBe(1)
|
||||
await page.keyboard.press('Escape')
|
||||
await hoverImage.waitFor({ state: 'detached' })
|
||||
await link.press('Tab')
|
||||
await page.keyboard.press('Shift+Tab')
|
||||
await hoverImage.waitFor()
|
||||
await page.keyboard.press('Escape')
|
||||
await hoverImage.waitFor({ state: 'detached' })
|
||||
|
||||
const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
|
||||
.split(SEED_ID).join('{{seededId}}')
|
||||
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md'])
|
||||
await link.evaluate((element) => { element.scrollIntoView({ block: 'end' }) })
|
||||
await page.mouse.move(0, 0)
|
||||
await link.hover()
|
||||
await hoverImage.waitFor()
|
||||
const linkRect = await link.boundingBox()
|
||||
if (linkRect === null) throw new Error('image link is not visible')
|
||||
const previewRect = await hoverImage.evaluate((element) => {
|
||||
const rect = element.parentElement!.parentElement!.getBoundingClientRect()
|
||||
return { top: rect.top, bottom: rect.bottom }
|
||||
})
|
||||
expect(previewRect.bottom <= linkRect.y || previewRect.top >= linkRect.y + linkRect.height).toBe(true)
|
||||
const thumbnailRect = await hoverImage.boundingBox()
|
||||
if (thumbnailRect === null) throw new Error('image preview is not visible')
|
||||
expect(thumbnailRect.y + thumbnailRect.height).toBeLessThanOrEqual(previewRect.bottom)
|
||||
await page.mouse.click(linkRect.x + linkRect.width / 2, linkRect.y + linkRect.height / 2)
|
||||
await expect.poll(() => page.locator('[data-image-preview] img').evaluate(element => (element as HTMLImageElement).naturalWidth)).toBe(1)
|
||||
}, 60_000)
|
||||
})
|
||||
@@ -107,8 +107,12 @@ it.each(['https://registry.npmjs.org/', MIRROR])('selects the fastest responding
|
||||
await dialog.getByRole('button', { name: '立即启用', exact: true }).waitFor({ timeout: 20_000 })
|
||||
await dialog.getByText('版本 2.0.0', { exact: true }).waitFor()
|
||||
const lookups = (await readFile(join(profile, '.registry-lookups'), 'utf8')).trim().split('\n')
|
||||
expect(lookups).toHaveLength(1)
|
||||
expect(JSON.parse(lookups[0]!)).toContain('--registry=' + MIRROR)
|
||||
.map(line => JSON.parse(line) as string[])
|
||||
// The inspection asked the mirror first, and each install attempt checked the named package's
|
||||
// DSH peers at the registry that attempt would use: the mirror, then pnpm's own.
|
||||
expect(lookups.map(call => call.filter(argument => argument.startsWith('--registry=')))).toEqual([
|
||||
['--registry=' + MIRROR], ['--registry=' + MIRROR], [],
|
||||
])
|
||||
await dialog.getByRole('button', { name: '查看安装详情', exact: true }).click()
|
||||
await dialog.getByText('Installed from the registry pnpm names', { exact: true }).waitFor()
|
||||
await dialog.getByText('第 1 次 · 中国大陆镜像源', { exact: true }).waitFor()
|
||||
|
||||
@@ -560,15 +560,15 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
}, 90_000)
|
||||
|
||||
it.each([
|
||||
['detailed', '详细'], ['expanded', '完全展开'],
|
||||
['compact', '简洁'], ['detailed', '详细'], ['verbose', '完全展开'],
|
||||
] as const)('persists the %s work-details mode across reload', async (mode, label) => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-transcript-view'))
|
||||
await openSettings(page, 'zh')
|
||||
const dialog = page.getByRole('dialog', { name: '设置' })
|
||||
await dialog.waitFor({ timeout: 10_000 })
|
||||
await dialog.getByText('工作过程展示', { exact: true }).waitFor({ timeout: 10_000 })
|
||||
const details = dialog.getByText('工作过程展示', { exact: true }).locator('../..')
|
||||
await details.getByRole('button', { name: '简洁', exact: true }).click()
|
||||
await dialog.getByText('工作步骤展示', { exact: true }).waitFor({ timeout: 10_000 })
|
||||
const details = dialog.getByText('工作步骤展示', { exact: true }).locator('../..')
|
||||
await details.getByRole('button', { name: '标准', exact: true }).click()
|
||||
await page.getByRole('menuitem', { name: label, exact: true }).click()
|
||||
await details.getByRole('button', { name: label, exact: true }).waitFor({ timeout: 10_000 })
|
||||
await expect.poll(async () => readFile(join(scaffold.harnessHome, 'profiles', 'scaffold', 'cordis.patch.yml'), 'utf8'), { timeout: 5_000 })
|
||||
@@ -581,14 +581,14 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
acknowledgeReloadConnectionLoss(tripwire, warningStart)
|
||||
await openSettings(page, 'zh')
|
||||
const reloaded = page.getByRole('dialog', { name: '设置' })
|
||||
const restoredDetails = reloaded.getByText('工作过程展示', { exact: true }).locator('../..')
|
||||
const restoredDetails = reloaded.getByText('工作步骤展示', { exact: true }).locator('../..')
|
||||
await restoredDetails.getByRole('button', { name: label, exact: true }).waitFor({ timeout: 10_000 })
|
||||
|
||||
await restoredDetails.getByRole('button', { name: label, exact: true }).click()
|
||||
await page.getByRole('menuitem', { name: '简洁', exact: true }).click()
|
||||
await restoredDetails.getByRole('button', { name: '简洁', exact: true }).waitFor({ timeout: 10_000 })
|
||||
await page.getByRole('menuitem', { name: '标准', exact: true }).click()
|
||||
await restoredDetails.getByRole('button', { name: '标准', exact: true }).waitFor({ timeout: 10_000 })
|
||||
await expect.poll(async () => readFile(join(scaffold.harnessHome, 'profiles', 'scaffold', 'cordis.patch.yml'), 'utf8'), { timeout: 5_000 })
|
||||
.toContain('transcriptView: compact')
|
||||
.toContain('transcriptView: standard')
|
||||
await page.keyboard.press('Escape')
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 90_000)
|
||||
|
||||
@@ -244,8 +244,8 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => {
|
||||
}, 60_000)
|
||||
|
||||
it.skipIf(MODE === 'record').each([
|
||||
['detailed', 'Detailed'], ['expanded', 'Expanded'],
|
||||
] as const)('preserves whole-Turn folding in %s mode', async (mode, label) => {
|
||||
['compact', 'Compact'], ['detailed', 'Detailed'], ['verbose', 'Verbose'],
|
||||
] as const)('applies whole-Turn presentation in %s mode', async (mode, label) => {
|
||||
await launch()
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-turn-process-setting'))
|
||||
const { settled } = await sendPrompt()
|
||||
@@ -259,31 +259,38 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => {
|
||||
await openSettings(page, 'en')
|
||||
const dialog = page.getByRole('dialog', { name: 'Settings' })
|
||||
await dialog.getByText('Work details', { exact: true }).locator('../..')
|
||||
.getByRole('button', { name: 'Compact', exact: true }).click()
|
||||
.getByRole('button', { name: 'Standard', exact: true }).click()
|
||||
await page.getByRole('menuitem', { name: label, exact: true }).click()
|
||||
await page.keyboard.press('Escape')
|
||||
|
||||
expect(await process.count()).toBe(1)
|
||||
expect(await process.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(await tool.isVisible()).toBe(false)
|
||||
await expect.poll(async () => readFile(join(scaffold!.harnessHome, 'profiles', 'scaffold', 'cordis.patch.yml'), 'utf8'), { timeout: 5_000 })
|
||||
.toContain(`transcriptView: ${mode}`)
|
||||
|
||||
await process.click()
|
||||
const group = page.locator('[data-sample="bash"]').first().locator('xpath=ancestor::*[@data-chat-group-key]')
|
||||
const groupControl = group.locator('[data-process-activity]')
|
||||
expect(await groupControl.isVisible()).toBe(true)
|
||||
expect(await groupControl.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(await tool.isVisible()).toBe(false)
|
||||
await groupControl.click()
|
||||
expect(await tool.isVisible()).toBe(true)
|
||||
await process.click()
|
||||
if (mode === 'verbose') {
|
||||
expect(await process.isDisabled()).toBe(true)
|
||||
expect(await process.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(await groupControl.isVisible()).toBe(false)
|
||||
expect(await tool.isVisible()).toBe(true)
|
||||
} else {
|
||||
expect(await process.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(await tool.isVisible()).toBe(false)
|
||||
await process.click()
|
||||
expect(await groupControl.isVisible()).toBe(true)
|
||||
expect(await groupControl.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(await tool.isVisible()).toBe(false)
|
||||
await groupControl.click()
|
||||
expect(await tool.isVisible()).toBe(true)
|
||||
await process.click()
|
||||
}
|
||||
|
||||
await openSettings(page, 'en')
|
||||
const restored = page.getByRole('dialog', { name: 'Settings' })
|
||||
await restored.getByText('Work details', { exact: true }).locator('../..')
|
||||
.getByRole('button', { name: label, exact: true }).click()
|
||||
await page.getByRole('menuitem', { name: 'Compact', exact: true }).click()
|
||||
await page.getByRole('menuitem', { name: 'Standard', exact: true }).click()
|
||||
await page.keyboard.press('Escape')
|
||||
await process.waitFor({ timeout: 10_000 })
|
||||
expect(await process.getAttribute('aria-expanded')).toBe('false')
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/config-catalog.md
|
||||
config-catalog.md: d330d7d4bd5c0c7536ba00a9e0c3275821bbc7ef
|
||||
config-catalog.zh.md: 9e3d54dba349f03e2887303b3590b0252e48d4ac
|
||||
config-catalog.md: 7e4aea0317645712916f804734b139ba2288940b
|
||||
config-catalog.zh.md: ea3d4d8654e93b46c0f45c24684a5c25a340c808
|
||||
@@ -2198,7 +2198,7 @@ export interface Config {
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/boot/plugin-manager/src/index.ts:38`](../packages/boot/plugin-manager/src/index.ts)
|
||||
Source: [`packages/boot/plugin-manager/src/index.ts:40`](../packages/boot/plugin-manager/src/index.ts)
|
||||
|
||||
<a id="deepseek-aidsh-plugin-package-inventory-deepseek"></a>
|
||||
|
||||
|
||||
@@ -2200,7 +2200,7 @@ export interface Config {
|
||||
}
|
||||
```
|
||||
|
||||
来源: [`packages/boot/plugin-manager/src/index.ts:38`](../packages/boot/plugin-manager/src/index.ts)
|
||||
来源: [`packages/boot/plugin-manager/src/index.ts:40`](../packages/boot/plugin-manager/src/index.ts)
|
||||
|
||||
<a id="deepseek-aidsh-plugin-package-inventory-deepseek"></a>
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/event-producer-consumer.md
|
||||
event-producer-consumer.md: 54a5e36bb6d2c39606c2e0263cad89293e6bb613
|
||||
event-producer-consumer.zh.md: 248aacbafc115d2c7aea8480406d2a1842ed293d
|
||||
event-producer-consumer.md: b67f1c9b2ab5764e8be7be0bb584de9da4eea103
|
||||
event-producer-consumer.zh.md: df371af272167b3b5d12ad9cc32abb27eaeea058
|
||||
@@ -26,7 +26,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:616`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:595`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:602`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:45`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) |
|
||||
| `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:52`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) |
|
||||
| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` |
|
||||
| `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) |
|
||||
| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` |
|
||||
@@ -52,9 +52,9 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
| `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` |
|
||||
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:73`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) |
|
||||
| `permission-presets/catalog-changed` | `emit` | [`packages/interaction/permission-presets/src/types.ts:48`](../packages/interaction/permission-presets/src/types.ts) | [`permission-presets`](../packages/interaction/permission-presets) (`events.dispatch`) | `remotes` |
|
||||
| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:230`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
|
||||
| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:236`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
|
||||
| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:243`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
|
||||
| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:243`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
|
||||
| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:249`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
|
||||
| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:256`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
|
||||
| `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
|
||||
| `session/created` | `emit` | [`packages/core/session/src/index.ts:53`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
|
||||
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:63`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) |
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:616`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:595`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:602`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:43`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) |
|
||||
| `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:52`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) |
|
||||
| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` |
|
||||
| `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) |
|
||||
| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` |
|
||||
@@ -54,9 +54,9 @@
|
||||
| `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` |
|
||||
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:73`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) |
|
||||
| `permission-presets/catalog-changed` | `emit` | [`packages/interaction/permission-presets/src/types.ts:48`](../packages/interaction/permission-presets/src/types.ts) | [`permission-presets`](../packages/interaction/permission-presets) (`events.dispatch`) | `remotes` |
|
||||
| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:230`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
|
||||
| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:236`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
|
||||
| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:243`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
|
||||
| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:243`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
|
||||
| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:249`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
|
||||
| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:256`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
|
||||
| `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
|
||||
| `session/created` | `emit` | [`packages/core/session/src/index.ts:53`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
|
||||
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:63`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) |
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/subsystems/boot.md
|
||||
boot.md: 5faf482b0532058e480adf13bf7fee8bb882498d
|
||||
boot.zh.md: 1f50732fd36a996c6bc15b406f7e9a4ac83bcb26
|
||||
boot.md: 07391705977bc7b3cba9377e3142feea7d62f69f
|
||||
boot.zh.md: 64ff36d41295f529b81f2856f2fbc77f09efa808
|
||||
@@ -89,6 +89,21 @@ Source: [`packages/boot/hmr/src/index.ts`](../../packages/boot/hmr/src/index.ts)
|
||||
Manage profile files and apply their declared reload lifecycle.
|
||||
|
||||
```ts cordis-catalog
|
||||
/** Read exact plugin-version exemptions saved in this profile.
|
||||
* @returns Accepted package-name@version keys with the runtime versions they may run on, and any
|
||||
* record or file problem the reader rejected, which the caller reports instead of failing.
|
||||
*/
|
||||
@Remote listVersionExemptions(): { exemptions: Record<string, string[]>; warnings: string[] }
|
||||
|
||||
/** Grant or revoke one exact plugin/runtime exemption and reevaluate live plugins.
|
||||
* @param packageVersion Exact manifest package name followed by @ and its version; never an installation spec or alias.
|
||||
* @param runtimeVersion Exact current DSH version for grants; revocation may name a previous runtime.
|
||||
* @param enabled Whether to grant rather than revoke the exemption.
|
||||
* @param acceptRisk Required true for grants after the user accepts possible crashes and data loss.
|
||||
* @returns Saved and runtime outcomes. Startup-only profiles require restart.
|
||||
*/
|
||||
@Remote setVersionExemption(packageVersion: string, runtimeVersion: string, enabled: boolean, acceptRisk?: boolean): Promise<ChangeResult>
|
||||
|
||||
/** Read current plugins, including why a row cannot be changed through the profile patch.
|
||||
* @returns Current runtime entries with persistent patch targets.
|
||||
*/
|
||||
|
||||
@@ -89,6 +89,21 @@ Source: [`packages/boot/hmr/src/index.ts`](../../packages/boot/hmr/src/index.ts)
|
||||
Manage profile files and apply their declared reload lifecycle.
|
||||
|
||||
```ts cordis-catalog
|
||||
/** Read exact plugin-version exemptions saved in this profile.
|
||||
* @returns Accepted package-name@version keys with the runtime versions they may run on, and any
|
||||
* record or file problem the reader rejected, which the caller reports instead of failing.
|
||||
*/
|
||||
@Remote listVersionExemptions(): { exemptions: Record<string, string[]>; warnings: string[] }
|
||||
|
||||
/** Grant or revoke one exact plugin/runtime exemption and reevaluate live plugins.
|
||||
* @param packageVersion Exact manifest package name followed by @ and its version; never an installation spec or alias.
|
||||
* @param runtimeVersion Exact current DSH version for grants; revocation may name a previous runtime.
|
||||
* @param enabled Whether to grant rather than revoke the exemption.
|
||||
* @param acceptRisk Required true for grants after the user accepts possible crashes and data loss.
|
||||
* @returns Saved and runtime outcomes. Startup-only profiles require restart.
|
||||
*/
|
||||
@Remote setVersionExemption(packageVersion: string, runtimeVersion: string, enabled: boolean, acceptRisk?: boolean): Promise<ChangeResult>
|
||||
|
||||
/** Read current plugins, including why a row cannot be changed through the profile patch.
|
||||
* @returns Current runtime entries with persistent patch targets.
|
||||
*/
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/tool-catalog.md
|
||||
tool-catalog.md: 8e1f4f7c8c8b36de366aa8a1b795106f4ae32283
|
||||
tool-catalog.zh.md: 0995196921738ad4b5a5deda0cf48c83b1d4e664
|
||||
tool-catalog.md: 3e4407192ee75b61537d9fd09f3d68381d1e803e
|
||||
tool-catalog.zh.md: cf78ee4d4f0d8a8bdddbf4ad3987a18bd1f300d1
|
||||
+13
-3
@@ -52,7 +52,7 @@ This table connects model-visible tool names to the plugin package and service s
|
||||
|
||||
### `plugin_manager`
|
||||
|
||||
List plugins or bundles in the current profile, enable or disable them, install a bundle, or remove an installed bundle. Every action requires danger-full-access permission or approval for this call. Approval does not change the session permission mode. Changes affect every session in this profile. List first to obtain exact identifiers. Package installation can execute allowed build scripts. Live profiles apply changes immediately; startup profiles require restart.
|
||||
List plugins or bundles in the current profile, enable or disable them, install a bundle, or remove an installed bundle. Every action requires danger-full-access permission or approval for this call. Approval does not change the session permission mode. Changes affect every session in this profile. List first to obtain exact identifiers. Package installation can execute allowed build scripts. Live profiles apply changes immediately; startup profiles require restart. Incompatible DSH peer dependencies block installation and activation. Version exemptions risk crashes and data loss: warn the user and obtain explicit permission for the exact plugin and runtime versions before granting one.
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -67,7 +67,9 @@ List plugins or bundles in the current profile, enable or disable them, install
|
||||
"set_plugin",
|
||||
"set_bundle",
|
||||
"install_bundle",
|
||||
"remove_bundle"
|
||||
"remove_bundle",
|
||||
"list_version_exemptions",
|
||||
"set_version_exemption"
|
||||
]
|
||||
},
|
||||
"target": {
|
||||
@@ -76,7 +78,15 @@ List plugins or bundles in the current profile, enable or disable them, install
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Required for set operations; defaults to true for installation."
|
||||
"description": "Required for set operations; defaults to true for installation. For set_version_exemption, true grants and false revokes."
|
||||
},
|
||||
"runtimeVersion": {
|
||||
"type": "string",
|
||||
"description": "For set_version_exemption: exact DSH version from list_version_exemptions. Target must be the manifest package-name@version, not an alias or version range."
|
||||
},
|
||||
"acceptRisk": {
|
||||
"type": "boolean",
|
||||
"description": "For granting an exemption: true only after warning the user about possible crashes and data loss and receiving explicit permission for this exact plugin/runtime pair. General installation permission is not enough."
|
||||
},
|
||||
"approvedBuilds": {
|
||||
"type": "array",
|
||||
|
||||
+13
-3
@@ -56,7 +56,7 @@
|
||||
|
||||
### `plugin_manager`
|
||||
|
||||
列出当前 profile 中的插件或组合包,启用或禁用它们,安装组合包或移除已安装的组合包。每项操作都要求 danger-full-access 权限或本次调用的批准。批准不改变会话权限模式。变更影响该 profile 的所有会话。先列出条目以获取准确标识。包安装可能运行已获批准的构建脚本。支持热更新的 profile 立即应用变更;仅启动时加载的 profile 需要重启。
|
||||
列出当前 profile 中的插件或组合包,启用或禁用它们,安装组合包或移除已安装的组合包。每项操作都要求 danger-full-access 权限或本次调用的批准。批准不改变会话权限模式。变更影响该 profile 的所有会话。先列出条目以获取准确标识。包安装可能运行已获批准的构建脚本。支持热更新的 profile 立即应用变更;仅启动时加载的 profile 需要重启。不兼容的 DSH peer 依赖会阻止安装和激活。版本豁免可能导致崩溃和数据丢失:授权前必须警告用户,并获得用户对精确插件版本与运行时版本组合的明确许可。
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -71,7 +71,9 @@
|
||||
"set_plugin",
|
||||
"set_bundle",
|
||||
"install_bundle",
|
||||
"remove_bundle"
|
||||
"remove_bundle",
|
||||
"list_version_exemptions",
|
||||
"set_version_exemption"
|
||||
]
|
||||
},
|
||||
"target": {
|
||||
@@ -80,7 +82,15 @@
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Required for set operations; defaults to true for installation."
|
||||
"description": "Required for set operations; defaults to true for installation. For set_version_exemption, true grants and false revokes."
|
||||
},
|
||||
"runtimeVersion": {
|
||||
"type": "string",
|
||||
"description": "For set_version_exemption: exact DSH version from list_version_exemptions. Target must be the manifest package-name@version, not an alias or version range."
|
||||
},
|
||||
"acceptRisk": {
|
||||
"type": "boolean",
|
||||
"description": "For granting an exemption: true only after warning the user about possible crashes and data loss and receiving explicit permission for this exact plugin/runtime pair. General installation permission is not enough."
|
||||
},
|
||||
"approvedBuilds": {
|
||||
"type": "array",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-root",
|
||||
"version": "0.1.7-alpha.2",
|
||||
"version": "0.1.7-rc.1",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-acp",
|
||||
"description": "Automation-only Agent Client Protocol server for driving DeepSeek Harness agents over JSON-RPC stdio",
|
||||
"version": "0.1.7-alpha.2",
|
||||
"version": "0.1.7-rc.1",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-api-account-controller",
|
||||
"description": "Expose safe account operations over authenticated Remote",
|
||||
"version": "0.1.7-alpha.2",
|
||||
"version": "0.1.7-rc.1",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/api/gateway/README.md
|
||||
README.md: 1a746a83eb966fba6ae40b61061d42bf478e1c84
|
||||
README.zh.md: 7b37548f6ad894b761457df80278eeed7aa6ca7b
|
||||
README.md: e0c986e290b1744017fca99e3a2553a68d754ef6
|
||||
README.zh.md: 8be11a7c5aea1e299c4ca45cde4b1c9dfa08ec4b
|
||||
@@ -38,6 +38,8 @@ When the launcher supplies `ctx.appReady`, Gateway registers the WebSocket upgra
|
||||
|
||||
Every stream also carries a Client-to-Host uplink on the same logical stream. A method declares the uplink item type as the second type argument of its return type, `RemoteStream<Out, In>` from [`dsh-typert-protocol`](../../typert/protocol/README.md); `In` defaults to `never`, and a method that returns `Iterable`, `AsyncIterable`, or `RemoteStream<Out>` declares that it reads none. The running method reads its call as `this.ctx.invocation`: the Gateway derives the receiver from a Context carrying that `RemoteInvocation` — `request`, `service`, `peer`, `signal`, and `uplink()` — so nothing enters the parameter list, and `ctx.invocation` is `undefined` on a Context no Remote call derived. `uplink()` is available once per call. With a descriptor codec it decodes every item before delivery; without one — an SRC method, or a method whose `In` is `never` — it delivers JSON-safe `unknown` values. The Client sends each item as an `item` frame on the open logical stream, a top-level `undefined` item as an `item` frame without `value`, and its half-close as an `end` frame; the method observes the half-close as the end of the `uplink()` iteration and cancellation through `signal`. A rejected item fails the whole stream with `gateway/input-invalid` (field `uplink`). Uplink items are validated one by one at the Host because they arrive from the browser; downlink items are values the Host method produced and pass through unchecked. When the method finishes its downlink the Gateway returns the uplink iterator and drops unread items; items sent to a method that never takes its uplink wait in the inbox until then. Each logical stream buffers at most `streamInboxBytes` (262144 by default) of uplink frame bytes; overflow fails the stream with `gateway/uplink-overflow`, and an item after `end` fails it with `gateway/protocol`, in both cases without closing the socket. `item`, `end`, and `cancel` frames for a stream id the Host has already finished are dropped; only a duplicate `open` closes the socket. Gateway-owned streams such as `$events` return their uplink as soon as they open, so their `item` frames are dropped instead of buffered. A unary method may also call `uplink()`; its items are readable only while the method runs. Every stream speaks for the operator Peer: a WebSocket's streams for the Peer Connection admitted at upgrade, whose scope disposal closes the socket, and an in-process carrier's for the operator directly.
|
||||
|
||||
Stream cancellation and stopping keep read-local state, not a history of delivered items, on the Host downlink, Host uplink, and Client uplink pump. Completing the downlink also wakes pending uplink reads.
|
||||
|
||||
Host composition can register one application event source through `registerRemoteEvents()`. Gateway reserves the internal `$events` logical endpoint for that source, accepts only empty `args`, and aborts streams opened by the registration when the source is withdrawn. API Remotes owns the event selection, argument validation, per-Client queues, and the Host home sent in the opening `{ type: 'ready', clientId, host: { home } }` frame. Its source factory attaches incremental listeners synchronously, so the Client publishes the generation and starts baseline reads only after incremental delivery is ready.
|
||||
|
||||
<a id="client-service-clientremote-ctx-key-remote"></a>
|
||||
|
||||
@@ -38,6 +38,8 @@ Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandle
|
||||
|
||||
每条流还在同一条逻辑流上携带从 Client 到 Host 的上行。方法把上行项类型声明为返回类型的第二个类型参数——来自 [`dsh-typert-protocol`](../../typert/protocol/README.zh.md) 的 `RemoteStream<Out, In>`;`In` 缺省为 `never`,返回 `Iterable`、`AsyncIterable` 或 `RemoteStream<Out>` 的方法即声明自己不读上行。运行中的方法通过 `this.ctx.invocation` 读取本次调用:Gateway 从携带该 `RemoteInvocation`(`request`、`service`、`peer`、`signal` 与 `uplink()`)的 Context 派生接收者,因此没有任何东西进入参数列表,而非 Remote 调用派生的 Context 上 `ctx.invocation` 为 `undefined`。`uplink()` 每次调用只能取一次。描述符带 codec 时它在交付前逐项解码;不带时——SRC 方法,或 `In` 为 `never` 的方法——它交付经 JSON 安全校验的 `unknown` 值。Client 在已打开的逻辑流上把每一项作为 `item` 帧发送(顶层 `undefined` 项是不带 `value` 的 `item` 帧),以 `end` 帧表示半关闭;方法通过 `uplink()` 迭代结束感知半关闭,通过 `signal` 感知取消。被拒绝的项使整条流以 `gateway/input-invalid`(字段 `uplink`)失败。上行项在 Host 逐项校验,因为它们来自浏览器;下行项是 Host 方法产出的值,不经校验原样透传。方法结束下行时 Gateway 调用 uplink 迭代器的 return 并丢弃未读的项;发给从未取用 uplink 的方法的项在 inbox 里等到那时为止。每条逻辑流最多缓冲 `streamInboxBytes`(默认 262144)字节的上行帧;超限使流以 `gateway/uplink-overflow` 失败,`end` 之后的项使流以 `gateway/protocol` 失败,两种情况都不关闭 socket。发给 Host 已结束的流 id 的 `item`、`end` 与 `cancel` 帧被丢弃;只有重复的 `open` 会关闭 socket。`$events` 这类 Gateway 自有的流在打开时立即 return 其 uplink,因此它们的 `item` 帧被丢弃而不缓冲。一元方法也可以调用 `uplink()`;其项只在方法运行期间可读。每条流都代表操作者 Peer:WebSocket 上的流代表 Connection 在升级时接纳的 Peer,该 scope 释放时 socket 关闭;进程内载体的流直接代表操作者。
|
||||
|
||||
Host 下行、Host 上行和 Client 上行泵的取消与停止状态归属单次读取,不保留已交付项的历史。下行结束也会唤醒尚未完成的上行读取。
|
||||
|
||||
Host 组合可通过 `registerRemoteEvents()` 注册唯一的应用事件 source。Gateway 为它保留内部 `$events` logical endpoint,只接受空 `args`,并在 source 撤回时中止该注册打开的流。事件名单、参数校验、每个 Client 的队列及 opening `{ type: 'ready', clientId, host: { home } }` frame 中的 Host home 由 API Remotes 拥有。source factory 在返回 iterable 前同步挂好增量 listener,因此 Client 只在增量投递就绪后发布 generation 并开始 baseline 读取。
|
||||
|
||||
<a id="client-service-clientremote-ctx-key-remote"></a>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-api-gateway",
|
||||
"description": "Typert Remote Host dispatcher and Client API endpoint",
|
||||
"version": "0.1.7-alpha.2",
|
||||
"version": "0.1.7-rc.1",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -158,7 +158,7 @@ export class RemoteStreamMuxClient {
|
||||
signal: AbortSignal,
|
||||
inbox: StreamInbox,
|
||||
): UplinkPump {
|
||||
const stopped = Promise.withResolvers<IteratorReturnResult<undefined>>()
|
||||
let stopped: PromiseWithResolvers<IteratorReturnResult<undefined>> | undefined
|
||||
const interruption: IteratorReturnResult<undefined> = { value: undefined, done: true }
|
||||
const uplinkIterator = uplink[Symbol.asyncIterator]()
|
||||
const state = { stopping: false, exhausted: false, released: false }
|
||||
@@ -171,7 +171,12 @@ export class RemoteStreamMuxClient {
|
||||
const done = (async (): Promise<void> => {
|
||||
try {
|
||||
while (true) {
|
||||
const stoppingBeforeRead = state.stopping
|
||||
if (stoppingBeforeRead) return
|
||||
// A stop promise belongs to one read, not the whole uplink history.
|
||||
stopped = Promise.withResolvers<IteratorReturnResult<undefined>>()
|
||||
const next = await Promise.race([uplinkIterator.next(), stopped.promise])
|
||||
stopped = undefined
|
||||
if (state.stopping || next === interruption || signal.aborted || this.socket !== socket) return
|
||||
if (next.done === true) {
|
||||
state.exhausted = true
|
||||
@@ -183,6 +188,7 @@ export class RemoteStreamMuxClient {
|
||||
} catch (error) {
|
||||
inbox.fail(error)
|
||||
} finally {
|
||||
stopped = undefined
|
||||
release()
|
||||
}
|
||||
})()
|
||||
@@ -191,7 +197,7 @@ export class RemoteStreamMuxClient {
|
||||
stop: () => {
|
||||
if (state.stopping) return
|
||||
state.stopping = true
|
||||
stopped.resolve(interruption)
|
||||
stopped?.resolve(interruption)
|
||||
release()
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1164,19 +1164,24 @@ async function *cancellableStream(
|
||||
? Reflect.apply(asyncFactory, source, []) as AsyncIterator<unknown>
|
||||
: Reflect.apply(syncFactory as (...args: never[]) => Iterator<unknown>, source, [])
|
||||
let rejectAbort: ((error: unknown) => void) | undefined
|
||||
const aborted = new Promise<never>((_resolve, reject) => { rejectAbort = reject })
|
||||
const onAbort = (): void => {
|
||||
rejectAbort?.(streamAbortFailure(endpoint, signal.reason))
|
||||
}
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
try {
|
||||
if (signal.aborted) throw streamAbortFailure(endpoint, signal.reason)
|
||||
while (true) {
|
||||
if (signal.aborted) throw streamAbortFailure(endpoint, signal.reason)
|
||||
// Reusing a pending cancellation promise retains every completed race.
|
||||
const aborted = new Promise<never>((_resolve, reject) => { rejectAbort = reject })
|
||||
// next() can abort and throw synchronously before the race subscribes.
|
||||
void aborted.catch(() => undefined)
|
||||
const next = await Promise.race([Promise.resolve(iterator.next()), aborted])
|
||||
rejectAbort = undefined
|
||||
if (next.done === true) return
|
||||
yield next.value
|
||||
}
|
||||
} finally {
|
||||
rejectAbort = undefined
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
// The uplink closes first so a method blocked on `uplink.next()` unwinds
|
||||
// before its iterator is asked to return.
|
||||
@@ -1209,9 +1214,10 @@ function remoteCancelled(endpoint: string, cause: unknown): RemoteError<'gateway
|
||||
*/
|
||||
class UplinkDecoder implements AsyncIterable<unknown>, AsyncIterator<unknown> {
|
||||
private readonly source: AsyncIterator<unknown>
|
||||
private readonly interrupted = Promise.withResolvers<IteratorResult<unknown>>()
|
||||
private readonly interrupted = new Set<PromiseWithResolvers<IteratorResult<unknown>>>()
|
||||
private readonly onAbort = (): void => {
|
||||
this.interrupted.reject(streamAbortFailure(this.endpoint, this.signal.reason))
|
||||
const failure = streamAbortFailure(this.endpoint, this.signal.reason)
|
||||
for (const read of this.interrupted) read.reject(failure)
|
||||
}
|
||||
private closed = false
|
||||
|
||||
@@ -1223,9 +1229,6 @@ class UplinkDecoder implements AsyncIterable<unknown>, AsyncIterator<unknown> {
|
||||
private readonly abort: (reason: unknown) => void,
|
||||
) {
|
||||
this.source = uplink[Symbol.asyncIterator]()
|
||||
// The rejection settles the race inside next(); an abort with no read in
|
||||
// flight must not surface as an unhandled rejection.
|
||||
void this.interrupted.promise.catch(() => undefined)
|
||||
signal.addEventListener('abort', this.onAbort, { once: true })
|
||||
}
|
||||
|
||||
@@ -1237,7 +1240,17 @@ class UplinkDecoder implements AsyncIterable<unknown>, AsyncIterator<unknown> {
|
||||
// A cancelled stream reports the cancellation on every read, closed or not.
|
||||
if (this.signal.aborted) throw streamAbortFailure(this.endpoint, this.signal.reason)
|
||||
if (this.closed) return UPLINK_DONE
|
||||
const next = await Promise.race([this.source.next(), this.interrupted.promise])
|
||||
// Only pending reads belong to the decoder; finish() must wake all of them.
|
||||
const interrupted = Promise.withResolvers<IteratorResult<unknown>>()
|
||||
this.interrupted.add(interrupted)
|
||||
// The source can abort and throw synchronously before the race subscribes.
|
||||
void interrupted.promise.catch(() => undefined)
|
||||
let next: IteratorResult<unknown>
|
||||
try {
|
||||
next = await Promise.race([this.source.next(), interrupted.promise])
|
||||
} finally {
|
||||
this.interrupted.delete(interrupted)
|
||||
}
|
||||
if (next.done === true) {
|
||||
this.finish()
|
||||
return UPLINK_DONE
|
||||
@@ -1270,7 +1283,7 @@ class UplinkDecoder implements AsyncIterable<unknown>, AsyncIterator<unknown> {
|
||||
private finish(): void {
|
||||
this.closed = true
|
||||
this.signal.removeEventListener('abort', this.onAbort)
|
||||
this.interrupted.resolve(UPLINK_DONE)
|
||||
for (const read of this.interrupted) read.resolve(UPLINK_DONE)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { once } from 'node:events'
|
||||
import { queryObjects } from 'node:v8'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import WebSocket, { type RawData } from 'ws'
|
||||
import { Context, symbols } from '@deepseek-ai/cordis'
|
||||
@@ -56,6 +57,7 @@ class FeedService extends TypertRemoteService {
|
||||
readonly peeked: unknown[] = []
|
||||
readonly peers: PeerScope[] = []
|
||||
leftover: AsyncIterator<string> | undefined
|
||||
source: Iterable<string> = []
|
||||
returns = 0
|
||||
|
||||
constructor(ctx: Context) {
|
||||
@@ -82,6 +84,11 @@ class FeedService extends TypertRemoteService {
|
||||
yield `${label}:two`
|
||||
}
|
||||
|
||||
@Remote({ mode: 'stream' })
|
||||
items(): Iterable<string> {
|
||||
return this.source
|
||||
}
|
||||
|
||||
@Remote({ mode: 'stream' })
|
||||
*invalid(): Iterable<string> {
|
||||
yield 42 as unknown as string
|
||||
@@ -541,6 +548,110 @@ describe('Typert Remote streams', () => {
|
||||
await expect(iterator.return?.()).resolves.toEqual({ done: true, value: undefined })
|
||||
})
|
||||
|
||||
it('finishes every pending uplink read when the downlink returns', async () => {
|
||||
const { ctx, service } = await setup(false)
|
||||
const reads = [
|
||||
Promise.withResolvers<IteratorResult<string>>(),
|
||||
Promise.withResolvers<IteratorResult<string>>(),
|
||||
]
|
||||
let index = 0
|
||||
const returned = vi.fn(async (): Promise<IteratorResult<string>> => ({ done: true, value: undefined }))
|
||||
const source = await ctx.typertGateway.stream({
|
||||
namespace: 'feed', method: 'hold', args: {},
|
||||
uplink: { [Symbol.asyncIterator]: () => ({ next: () => reads[index++]!.promise, return: returned }) },
|
||||
})
|
||||
const iterator = source[Symbol.asyncIterator]()
|
||||
const completed: IteratorResult<string>[] = []
|
||||
const pending: Promise<void>[] = []
|
||||
try {
|
||||
await expect(iterator.next()).resolves.toEqual({ done: false, value: 'held' })
|
||||
const uplink = service.leftover!
|
||||
pending.push(...reads.map(() => uplink.next().then((result) => { completed.push(result) })))
|
||||
await iterator.return?.()
|
||||
expect(completed).toEqual([
|
||||
{ done: true, value: undefined },
|
||||
{ done: true, value: undefined },
|
||||
])
|
||||
expect(returned).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
for (const read of reads) read.resolve({ done: true, value: undefined })
|
||||
await Promise.all(pending)
|
||||
await iterator.return?.()
|
||||
}
|
||||
})
|
||||
|
||||
it.each(['downlink', 'uplink'] as const)('releases consumed %s read results while the stream stays open', async (direction) => {
|
||||
class ReadResult implements IteratorYieldResult<string> {
|
||||
readonly done = false
|
||||
readonly value = 'item'
|
||||
}
|
||||
const { ctx, service } = await setup(false)
|
||||
service.source = { [Symbol.iterator]: () => ({ next: () => new ReadResult() }) }
|
||||
const source = await ctx.typertGateway.stream(direction === 'downlink'
|
||||
? { namespace: 'feed', method: 'items', args: {} }
|
||||
: {
|
||||
namespace: 'feed', method: 'echo', args: { prefix: '' },
|
||||
uplink: { [Symbol.asyncIterator]: () => ({ next: async () => new ReadResult() }) },
|
||||
})
|
||||
const iterator = source[Symbol.asyncIterator]()
|
||||
try {
|
||||
for (const count of [64, 256]) {
|
||||
for (let index = 0; index < count; index++) {
|
||||
await expect(iterator.next()).resolves.toEqual({ done: false, value: 'item' })
|
||||
}
|
||||
// queryObjects collects garbage; the suspended read can retain its last result, not the history.
|
||||
expect(queryObjects(ReadResult, { format: 'count' })).toBeLessThanOrEqual(2)
|
||||
}
|
||||
} finally {
|
||||
await iterator.return?.()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not read another downlink item after cancellation between reads', async () => {
|
||||
const { ctx, service } = await setup(false)
|
||||
const abort = new AbortController()
|
||||
const next = vi.fn((): IteratorResult<string> => ({ done: false, value: 'item' }))
|
||||
service.source = { [Symbol.iterator]: () => ({ next }) }
|
||||
const source = await ctx.typertGateway.stream({
|
||||
namespace: 'feed', method: 'items', args: {}, signal: abort.signal,
|
||||
})
|
||||
const iterator = source[Symbol.asyncIterator]()
|
||||
try {
|
||||
await expect(iterator.next()).resolves.toEqual({ done: false, value: 'item' })
|
||||
abort.abort(new Error('caller cancelled between reads'))
|
||||
await expect(iterator.next()).rejects.toMatchObject({ code: 'gateway/cancelled' })
|
||||
expect(next).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
await iterator.return?.()
|
||||
}
|
||||
})
|
||||
|
||||
it.each(['downlink', 'uplink'] as const)('handles a synchronous %s read that aborts and throws', async (direction) => {
|
||||
const { ctx, service } = await setup(false)
|
||||
const abort = new AbortController()
|
||||
const failure = new Error('source aborted and threw')
|
||||
const next = (): never => {
|
||||
abort.abort(failure)
|
||||
throw failure
|
||||
}
|
||||
service.source = { [Symbol.iterator]: () => ({ next }) }
|
||||
const source = await ctx.typertGateway.stream(direction === 'downlink'
|
||||
? { namespace: 'feed', method: 'items', args: {}, signal: abort.signal }
|
||||
: {
|
||||
namespace: 'feed', method: 'hold', args: {}, signal: abort.signal,
|
||||
uplink: { [Symbol.asyncIterator]: () => ({ next }) },
|
||||
})
|
||||
const iterator = source[Symbol.asyncIterator]()
|
||||
try {
|
||||
if (direction === 'uplink') {
|
||||
await expect(iterator.next()).resolves.toEqual({ done: false, value: 'held' })
|
||||
}
|
||||
await expect((direction === 'downlink' ? iterator : service.leftover!).next()).rejects.toBe(failure)
|
||||
} finally {
|
||||
await iterator.return?.()
|
||||
}
|
||||
})
|
||||
|
||||
it('reports done on every read after the uplink ended', async () => {
|
||||
const { ctx } = await setup(false)
|
||||
await expect(collect(await ctx.typertGateway.stream({
|
||||
@@ -1461,6 +1572,7 @@ function descriptors(): InvocationDescriptor[] {
|
||||
stream('context', [label], z.string()),
|
||||
{ ...stream('follow', [label], z.string()), cancellation: { parameter: 'signal' } },
|
||||
stream('sync', [label], z.string()),
|
||||
stream('items', [], z.string()),
|
||||
stream('invalid', [], z.string()),
|
||||
stream('nonJson', [], z.unknown()),
|
||||
stream('missing', [], z.string()),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { queryObjects } from 'node:v8'
|
||||
import { RemoteError, typertOwnedValue } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import { Context, Service } from '@deepseek-ai/cordis'
|
||||
import type { Fiber } from '@deepseek-ai/cordis'
|
||||
@@ -2804,6 +2805,81 @@ describe('Client Typert API', () => {
|
||||
})
|
||||
|
||||
describe('Remote stream client carrier lifecycle', () => {
|
||||
it('does not pull another uplink item when sending ends the stream', async () => {
|
||||
await withFakeWebSocket('https://harness.example', async () => {
|
||||
const client = new RemoteStreamMuxClient()
|
||||
client.start()
|
||||
const socket = FakeWebSocket.sockets[0]!
|
||||
const send = socket.send.bind(socket)
|
||||
const sent = vi.spyOn(socket, 'send').mockImplementation((text) => {
|
||||
send(text)
|
||||
const frame = parseRemoteStreamClientMessage(text)
|
||||
if (frame.type === 'item') socket.receive({ type: 'end', streamId: frame.streamId })
|
||||
})
|
||||
const next = vi.fn(async (): Promise<IteratorResult<string>> => ({ done: false, value: 'item' }))
|
||||
const returned = vi.fn(async (): Promise<IteratorResult<string>> => ({ done: true, value: undefined }))
|
||||
try {
|
||||
const iterator = client.open('probe/attach', {}, new AbortController().signal, {
|
||||
[Symbol.asyncIterator]: () => ({ next, return: returned }),
|
||||
})[Symbol.asyncIterator]()
|
||||
await expect(iterator.next()).resolves.toEqual({ done: true, value: undefined })
|
||||
expect(next).toHaveBeenCalledOnce()
|
||||
expect(returned).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
await client.close()
|
||||
sent.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('releases sent uplink read results while the stream stays open', async () => {
|
||||
await withFakeWebSocket('https://harness.example', async () => {
|
||||
class ReadResult implements IteratorYieldResult<string> {
|
||||
readonly done = false
|
||||
readonly value = 'item'
|
||||
}
|
||||
const blocked = Promise.withResolvers<undefined>()
|
||||
const lastRead = Promise.withResolvers<IteratorResult<string>>()
|
||||
let reads = 0
|
||||
const uplink: AsyncIterable<string> = {
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
next: () => {
|
||||
if (reads++ < 256) return Promise.resolve(new ReadResult())
|
||||
blocked.resolve(undefined)
|
||||
return lastRead.promise
|
||||
},
|
||||
return: async () => {
|
||||
lastRead.resolve({ done: true, value: undefined })
|
||||
return { done: true, value: undefined }
|
||||
},
|
||||
}),
|
||||
}
|
||||
const client = new RemoteStreamMuxClient()
|
||||
client.start()
|
||||
const iterator = client.open('probe/attach', {}, new AbortController().signal, uplink)[Symbol.asyncIterator]()
|
||||
const first = iterator.next()
|
||||
// Closing the carrier during failed assertions can reject this pending read.
|
||||
void first.catch(() => undefined)
|
||||
try {
|
||||
await blocked.promise
|
||||
const socket = FakeWebSocket.sockets[0]!
|
||||
expect(socket.sent).toHaveLength(257)
|
||||
const opened = parseRemoteStreamClientMessage(socket.sent[0]!)
|
||||
if (opened.type !== 'open') throw new Error('fixture expected an open frame')
|
||||
socket.receive({ type: 'item', streamId: opened.streamId, value: 'ready' })
|
||||
await expect(first).resolves.toEqual({ done: false, value: 'ready' })
|
||||
// A full GC runs while the uplink pump is still waiting for its next item.
|
||||
expect(queryObjects(ReadResult, { format: 'count' })).toBeLessThanOrEqual(2)
|
||||
socket.receive({ type: 'end', streamId: opened.streamId })
|
||||
await expect(iterator.next()).resolves.toEqual({ done: true, value: undefined })
|
||||
} finally {
|
||||
await client.close()
|
||||
await first.catch(() => undefined)
|
||||
await iterator.return?.(undefined)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('connects to the shell-owned Host while the document uses a local asset origin', async () => {
|
||||
await withFakeWebSocket('dsh-app://app', async () => {
|
||||
vi.stubGlobal('__DSH_TRANSPORT__', { streamBaseUrl: 'http://127.0.0.1:43210' })
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-api-job-controller",
|
||||
"description": "Job Remote observation stream and the reference-counted client job-output service",
|
||||
"version": "0.1.7-alpha.2",
|
||||
"version": "0.1.7-rc.1",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-api-remotes",
|
||||
"description": "Remote BFF assembly for application-selected Host capabilities",
|
||||
"version": "0.1.7-alpha.2",
|
||||
"version": "0.1.7-rc.1",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -27,7 +27,8 @@ import type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client'
|
||||
|
||||
export type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client'
|
||||
export type {
|
||||
BundleInfo, BundleRowInfo, ChangeResult, InspectOptions, InstallBundleOptions, InstallSpecKind, ManagementError, PackageResult,
|
||||
BundleInfo, BundleRowInfo, ChangeResult, IncompatiblePlugin, InspectOptions, InstallBundleOptions, InstallSpecKind, ManagementError,
|
||||
PackageResult,
|
||||
PluginChange, PluginEntryId, PluginInfo, PluginInspectProblem, PluginInstallCancellation, PluginInstallFailureKind,
|
||||
PluginInstallLogChunk, PluginInstallProgress, PluginInstallRequestId, PluginRegistries, PluginSpecInspection, ReadOnlyReason, Registry,
|
||||
} from '@deepseek-ai/dsh-plugin-manager/types'
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/api/session-controller/README.md
|
||||
README.md: fa2a251e94cfa2855790d7d71457ac4bdf2ff752
|
||||
README.zh.md: d1a73ed5843fddadfc2f401fee1a86eece4e5272
|
||||
README.md: 728d98b90aa96829a878b071c03af3ec961c839e
|
||||
README.zh.md: 9a2c54b6865302e69daa5f67ec9baa17255f8100
|
||||
@@ -43,7 +43,7 @@ Projection reads retain loading and failure state independently of their values.
|
||||
|
||||
Accepted prompts and observed running keep their Client display conversion across list refresh, reconnect, and Session-object replacement. The Manager retains these observations by Session id; they do not establish durable history. Drafts, projection stores, and sidebar display rules retain their existing behavior. The [blank rollback decision](../../../.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.md) defines retention and late-response handling.
|
||||
|
||||
The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, `append`, and `settle-assistant` changes, and repairs reconnect or sequence gaps through a tail page. Backwards paging has two verbs: `loadOlder()` pulls one Turn-aligned page, and `loadThrough(seq)` — the turn-jump loader — loops Turn-aligned pages until the window covers the target seq, lowering a shared target on repeated calls, stopping on a page that makes no progress, and reporting busy through the same `loadingOlder` snapshot bit. The Web adapter explicitly opts into cursorless Assistant frames: each opening carries the active attempt's `startedAfterSeq`, `nextIndex`, and compact stream, and every stream member becomes a Client-only `assistant/live-chunk` entry ordered between durable cursors. The Host captures a follower-local arrival ordinal with that baseline and suppresses buffered frames at or before the cut; a replacement Agent may restart frame revision at one. A durable `assistant/message` or `assistant/attempt` arriving after an active opening stays staged only when its seq follows `startedAfterSeq` and its Turn and Step match; the matching end type, seq, and index publishes one named settlement delta that retires the attempt's transient rows and adds the durable entry while earlier same-step retries remain visible. Revision, dense-index, or settlement gaps for a known attempt reopen follow, while a controller that missed the start ignores unknown-attempt frames and publishes their durable settlement normally. An abandoned end publishes a settlement delta without a durable entry so its transient rows retire immediately. A durable gap-repair page has no Assistant baseline, so its held notification reopens follow once for a paired page and baseline. Every history record covers exactly its event seq. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces projection state instead of treating transient values as durable events. At each ready Host generation, a synchronous Client subscription clears retained projection values and watermarks before refreshing queries and restarting the control stream, including Sessions absent from the control baseline. The first control stream waits for generation readiness, so its opening values cannot precede invalidation. Outstanding list responses from the previous generation cannot republish those values. Within a generation, a delayed control baseline cannot overwrite or clear newer sequenced values, whether they arrived through a live Session's list block, a history page, or a frame; a cached list block, viewed from the projection cache for a cold Session, yields to the connected Session's baseline whatever watermark it carries. The durable `inbox` projection carries both pending lists through the same cold-read and reconnect path as other projections. Client Agent contexts provide the identity used by the independent [`fileUpload`](../../client/file-upload/README.md) service; Session objects expose lifecycle, prompt, queue, and history operations rather than file transfer.
|
||||
The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, `append`, and `settle-assistant` changes, and repairs reconnect or sequence gaps through a tail page. Backwards paging has two verbs: `loadOlder()` pulls one Turn-aligned page, and `loadThrough(seq)` — the turn-jump loader — loops Turn-aligned pages until the window covers the target seq, lowering a shared target on repeated calls, stopping on a page that makes no progress, and reporting busy through the same `loadingOlder` snapshot bit. The Web adapter explicitly opts into cursorless Assistant frames: each opening carries the active attempt's `startedAfterSeq`, `nextIndex`, and compact stream, and every stream member becomes a Client-only `assistant/live-chunk` entry ordered between durable cursors. The Host captures a follower-local arrival ordinal with that baseline and suppresses buffered frames at or before the cut; a replacement Agent may restart frame revision at one. A durable `assistant/message` or `assistant/attempt` arriving after an active opening stays staged only when its seq follows `startedAfterSeq` and its Turn and Step match; the matching end type, seq, and index releases the durable entry. A successful message retains its transient rows until the owning `step/end`, so pending tool identities remain available for dispatch. Failed or interrupted settlements retire their rows immediately; earlier same-step retries remain visible. Revision, dense-index, or settlement gaps for a known attempt reopen follow, while a controller that missed the start ignores unknown-attempt frames and publishes their durable settlement normally. An abandoned end publishes a settlement delta without a durable entry so its transient rows retire immediately. A durable gap-repair page has no Assistant baseline, so its held notification reopens follow once for a paired page and baseline. Every history record covers exactly its event seq. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces projection state instead of treating transient values as durable events. At each ready Host generation, a synchronous Client subscription clears retained projection values and watermarks before refreshing queries and restarting the control stream, including Sessions absent from the control baseline. The first control stream waits for generation readiness, so its opening values cannot precede invalidation. Outstanding list responses from the previous generation cannot republish those values. Within a generation, a delayed control baseline cannot overwrite or clear newer sequenced values, whether they arrived through a live Session's list block, a history page, or a frame; a cached list block, viewed from the projection cache for a cold Session, yields to the connected Session's baseline whatever watermark it carries. The durable `inbox` projection carries both pending lists through the same cold-read and reconnect path as other projections. Client Agent contexts provide the identity used by the independent [`fileUpload`](../../client/file-upload/README.md) service; Session objects expose lifecycle, prompt, queue, and history operations rather than file transfer.
|
||||
|
||||
The Session object also carries local submission echoes: `session.beginSubmission` inserts one into `SessionSnapshot.pendingSubmissions` synchronously, before the caller serializes and prompts, so a conversation UI can show the message on the submit click's own frame. The echo stores ordered image previews and durable file references. Session derives its `transcript`, `queued`, or `steering` placement from the current running state and requested delivery mode, then retains that placement until the display handoff. The prompt's `requestId` is the correlation identity: the Host echoes it as the durable user source's `rpcId`, including pending messages in the `inbox` projection. Queued echoes retire one animation frame after queue acceptance; Chat echoes follow the admission rule below. Identified failures and abandonment retire unadmitted echoes immediately. Disposal preserves observed admission and fails other unsettled submissions. Each retirement fires `onRetire` exactly once; an observed retirement includes the ordered durable attachment references so the composer can release successful cards while preserving failed drafts. Echoes are Client memory only; reload and reconnect rebuild the conversation from durable events alone.
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ Client 列表刷新保留未变化的行对象,并在顺序和值均相同时
|
||||
|
||||
已受理的提示词与已观察到的运行,会使客户端展示转换跨列表刷新、重连及 Session 对象替换保留。Manager 按 Session id 保留这些观察;它们不证明持久历史已经产生。草稿、投影存储和侧边栏展示规则保留原有行为。[blank 回退修复决策](../../../.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.zh.md) 定义保留期限及迟到响应处理。
|
||||
|
||||
Client 适配器提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend`、`append` 与 `settle-assistant` 变更,并通过 tail page 修复重连或 seq 缺口。向后分页有两个动词:`loadOlder()` 拉一页按轮次开头对齐的历史,而 `loadThrough(seq)`——轮次跳转加载器——按轮次开头对齐页面循环拉取直到窗口覆盖目标 seq,重复调用会下调共享目标,遇到无进展的页即停止,忙碌状态复用同一个 `loadingOlder` 快照位。Web 适配器显式选择接收无 cursor 的 Assistant frame:每个 opening 携带活跃 attempt 的 `startedAfterSeq`、`nextIndex` 与紧凑 stream,每个 stream member 都成为排在持久 cursor 之间的 Client-only `assistant/live-chunk` 条目。Host 会随该 baseline 捕获 follower 本地到达序号,并抑制该 cut 及之前的 buffered frame;replacement Agent 可以从 revision 一重新开始。活跃 opening 之后到达的持久 `assistant/message` 或 `assistant/attempt` 只有在其 seq 晚于 `startedAfterSeq` 且轮次与步骤匹配时才会保持暂存;匹配的 end type、seq 与 index 会发布一个具名 settlement delta,删除该 attempt 的瞬态 row、加入持久条目,并保留同一步骤中更早的 retry。已知 attempt 的 revision、密集 index 或 settlement 缺口会重新打开 follow;若 controller 错过 start,则忽略 unknown-attempt frame,并正常发布其持久 settlement。Abandoned end 会发布不含持久条目的 settlement delta,使瞬态 row 立即退出。持久缺口修复 page 不携带 Assistant baseline,因此 held notification 会重新打开 follow 一次,以取得配对的 page 与 baseline。每条历史 record 只覆盖自身的事件 seq。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 projection 状态,而不会把瞬态值当作 durable event。每次 Host generation 就绪时,同步的 Client 订阅会先清除保留的投影值及其水位,再刷新查询并重新打开 control stream,其中也包括 control baseline 中没有列出的 Session。首次 control stream 会等待 generation 就绪,确保其 opening 值不会先于旧状态清理到达。上一代尚未完成的 list 响应无法重新发布这些值。同一 generation 内,延迟到达的 control baseline 不能覆盖或清除较新的 sequenced 值,无论它们来自活 Session 的 list block、history page 还是 frame;冷 Session 从 projection cache 看出来的 cached list block 则不论水位都让位于建连 Session 的 baseline。持久 `inbox` 投影通过与其他投影相同的冷读取和重连路径传输两份待处理列表。Client Agent 上下文提供独立 [`fileUpload`](../../client/file-upload/README.zh.md) 服务使用的身份;Session 对象提供生命周期、prompt、queue 与历史操作,不提供文件传输。
|
||||
Client 适配器提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend`、`append` 与 `settle-assistant` 变更,并通过 tail page 修复重连或 seq 缺口。向后分页有两个动词:`loadOlder()` 拉一页按轮次开头对齐的历史,而 `loadThrough(seq)`——轮次跳转加载器——按轮次开头对齐页面循环拉取直到窗口覆盖目标 seq,重复调用会下调共享目标,遇到无进展的页即停止,忙碌状态复用同一个 `loadingOlder` 快照位。Web 适配器显式选择接收无 cursor 的 Assistant frame:每个 opening 携带活跃 attempt 的 `startedAfterSeq`、`nextIndex` 与紧凑 stream,每个 stream member 都成为排在持久 cursor 之间的 Client-only `assistant/live-chunk` 条目。Host 会随该 baseline 捕获 follower 本地到达序号,并抑制该 cut 及之前的 buffered frame;replacement Agent 可以从 revision 一重新开始。活跃 opening 之后到达的持久 `assistant/message` 或 `assistant/attempt` 只有在其 seq 晚于 `startedAfterSeq` 且轮次与步骤匹配时才会保持暂存;匹配的 end type、seq 与 index 会发布持久条目。成功消息的瞬态 row 保留到所属 `step/end`,让待派发工具的身份持续可用;失败或中断的 settlement 立即移除对应 row,同一步骤中更早的 retry 仍然保留。已知 attempt 的 revision、密集 index 或 settlement 缺口会重新打开 follow;若 controller 错过 start,则忽略 unknown-attempt frame,并正常发布其持久 settlement。Abandoned end 会发布不含持久条目的 settlement delta,使瞬态 row 立即退出。持久缺口修复 page 不携带 Assistant baseline,因此 held notification 会重新打开 follow 一次,以取得配对的 page 与 baseline。每条历史 record 只覆盖自身的事件 seq。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 projection 状态,而不会把瞬态值当作 durable event。每次 Host generation 就绪时,同步的 Client 订阅会先清除保留的投影值及其水位,再刷新查询并重新打开 control stream,其中也包括 control baseline 中没有列出的 Session。首次 control stream 会等待 generation 就绪,确保其 opening 值不会先于旧状态清理到达。上一代尚未完成的 list 响应无法重新发布这些值。同一 generation 内,延迟到达的 control baseline 不能覆盖或清除较新的 sequenced 值,无论它们来自活 Session 的 list block、history page 还是 frame;冷 Session 从 projection cache 看出来的 cached list block 则不论水位都让位于建连 Session 的 baseline。持久 `inbox` 投影通过与其他投影相同的冷读取和重连路径传输两份待处理列表。Client Agent 上下文提供独立 [`fileUpload`](../../client/file-upload/README.zh.md) 服务使用的身份;Session 对象提供生命周期、prompt、queue 与历史操作,不提供文件传输。
|
||||
|
||||
Session 对象还承载本地提交回显:`session.beginSubmission` 在调用方序列化与提示词之前,同步把一条回显写入 `SessionSnapshot.pendingSubmissions`,会话 UI 因此能在点击提交的当帧显示消息。回显按顺序存放图片预览与持久文件引用。Session 根据当前运行状态与请求的投递模式推导其 `transcript`、`queued` 或 `steering` 位置,并保留该位置直到展示接管。提示词的 `requestId` 是关联标识:Host 把它回显为 durable user source 的 `rpcId`,`inbox` 投影中的待处理消息也保留同一 source。排队回显在队列接受后延迟一个动画帧退休;Chat 回显遵循下述入档规则。尚未入档的回显在带标识的提示词失败或被放弃时立即退休。销毁时保留已观察到的入档结果,其余未结算提交按 failed 退休。每次退休恰好触发一次 `onRetire`;observed 退休还会携带有序的持久附件引用,让 composer 释放成功卡片并保留失败草稿。回显只存在于 Client 内存;刷新与重连只从持久事件重建会话。
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-api-session-controller",
|
||||
"description": "Session Remote commands, cold reads, and live control transport",
|
||||
"version": "0.1.7-alpha.2",
|
||||
"version": "0.1.7-rc.1",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -24,7 +24,12 @@ interface ActiveAttempt {
|
||||
|
||||
/** One Web publication decision from the assistant stream fold. */
|
||||
export type ClientAssistantStreamResult =
|
||||
| { readonly type: 'publish'; readonly entry: SessionLiveEventEntry }
|
||||
| {
|
||||
readonly type: 'publish'
|
||||
readonly entry: SessionLiveEventEntry
|
||||
/** Retire the successful attempt's transient rows after publishing its Step end. */
|
||||
readonly retireAttemptId?: LlmAttemptId
|
||||
}
|
||||
| {
|
||||
readonly type: 'settlement'
|
||||
readonly attemptId: LlmAttemptId
|
||||
@@ -38,6 +43,7 @@ export type ClientAssistantStreamResult =
|
||||
/** Keeps transient Assistant presentation behind one settlement-aware interface. */
|
||||
export class ClientAssistantStream {
|
||||
private activeAttempt: ActiveAttempt | undefined
|
||||
private retainedAttempt: Pick<ActiveAttempt, 'attemptId' | 'turn' | 'step'> | undefined
|
||||
private readonly pending = new Map<number, SessionAssistantSettlementEntry>()
|
||||
private publishedSeqs = new Set<number>()
|
||||
private durableCursor = -1
|
||||
@@ -56,6 +62,7 @@ export class ClientAssistantStream {
|
||||
this.pending.clear()
|
||||
this.transientInGap = 0
|
||||
this.activeAttempt = undefined
|
||||
this.retainedAttempt = undefined
|
||||
const opening = baseline?.activeAttempt
|
||||
if (opening !== undefined) {
|
||||
this.activeAttempt = {
|
||||
@@ -114,13 +121,16 @@ export class ClientAssistantStream {
|
||||
|
||||
/**
|
||||
* Fold one dense transient frame and release its named durable settlement.
|
||||
* Successful messages retain their transient rows until the owning Step ends;
|
||||
* interrupted messages, failed attempts, and abandonment retire them immediately.
|
||||
* @param frame - next Assistant stream frame received by the follow connection.
|
||||
* @returns a transient, publication, or rebaseline decision, or `undefined` when no entry becomes visible.
|
||||
*/
|
||||
acceptFrame(frame: SessionAssistantStreamFrame): ClientAssistantStreamResult {
|
||||
switch (frame.type) {
|
||||
case 'start':
|
||||
if (this.activeAttempt !== undefined || this.pending.size > 0) return { type: 'rebaseline' }
|
||||
if (this.activeAttempt !== undefined || this.retainedAttempt !== undefined
|
||||
|| this.pending.size > 0) return { type: 'rebaseline' }
|
||||
this.pending.clear()
|
||||
this.activeAttempt = {
|
||||
attemptId: frame.attemptId,
|
||||
@@ -176,6 +186,10 @@ export class ClientAssistantStream {
|
||||
return { type: 'rebaseline' }
|
||||
}
|
||||
this.pending.delete(frame.outcome.seq)
|
||||
if (entry.event.type === 'assistant/message' && entry.event.data.interrupted !== true) {
|
||||
this.retainedAttempt = { attemptId: attempt.attemptId, turn: attempt.turn, step: attempt.step }
|
||||
return this.publish(entry)
|
||||
}
|
||||
this.publishedSeqs.add(entry.event.seq)
|
||||
return { type: 'settlement', attemptId: attempt.attemptId, entry }
|
||||
}
|
||||
@@ -196,6 +210,12 @@ export class ClientAssistantStream {
|
||||
|
||||
private publish(entry: SessionLiveEventEntry): ClientAssistantStreamResult {
|
||||
this.publishedSeqs.add(entry.event.seq)
|
||||
const retained = this.retainedAttempt
|
||||
if (retained !== undefined && entry.event.type === 'step/end'
|
||||
&& entry.event.data.turn === retained.turn && entry.event.data.step === retained.step) {
|
||||
this.retainedAttempt = undefined
|
||||
return { type: 'publish', entry, retireAttemptId: retained.attemptId }
|
||||
}
|
||||
return { type: 'publish', entry }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -723,8 +723,10 @@ export class Session implements SessionFace {
|
||||
this.notifier.markDirty()
|
||||
return
|
||||
}
|
||||
if (result?.type === 'publish' && this.appendLive(result.entry)) {
|
||||
this.notifier.markDirty()
|
||||
if (result?.type === 'publish') {
|
||||
const changed = this.appendLive(result.entry)
|
||||
if (result.retireAttemptId !== undefined) this.eventSource.settleAssistant(result.retireAttemptId)
|
||||
if (changed || result.retireAttemptId !== undefined) this.notifier.markDirty()
|
||||
} else if (result?.type === 'transient') {
|
||||
this.eventSource.append(result.entry)
|
||||
this.notifier.markDirty()
|
||||
|
||||
@@ -136,14 +136,48 @@ describe('ClientAssistantStream', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('stages one owned settlement and releases it from the matching end frame', () => {
|
||||
it('publishes a successful message at stream end and retires its chunks at its Step end', () => {
|
||||
const stream = opened()
|
||||
const durable = messageEvent(2)
|
||||
expect(stream.acceptDurable(durable)).toBeUndefined()
|
||||
expect(stream.acceptFrame(chunkFrame(0))).toEqual(expect.objectContaining({ type: 'transient' }))
|
||||
expect(stream.acceptFrame(end(1, {
|
||||
kind: 'committed', eventType: 'assistant/message', seq: 2,
|
||||
}))).toEqual({ type: 'settlement', attemptId: String(ATTEMPT), entry: durable })
|
||||
}))).toEqual({ type: 'publish', entry: durable })
|
||||
for (const [turn, step] of [[2, 1], [1, 2]]) {
|
||||
const unrelated = entry({ type: 'step/end', seq: SessionSeq(3), time: 3, data: { turn: turn!, step: step! } })
|
||||
expect(stream.acceptDurable(unrelated)).toEqual({ type: 'publish', entry: unrelated })
|
||||
}
|
||||
const closed = entry({ type: 'step/end', seq: SessionSeq(4), time: 4, data: { turn: 1, step: 1 } })
|
||||
expect(stream.acceptDurable(closed)).toEqual({ type: 'publish', entry: closed, retireAttemptId: ATTEMPT })
|
||||
expect(stream.acceptDurable(ordinary(5))).toEqual({ type: 'publish', entry: ordinary(5) })
|
||||
expect(stream.acceptFrame(start(LlmAttemptId('session:2'), 4))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('clears retained chunks on replacement and refuses a new attempt before Step completion', () => {
|
||||
const stream = opened()
|
||||
const durable = messageEvent(2)
|
||||
stream.acceptDurable(durable)
|
||||
stream.acceptFrame(end(0, { kind: 'committed', eventType: 'assistant/message', seq: 2 }))
|
||||
expect(stream.acceptFrame(start(LlmAttemptId('session:2')))).toEqual({ type: 'rebaseline' })
|
||||
expect(stream.replace([durable])).toEqual([durable])
|
||||
const closed = entry({ type: 'step/end', seq: SessionSeq(3), time: 3, data: { turn: 1, step: 1 } })
|
||||
expect(stream.acceptDurable(closed)).toEqual({ type: 'publish', entry: closed })
|
||||
expect(stream.acceptFrame(start(LlmAttemptId('session:2'), 3))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('retires failed attempts and interrupted messages at stream end', () => {
|
||||
const message = messageEvent(2)
|
||||
if (message.event.type !== 'assistant/message') throw new Error('expected assistant message')
|
||||
const interrupted = entry({ ...message.event, data: { ...message.event.data, interrupted: true as const } })
|
||||
for (const durable of [attemptEvent(2), interrupted]) {
|
||||
const stream = opened()
|
||||
stream.acceptDurable(durable)
|
||||
expect(stream.acceptFrame(end(0, {
|
||||
kind: 'committed', eventType: durable.event.type as 'assistant/message' | 'assistant/attempt', seq: 2,
|
||||
}))).toEqual({ type: 'settlement', attemptId: ATTEMPT, entry: durable })
|
||||
expect(stream.acceptFrame(start(LlmAttemptId('session:2'), 2))).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('rebaselines duplicate durable settlements or starts', () => {
|
||||
|
||||
@@ -164,7 +164,7 @@ describe('scope tree', () => {
|
||||
expect(b.mock.remote.session.projections).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('publishes transient Assistant chunks and the named durable v2 settlement through one event source', async ({ bench }) => {
|
||||
it('publishes the final Assistant message before retiring transient chunks at Step end', async ({ bench }) => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 's1' }])
|
||||
using _reference = b.svc.retain(sid('s1'), { source: 'controllerOperation' })
|
||||
@@ -232,13 +232,20 @@ describe('scope tree', () => {
|
||||
})
|
||||
await b.mock.streams.drained(FOLLOW)
|
||||
await vi.waitFor(() => {
|
||||
expect(binding.eventSource.getSnapshot().entries).toHaveLength(1)
|
||||
expect(binding.eventSource.getSnapshot().entries).toHaveLength(2)
|
||||
})
|
||||
|
||||
expect(publications).toEqual([
|
||||
['assistant/live-chunk'],
|
||||
['assistant/message'],
|
||||
['assistant/live-chunk', 'assistant/message'],
|
||||
])
|
||||
b.mock.streams.push(FOLLOW, {
|
||||
type: 'event', event: { type: 'step/end', seq: 1, time: 3, data: { turn: 1, step: 1 } },
|
||||
})
|
||||
await b.mock.streams.drained(FOLLOW)
|
||||
expect(binding.eventSource.getSnapshot().entries.map(entry => entry.event.type))
|
||||
.toEqual(['assistant/message', 'step/end'])
|
||||
expect(binding.eventSource.getSnapshot().change).toEqual({ kind: 'settle-assistant', attemptId })
|
||||
dispose()
|
||||
})
|
||||
|
||||
@@ -373,10 +380,10 @@ describe('scope tree', () => {
|
||||
await b.mock.streams.drained(FOLLOW)
|
||||
await vi.waitFor(() => {
|
||||
expect(binding.eventSource.getSnapshot().entries.map(entry => entry.event.type))
|
||||
.toEqual(['assistant/message', 'assistant/message'])
|
||||
.toEqual(['assistant/message', 'assistant/live-chunk', 'assistant/message'])
|
||||
})
|
||||
expect(binding.eventSource.getSnapshot().change).toEqual({
|
||||
kind: 'settle-assistant', attemptId: String(attemptId), entry: currentMessage,
|
||||
kind: 'append', entries: [currentMessage],
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-api-settings-controller",
|
||||
"description": "Remote owner for the configuration surfaces over the settings-domain seams",
|
||||
"version": "0.1.7-alpha.2",
|
||||
"version": "0.1.7-rc.1",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-api-terminal-controller",
|
||||
"description": "Session-owned interactive terminals with shell discovery, screen recovery and typed Remote control",
|
||||
"version": "0.1.7-alpha.2",
|
||||
"version": "0.1.7-rc.1",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-api-workspace-controller",
|
||||
"description": "Workspace Remote commands and reconnect-safe state transport",
|
||||
"version": "0.1.7-alpha.2",
|
||||
"version": "0.1.7-rc.1",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-api-workspace-files",
|
||||
"description": "Workspace file service and Client resource provider: bounded reads, directory listing, and live metadata over the workspaceFiles Remote namespace",
|
||||
"version": "0.1.7-alpha.2",
|
||||
"version": "0.1.7-rc.1",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-attachment-local",
|
||||
"description": "Private content-addressed DSH_HOME attachment storage",
|
||||
"version": "0.1.7-alpha.2",
|
||||
"version": "0.1.7-rc.1",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-attachment",
|
||||
"description": "Durable immutable attachment storage seam for the DeepSeek Harness",
|
||||
"version": "0.1.7-alpha.2",
|
||||
"version": "0.1.7-rc.1",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/boot/app-boot/README.md
|
||||
README.md: 70a8e235ac5846061ea47e79e7dc575881110a50
|
||||
README.zh.md: 9849b033e01827a4c50a22b72ea20deebb0fdd85
|
||||
README.md: c32f8e4dd9065897a7dcbe4b7a2fd2e067b25e46
|
||||
README.zh.md: 0bbdab2a5d51deeb6b63bf972e6550f9b4034db1
|
||||
@@ -49,6 +49,12 @@ Import profile and bundle declaration types from [`@deepseek-ai/dsh-package-mani
|
||||
|
||||
A profile is how one dsh installation ships different app surfaces: `web`, `headless`, `acp`, `sdk`, and `sdk-minimal` start distinct compositions from the same launcher. A profile lives at `$DSH_HOME/profiles/<name>` and combines installable bundles with its own `cordis.patch.yml`. A bundle's `dsh.bundle.patch` names one patch file or an ordered list of files; `bundlePatchFiles` validates the declaration and `bundlePatchPaths` resolves it to absolute paths; the layer concatenates their patch lists in that order. The YAML composition enables or disables HMR. The shipped `web` template uses live reload, while the other shipped templates apply patches only at startup. `sdk-minimal` names only its standalone bundle; the other templates retain base-plus-mode stacks. `dsh --profile <name> --from-default-profile <template>` creates a custom profile at a new non-shipped name from one shipped template, while `dsh plugin` initializes a base-backed profile and manages its installed bundles. Bundle resolution, manifest, and patch-loading failures print a diagnostic and skip that bundle without changing its selection. Remaining bundles keep their order; profile and user-patch errors still fail startup. Skipping a bundle does not guarantee that the remaining composition can provide the required services. Application-owned npm projects, such as Electron's reserved Desktop profile, use `loadProfileDirectory` to load an already initialized directory without exposing it through CLI profile lookup.
|
||||
|
||||
Before a profile imports a plugin, DSH checks its `peerDependencies` on `@deepseek-ai/dsh` and `@deepseek-ai/dsh-*` against the single runtime version returned by `getDshRuntimeVersion()`. Every declared range must match; prereleases participate in range matching. Source-workspace `workspace:^`, `workspace:~`, and `workspace:*` refer to that same runtime. Missing DSH peers impose no constraint; invalid ranges are incompatible. These checks use peer declarations, not `engines.dsh`, and are not a sandbox against malicious package code.
|
||||
|
||||
Admission happens only at composition boundaries DSH owns, and a denied row is denied in the launcher's own copy of the composition: no profile patch layer, dependency manifest, or bundle list changes. `prepareProfilePatches` composes over the launcher's empty profile root and runs while the root Include is mounted and on every profile recomposition, so a denied plugin never imports its module. `prepareProfileEntries` does the same for a preset's rows. A denied ordinary row becomes a detached `disabled: true` row; a native group stays mounted and its denied children do not load; a native Include that reaches a denied plugin is omitted whole, because its file is never rewritten. A row denied by policy keeps its configured `disabled` value in the profile, and every refusal is reported with the package, versions, and risk. A bundle is not a row, so `loadProfileDirectory` checks each bundle's own DSH peers when it loads the profile's bundle layers, at startup and on every recomposition; an incompatible bundle without an exemption is skipped like an unreadable one. These boundaries do not cover plugins another embedder mounts through its own `ctx.plugin` call. Two edits made directly to files during a session are judged only at the next recomposition or startup: a running plugin's own `package.json` peer declaration, and an entry-list file the Loader reads itself, such as the launcher's root config or a nested `cordis:include` file. `--dump-config` reports the configured composition, so a denied plugin row still appears there while a denied bundle contributes no rows, and `--dump-config-schema` imports each composed module to read its schema; run it only against a profile whose plugins you already trust.
|
||||
|
||||
Exact-version exemptions live in the profile's own `compatibility.json`, not in its `package.json`, so an exemption write never touches the dependency manifest, the bundle list, or Cordis patch files. It maps exact `package-name@version` keys to lists of exact DSH runtime versions; neither a plugin upgrade nor a DSH upgrade inherits permission. A missing file means no exemptions. A malformed one never stops the profile from starting: records the reader accepts still apply, each rejected record is reported on stderr beside the plugin denials, and the file is then treated as read-only, so a grant or revocation refuses and asks the user to repair it by hand instead of overwriting their content. [Plugin Manager](../plugin-manager/README.md#version-compatibility-and-exemptions) owns the grant, revocation, and risk confirmation each change requires.
|
||||
|
||||
Your machine-local preferences also live in the Harness home:
|
||||
|
||||
- **`.env`** — your ordinary environment layers: the invoking directory's file outranks the Harness-home file, and both sit below the inherited environment. Variables that decide how the process starts (`PATH`, `DSH_*`, `XDG_*` and similar) are rejected from files: export them instead. The four proxy names (`HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, `NO_PROXY`) are accepted from the Harness-home file only, never from the invoking directory's, which arrives with a clone. For a non-product bin that just wants one directory's `.env`, a missing file is fine and an unloadable one prints one labelled warning line.
|
||||
@@ -122,6 +128,7 @@ This section explains how the outcomes above are realized and points at the code
|
||||
|
||||
### Design notes
|
||||
|
||||
- **Runtime version.** `getDshRuntimeVersion()` reads this package's manifest through a filesystem path, including the executable's virtual filesystem; missing or invalid versions fail rather than bypassing compatibility checks.
|
||||
- **Profile launch data.** `ctx.profileContext` contains only profile locations, startup bundle names, parsed invocation overlays and the telemetry opt-out value. `readProfilePatches()` composes the supplied startup profile or reads current files at those locations; callers schedule and apply the result.
|
||||
- **Process-local module resolution.** The launcher installs the runtime resolution on Node's internal ESM and CommonJS resolvers before profile rows mount. Node still owns exports, conditions, subpaths, module caches, and error codes; routed ESM failures report the original importer. Explicit CommonJS `paths` always retain native lookup, including paths inside profiles.
|
||||
- **Linked directories.** A profile link to an external directory admits its importers to peer-aware ancestor lookup, even without its own `package.json`. At each `D/node_modules` position, current `D/package.json` peer names present in the runtime table use the runtime package; other names use the physical candidate. A nearer physical package precedes a later peer declaration, and a peer position needs no physical `node_modules`. Installation-scope package directories are excluded from linked interception; overlapping roots do not change the importer's lookup order ([rule](../../../.agents/notes/implemented/architecture/2026-09-19-profile-resolution-lookup-order.md)).
|
||||
@@ -192,7 +199,7 @@ These limits describe when this boot library is a poor fit or needs special care
|
||||
|
||||
- **Runtime resolution depends on Node internals** — supported Node versions require the native builtin-access addon and executable compatibility coverage. Only built Harness-owned Workers receive the runtime resolution bootstrap; third-party Workers and custom `vm` linkers keep native resolution.
|
||||
- **Relinking a profile package needs a restart** — Node caches real paths, so changing the target of a profile link or a dependency link requires a process restart.
|
||||
- **Linked scope follows recorded real directories** — a hoisted dependency outside every linked root uses native Node. Current peer reads do not invalidate Node caches, watch files, or validate peer version ranges.
|
||||
- **Linked scope follows recorded real directories** — a hoisted dependency outside every linked root uses native Node. A peer read never invalidates Node caches or changes which files are watched.
|
||||
- **Source launches use an ESM-only hook** — CommonJS requests still need the JavaScript files selected by package exports; resolution does not supply missing build outputs.
|
||||
- **Snapshot replay swapping is basename-specific** — only a config ending in `cordis.yml` or `cordis.yaml` maps to the sibling `cordis.snapshot.yml`; custom config names require caller-managed selection.
|
||||
- **Environment discovery is launch-scoped** — `loadLayeredEnv` reads only the invocation directory and Harness home once; it does not search parents or follow a workspace selected later. `loadEnv` remains the one-directory helper for non-product bins.
|
||||
|
||||
@@ -49,6 +49,12 @@ Profile 与组合包的声明类型从 [`@deepseek-ai/dsh-package-manifest`](../
|
||||
|
||||
profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`headless`、`acp`、`sdk` 与 `sdk-minimal` 从同一 launcher 启动不同组合。profile 位于 `$DSH_HOME/profiles/<name>`,由可安装组合包和自身 `cordis.patch.yml` 组成。组合包的 `dsh.bundle.patch` 指定一个 patch 文件或一个有序的文件列表;`bundlePatchFiles` 校验该声明,`bundlePatchPaths` 把它解析为绝对路径;该层按此顺序拼接各文件的 patch 列表。YAML 组合决定是否启用 HMR。随产品交付的 `web` 模板实时重载,其他随附模板只在启动时应用 patch。`sdk-minimal` 只列出自身的独立组合包,其他模板保留 base 加模式的组合包栈。`dsh --profile <name> --from-default-profile <template>` 从一个随附模板,在新的非内置名称处创建自定义 profile;`dsh plugin` 则初始化以 base 为基础的 profile,并管理其中安装的组合包。组合包解析、manifest 读取或 patch 加载失败时会输出诊断并跳过该组合包,不改变其选择状态。其余组合包保持原顺序;profile 和用户 patch 错误仍会导致启动失败。跳过组合包不保证剩余组合能够提供所需服务。由应用持有的 npm 项目(例如 Electron 保留的 Desktop profile)通过 `loadProfileDirectory` 加载已经初始化的目录,而不会将它暴露给 CLI profile 查找。
|
||||
|
||||
profile 导入插件前,DSH 会检查其 `peerDependencies` 中对 `@deepseek-ai/dsh` 和 `@deepseek-ai/dsh-*` 的依赖,与 `getDshRuntimeVersion()` 返回的唯一运行时版本比较。每个声明的版本范围都必须匹配;预发布版本参与范围匹配。源码工作区的 `workspace:^`、`workspace:~` 和 `workspace:*` 指向同一个运行时。未声明 DSH peer 时不施加版本约束;无效范围视为不兼容。这些检查使用 peer 声明,而不是 `engines.dsh`,也不是防范恶意包代码的沙箱。
|
||||
|
||||
检查只发生在 DSH 自己持有的组合入口,改写的是启动器自己的那份组合:profile 的 patch 层、依赖清单与组合包列表都不会改变。`prepareProfilePatches` 在启动器的空 profile 根之上组合,并在挂载根 Include 时以及每次 profile 重新组合时执行,因此被拒绝的插件永远不会导入其模块;`prepareProfileEntries` 对 preset 行做同样的事。被拒绝的普通行会变成游离的 `disabled: true` 行;原生 group 保持挂载,其被拒绝的子行不会加载;若某个原生 Include 会到达被拒绝的插件,则整体省略该 Include,因为它的文件不会被改写。被策略拒绝的行在 profile 中保留其配置的 `disabled` 值,每次拒绝都会报告包名、版本与风险。组合包本身不是行,因此 `loadProfileDirectory` 在启动和每次重新组合加载 profile 的组合包层时,检查每个组合包自己声明的 DSH peer;没有豁免的不兼容组合包会像无法读取的组合包一样被跳过。这些入口不覆盖其他嵌入方通过自己的 `ctx.plugin` 调用挂载的插件。会话中直接改文件的两类编辑只在下一次重新组合或启动时才被判定:正在运行的插件自己的 `package.json` peer 声明,以及 Loader 自己读取的入口清单文件(如启动器的根配置或嵌套的 `cordis:include` 文件)。`--dump-config` 报告的是配置出的组合,因此被拒绝的插件行仍会出现在其中,而被拒绝的组合包不提供任何行;`--dump-config-schema` 会导入每个组合模块以读取其 schema,请只对已经信任其插件的 profile 运行。
|
||||
|
||||
精确版本豁免保存在 profile 自己的 `compatibility.json` 中,而不是 `package.json`,因此写豁免不会触及依赖清单、组合包列表或 Cordis patch 文件。它把精确的 `package-name@version` 键映射到精确 DSH 运行时版本列表;插件升级和 DSH 升级都不继承授权。文件缺失表示没有豁免。文件损坏绝不会阻止 profile 启动:读取器接受的记录仍然生效,每条被拒绝的记录会与插件拒绝信息一起输出到 stderr,此后该文件被视为只读,因此授予或撤销会拒绝执行并要求用户手工修复,而不是覆盖用户的内容。[插件管理器](../plugin-manager/README.zh.md#version-compatibility-and-exemptions)负责每次变更所需的授权、撤销与风险确认。
|
||||
|
||||
你的机器本地偏好同样位于 harness home 中:
|
||||
|
||||
- **`.env`**——你的普通环境层:调用目录的文件优先于 harness home 的文件,两者都低于继承环境。在文件中设置的进程启动变量(如 `PATH`、`DSH_*`、`XDG_*`)会被拒绝:请改为导出这些变量。四个代理名(`HTTP_PROXY`、`HTTPS_PROXY`、`ALL_PROXY`、`NO_PROXY`)只从 harness home 的文件接受,绝不从调用目录的文件接受——后者随 clone 一起到来。对于只想加载某个目录 `.env` 的非产品 bin,文件缺失不影响启动,文件无法加载时输出一行带标签的警告。
|
||||
@@ -122,6 +128,7 @@ Loader 结算后,app-boot 在仅 optional 条目未激活时输出警告。如
|
||||
|
||||
### 设计说明
|
||||
|
||||
- **运行时版本。** `getDshRuntimeVersion()` 通过文件系统路径读取本包清单,也支持可执行文件内的虚拟文件系统;版本缺失或无效时会失败,而不会绕过兼容性检查。
|
||||
- **Profile 启动数据。** `ctx.profileContext` 只包含 profile 位置、启动时组合包名称、已解析的调用级 overlay 与遥测退出值。`readProfilePatches()` 组合传入的启动 profile,或读取这些位置上的当前文件;调用方负责调度和应用结果。
|
||||
- **进程内模块解析。** launcher 在挂载 profile 条目前,将 runtime resolution 安装到 Node 的 ESM 与 CommonJS 内部 resolver。exports、conditions、子路径、模块缓存和错误码仍由 Node 负责;路由后的 ESM 失败报告原始 importer。显式 CommonJS `paths` 始终保留原生查询,包括指向 profile 内的路径。
|
||||
- **链接目录。** profile 链接到树外目录时,其下的 importer 参与逐层 peer 查询,即使目标没有自身的 `package.json`。在每个 `D/node_modules` 位置,当前 `D/package.json` 的 peer 包名若存在于运行时表,就使用运行时包;其他包名查询物理候选。更近的物理包先于后续 peer 声明,peer 位置无需物理 `node_modules`。installation 作用域包目录不参与 linked 拦截,重叠 root 不改变 importer 的查询顺序([规则](../../../.agents/notes/implemented/architecture/2026-09-19-profile-resolution-lookup-order.zh.md))。
|
||||
@@ -192,7 +199,7 @@ Loader 结算后,app-boot 在仅 optional 条目未激活时输出警告。如
|
||||
|
||||
- **运行时解析依赖 Node 内部机制**——受支持的 Node 版本需要 native builtin access addon 和可执行兼容验证。只有构建后的 Harness 自有 Worker 接收 runtime resolution bootstrap;第三方 Worker 与自定义 `vm` linker 保持原生解析。
|
||||
- **重新链接 profile 包需要重启**——Node 缓存真实路径,因此改变 profile 链接或依赖链接的目标需要重启进程。
|
||||
- **链接作用域以记录的真实目录为准**——提升后的依赖若在所有 linked root 之外,就使用原生 Node。实时读取 peer 不会使 Node 缓存失效、监视文件或校验 peer 版本范围。
|
||||
- **链接作用域以记录的真实目录为准**——提升后的依赖若在所有 linked root 之外,就使用原生 Node。每次读取 peer 都不会使 Node 缓存失效,也不会改变被监视的文件。
|
||||
- **源码启动只安装 ESM 钩子**——CommonJS 请求仍需要 package exports 选中的 JavaScript 文件,解析器不会补出缺失的构建产物。
|
||||
- **快照回放替换仅识别特定 basename**——只有以 `cordis.yml` 或 `cordis.yaml` 结尾的配置会映射到同级 `cordis.snapshot.yml`;自定义配置名称需要调用方自行选择。
|
||||
- **环境发现以启动为界**——`loadLayeredEnv` 只读取一次调用目录与 harness home 中的 `.env`;它不搜索父目录,也不跟随之后选择的 workspace。`loadEnv` 仍是非产品 bin 使用的单目录 helper。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-app-boot",
|
||||
"description": "Shared boot glue for the app bins: .env loading, fail-loud Loader guards, snapshot-aware config resolution, and the Loader boot sequence",
|
||||
"version": "0.1.7-alpha.2",
|
||||
"version": "0.1.7-rc.1",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
@@ -32,12 +32,14 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-atomic-write": "workspace:*",
|
||||
"@deepseek-ai/dsh-package-manifest": "workspace:*",
|
||||
"@eslint-community/regexpp": "^4.12.2",
|
||||
"ajv": "^8.20.0",
|
||||
"js-yaml": "^4.2.0",
|
||||
"node-addon-require-builtin": "^0.1.6",
|
||||
"resolve.exports": "^2.0.3"
|
||||
"resolve.exports": "^2.0.3",
|
||||
"semver": "^7.8.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis-plugin-group": "workspace:~",
|
||||
@@ -58,6 +60,7 @@
|
||||
"@deepseek-ai/dsh-home-paths": "workspace:*",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:*",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/semver": "^7.8.0",
|
||||
"@deepseek-ai/cordis": "workspace:~"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
/** Compatibility checks at the composition entry points DSH owns; no Loader instrumentation. */
|
||||
import { existsSync, readFileSync, realpathSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { dirname, extname, isAbsolute, join } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
|
||||
import { applyEntryPatches, entryListSchema, type PatchOptions } from '@deepseek-ai/cordis-plugin-include'
|
||||
import { load } from 'js-yaml'
|
||||
import { resolvePluginResource } from './package-meta.ts'
|
||||
import { barePackageName } from './profile-resolution/resolver.ts'
|
||||
import type {} from './profile-resolution/service.ts'
|
||||
import type {} from './profile-context.ts'
|
||||
import { evaluatePluginCompatibility, pluginCompatibilityWarning } from './plugin-compatibility.ts'
|
||||
import { readProfileCompatibility } from './profile-compatibility.ts'
|
||||
|
||||
function readManifest(filename: string): object {
|
||||
return JSON.parse(readFileSync(filename, 'utf8')) as object
|
||||
}
|
||||
|
||||
/** Render the patch wording an Include would report, through the profile's logger. */
|
||||
function patchWarning(ctx: Context): (message: string, ...args: unknown[]) => void {
|
||||
return (message, ...args) => {
|
||||
let index = 0
|
||||
ctx.logger.warn(message.replace(/%C/g, () => JSON.stringify(args[index++])))
|
||||
}
|
||||
}
|
||||
|
||||
function manifestOf(ctx: Context, name: string, parentURL: string): object | undefined {
|
||||
if (name.startsWith('cordis:')) return undefined
|
||||
const specifier = name.startsWith('#') ? resolvePluginResource(name, parentURL) : name
|
||||
const packages = ctx.get('pluginPackages')
|
||||
const pkg = packages?.packageOf(specifier, parentURL)
|
||||
if (pkg !== undefined) return readManifest(pkg.manifestPath)
|
||||
const bare = barePackageName(specifier)
|
||||
if (bare !== undefined && packages === undefined) {
|
||||
// An embedder without the package service falls back to Node's own lookup, as `packageDirFromParent` does.
|
||||
for (const path of createRequire(parentURL).resolve.paths(bare) as string[]) {
|
||||
const filename = join(path, bare, 'package.json')
|
||||
if (existsSync(filename)) return readManifest(filename)
|
||||
}
|
||||
}
|
||||
if (!isAbsolute(specifier) && !specifier.startsWith('.') && !specifier.startsWith('file:')) return undefined
|
||||
const url = isAbsolute(specifier) ? pathToFileURL(specifier) : new URL(specifier, parentURL)
|
||||
let dir: string
|
||||
try { dir = dirname(realpathSync(fileURLToPath(url))) }
|
||||
catch (error) {
|
||||
// A row this profile cannot resolve is the Loader's import failure to report, not a compatibility decision.
|
||||
if (['ENOENT', 'ENOTDIR', 'EISDIR'].includes(String((error as NodeJS.ErrnoException).code))) return undefined
|
||||
throw error
|
||||
}
|
||||
while (true) {
|
||||
try { return readManifest(join(dir, 'package.json')) }
|
||||
catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
const parent = dirname(dir)
|
||||
if (parent === dir) return undefined
|
||||
dir = parent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Prepare profile or preset rows before the owning DSH caller passes them to Loader.
|
||||
* Only a compatibility conflict denies a row: an entry whose manifest cannot be resolved keeps
|
||||
* the Loader's own import failure. A denied ordinary row gains `disabled`; a native Include that
|
||||
* reaches a denied plugin is denied as a whole, because its file is never rewritten.
|
||||
* @param ctx Context carrying launcher-owned profile facts; non-profile contexts retain their rows.
|
||||
* @param entries Complete effective entry list, after patch composition.
|
||||
* @param parentURL Resolution base of the tree that will import these rows.
|
||||
* @param binName Diagnostic prefix for a denied row; defaults to `dsh`.
|
||||
* @returns Detached rows with incompatible entries denied; reads the profile compatibility file once.
|
||||
* @throws For malformed compatibility permissions or a profile composition without a resolution base.
|
||||
*/
|
||||
export function prepareProfileEntries(
|
||||
ctx: Context, entries: readonly EntryOptions[], parentURL: string | undefined, binName = 'dsh',
|
||||
): EntryOptions[] {
|
||||
// Admission runs before the composed tree mounts, so no plugin-owned logger exporter exists yet;
|
||||
// composition-stage diagnostics go to stderr like the profile launcher's skipped-bundle report.
|
||||
return preflight(ctx, entries, parentURL, (row, reason) => {
|
||||
// Preset rows may omit ids, and a bundle row's name is its resolved module URL.
|
||||
const label = typeof row.id === 'string' ? `row ${JSON.stringify(row.id)}` : row.name
|
||||
process.stderr.write(`${binName}: disabling profile plugin ${label}: ${reason}\n`)
|
||||
}).rows
|
||||
}
|
||||
|
||||
/** Denial reporter invoked once per denied row. */
|
||||
type DenialReporter = (row: EntryOptions, reason: string) => void
|
||||
|
||||
function preflight(
|
||||
ctx: Context, entries: readonly EntryOptions[], parentURL: string | undefined, report: DenialReporter,
|
||||
) {
|
||||
const rows = structuredClone(entries) as EntryOptions[]
|
||||
const profile = ctx.get('profileContext')
|
||||
if (profile === undefined) return { rows, blocked: false }
|
||||
if (parentURL === undefined) throw new Error('Profile compatibility preflight requires a resolution base')
|
||||
// A damaged permission file authorizes nothing, but it must not stop the profile from starting.
|
||||
const { exemptions, warnings } = readProfileCompatibility(profile.dir)
|
||||
for (const warning of warnings) process.stderr.write(`${warning}\n`)
|
||||
const includes = new Set<string>()
|
||||
/** Only a compatibility conflict denies a row; every other failure keeps the Loader's own diagnosis. */
|
||||
const denial = (row: EntryOptions, base: string): string | undefined => {
|
||||
try {
|
||||
const manifest = manifestOf(ctx, row.name, base)
|
||||
if (manifest === undefined) return undefined
|
||||
const issue = evaluatePluginCompatibility(manifest, exemptions)
|
||||
return issue === undefined || issue.exempted ? undefined : pluginCompatibilityWarning(issue)
|
||||
} catch (error) {
|
||||
// Peer metadata that cannot be read or validated is refused rather than silently admitted.
|
||||
/* v8 ignore next -- every reader and parser used here rejects with an Error. */
|
||||
const detail = error instanceof Error ? error.message : String(error)
|
||||
return `its declared peer dependencies cannot be validated: ${detail}`
|
||||
}
|
||||
}
|
||||
const deny = (row: EntryOptions, reason: string): void => {
|
||||
row.disabled = true
|
||||
if (row.group) row.group = false
|
||||
report(row, reason)
|
||||
}
|
||||
const check = (rows: EntryOptions[], base: string): boolean => {
|
||||
let blocked = false
|
||||
for (const row of rows) {
|
||||
if (row.disabled === true && !row.group) continue
|
||||
const conflict = denial(row, base)
|
||||
if (conflict !== undefined) {
|
||||
deny(row, conflict)
|
||||
blocked = true
|
||||
continue
|
||||
}
|
||||
// The `group` marker, not the module name, is what makes a row another tree carrier.
|
||||
if ((row.group === true || row.name === 'cordis:group' || row.name === '@deepseek-ai/cordis-plugin-group')
|
||||
&& Array.isArray(row.config) && check(row.config as EntryOptions[], base)) blocked = true
|
||||
if (row.name !== 'cordis:include' && row.name !== '@deepseek-ai/cordis-plugin-include') continue
|
||||
const reached = includedConflicts(row, base)
|
||||
if (reached !== undefined) {
|
||||
deny(row, reached)
|
||||
blocked = true
|
||||
}
|
||||
}
|
||||
return blocked
|
||||
}
|
||||
/** Whether a native Include reaches a denied plugin; an unreadable one has no compatibility opinion. */
|
||||
const includedConflicts = (row: EntryOptions, base: string): string | undefined => {
|
||||
const config = row.config as { path?: unknown; patches?: PatchOptions[]; initial?: EntryOptions[] } | undefined
|
||||
if (typeof config?.path !== 'string' || !['.yml', '.yaml', '.json'].includes(extname(config.path))) {
|
||||
// A dynamic Include is resolved by the Include plugin itself; this walk cannot judge its rows.
|
||||
return undefined
|
||||
}
|
||||
const requested = isAbsolute(config.path) ? config.path : fileURLToPath(new URL(config.path, base))
|
||||
const filename = existsSync(requested) ? realpathSync(requested) : requested
|
||||
if (includes.has(filename)) return undefined
|
||||
includes.add(filename)
|
||||
try {
|
||||
let data: unknown
|
||||
try { data = load(readFileSync(filename, 'utf8'), { schema: entryListSchema }) }
|
||||
catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT' || config.initial === undefined) return undefined
|
||||
data = config.initial
|
||||
}
|
||||
if (!Array.isArray(data)) return undefined
|
||||
const children = applyEntryPatches(data as EntryOptions[], config.patches, patchWarning(ctx))
|
||||
return check(children, pathToFileURL(filename).href)
|
||||
? `its included file ${filename} reaches an incompatible plugin, and that file is never rewritten`
|
||||
: undefined
|
||||
} finally {
|
||||
includes.delete(filename)
|
||||
}
|
||||
}
|
||||
return { rows, blocked: check(rows, parentURL) }
|
||||
}
|
||||
|
||||
/** Apply compatibility policy to the complete patch composition over a profile's empty root.
|
||||
* The caller passes every patch layer of the profile, because this returns one insertion patch for
|
||||
* that empty root: a non-empty root config would lose the caller's override patches by id.
|
||||
* @param ctx Profile context prepared by the launcher.
|
||||
* @param patches Original ordered profile patches; these remain unchanged.
|
||||
* @param parentURL Root Include's resolution base.
|
||||
* @param binName Diagnostic prefix for a denied row; defaults to `dsh`.
|
||||
* @returns One prepared insertion patch for profiles, or the original patches for non-profile callers.
|
||||
*/
|
||||
export function prepareProfilePatches(
|
||||
ctx: Context, patches: PatchOptions[], parentURL: string, binName = 'dsh',
|
||||
): PatchOptions[] {
|
||||
if (ctx.get('profileContext') === undefined) return patches
|
||||
const entries = applyEntryPatches([], patches, patchWarning(ctx))
|
||||
const rows = prepareProfileEntries(ctx, entries, parentURL, binName)
|
||||
return rows.length === 0 ? [] : [{ insert: rows }]
|
||||
}
|
||||
@@ -19,6 +19,13 @@ import { dshHomePath, resolveDshHome } from '@deepseek-ai/dsh-home-paths'
|
||||
import { createLaunchEnvironmentSnapshot, type LaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment'
|
||||
export { readProfilePatches, resolveTelemetryPatch, type ProfileContext, type ProfilePnpmInvocation } from './profile-context.ts'
|
||||
export { sanitizeProfile } from './profile-sanitize.ts'
|
||||
export { getDshRuntimeVersion, evaluatePluginCompatibility, pluginCompatibilityWarning, type PluginCompatibility } from './plugin-compatibility.ts'
|
||||
export {
|
||||
PROFILE_COMPATIBILITY_FILENAME, readProfileCompatibility, readProfileVersionExemptions,
|
||||
setProfileVersionExemption, type ProfileCompatibility,
|
||||
} from './profile-compatibility.ts'
|
||||
import { prepareProfilePatches } from './compatibility-preflight.ts'
|
||||
export { prepareProfileEntries, prepareProfilePatches } from './compatibility-preflight.ts'
|
||||
export { readPluginMeta } from './package-meta.ts'
|
||||
export { generateConfigSchema, type ConfigSchemaDump, type NativeConfigSchema } from './config-schema/index.ts'
|
||||
export { createConfigProjector, LOADER_EXPRESSION_SCHEMA, type ConfigProjection } from './config-schema/projector.ts'
|
||||
@@ -274,7 +281,10 @@ export async function reconcileProfilePatches(
|
||||
fiber: row.fiber, failed: row.fiber.state === FIBER_FAILED || row.fiber.state === FIBER_DISPOSED,
|
||||
}])
|
||||
const { patches: _previous, ...includeConfig } = entry.options.config as Include.Config
|
||||
await entry.update({ config: { ...includeConfig, patches } })
|
||||
// The recomposition judges the rows the launch judged, resolved from the file this Include read.
|
||||
const parentURL = new URL('.', new URL(includeConfig.path, entry.parent.tree.ctx.baseUrl)).href
|
||||
const prepared = prepareProfilePatches(ctx, patches, parentURL, binName)
|
||||
await entry.update({ config: { ...includeConfig, patches: prepared } })
|
||||
const results = await Promise.allSettled(previousFibers.map(({ fiber }) => fiber.await()))
|
||||
await ctx.loader.await()
|
||||
const failures = await inactiveEntries(ctx)
|
||||
@@ -384,14 +394,15 @@ export interface ConfigDumpLayer {
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose the effective entry list exactly as `boot()` would mount it: parse
|
||||
* the base config file with the include's entry-list dialect, apply every
|
||||
* layer's patches as ONE flattened list through the include's own patch
|
||||
* algorithm (`applyEntryPatches`) — the same single call `boot()` makes, so
|
||||
* even patch-visibility corner cases (a later layer targeting a group child a
|
||||
* plain config replacement introduced, which the single-pass id index never
|
||||
* sees) compose identically — then render the result as YAML in the same
|
||||
* dialect (`!!js` expressions print verbatim, unevaluated).
|
||||
* Compose the configured entry list: parse the base config file with the
|
||||
* include's entry-list dialect, apply every layer's patches as ONE flattened
|
||||
* list through the include's own patch algorithm (`applyEntryPatches`) — the
|
||||
* same call `boot()` makes, so even patch-visibility corner cases (a later
|
||||
* layer targeting a group child a plain config replacement introduced, which
|
||||
* the single-pass id index never sees) compose identically — then render the
|
||||
* result as YAML in the same dialect (`!!js` expressions print verbatim,
|
||||
* unevaluated). Row admission is a later stage: a plugin row the compatibility
|
||||
* policy denies still appears here, while a denied bundle contributes no layer.
|
||||
*
|
||||
* Every run of rows from the same file and patch layers is preceded by a `# ==` comment
|
||||
* naming the file that contributed the rows and any layers that patched them,
|
||||
@@ -517,6 +528,7 @@ function groupedDump(
|
||||
* @param patches - initial app and user patches, applied in order.
|
||||
* @param bareModuleBaseUrl - optional installed-host base for bare package
|
||||
* names; relative names continue to resolve beside the configuration file.
|
||||
* @param binName - diagnostic prefix for a profile plugin denied by compatibility policy; defaults to `dsh`.
|
||||
* @returns the created root Include entry, or `undefined` when a surface
|
||||
* disposed the whole tree (taking the Loader service with it) while the
|
||||
* entry creation was in flight.
|
||||
@@ -526,6 +538,7 @@ export async function mountRootInclude(
|
||||
absoluteConfigPath: string,
|
||||
patches: readonly PatchOptions[] = [],
|
||||
bareModuleBaseUrl?: string,
|
||||
binName = 'dsh',
|
||||
): Promise<Entry | undefined> {
|
||||
ctx.loader.builtins.include = bareModuleBaseUrl === undefined
|
||||
? Include
|
||||
@@ -549,9 +562,12 @@ export async function mountRootInclude(
|
||||
// Pinned id: the bootstrap include is app glue, not a config row, and its
|
||||
// id appears in Loader failure chains — a random id would make startup
|
||||
// diagnostics unstable across runs (and snapshot fixtures).
|
||||
// The launcher's own copy is prepared here: compatibility decisions must be made before the root
|
||||
// Include imports anything, and they change no profile patch layer, manifest, or bundle list.
|
||||
const prepared = prepareProfilePatches(ctx, [...patches], pathToFileURL(dirname(absoluteConfigPath)).href + '/', binName)
|
||||
const includeConfig: Include.Config = {
|
||||
path: pathToFileURL(absoluteConfigPath).href,
|
||||
...patches.length > 0 ? { patches: [...patches] } : {},
|
||||
...prepared.length > 0 ? { patches: prepared } : {},
|
||||
}
|
||||
const rootInclude: EntryOptions = {
|
||||
id: 'include',
|
||||
@@ -983,7 +999,7 @@ export async function boot(
|
||||
await ctx.plugin(Loader)
|
||||
await prepare?.(ctx)
|
||||
stage = 'plugin tree failed to load'
|
||||
await mountRootInclude(ctx, absoluteConfigPath, patches, bareModuleBaseUrl)
|
||||
await mountRootInclude(ctx, absoluteConfigPath, patches, bareModuleBaseUrl, binName)
|
||||
// A surface can finish and dispose the whole tree while startup is still
|
||||
// in flight, before the last entry settles. The Loader service goes with
|
||||
// it, and the activation audit describes a live tree — reading `ctx.loader`
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
/** Evaluate plugin dsh peer requirements without importing plugin code. */
|
||||
|
||||
import fs from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import semver from 'semver'
|
||||
|
||||
/** Incompatible dsh peers and the exact plugin/runtime exemption decision. */
|
||||
export interface PluginCompatibility {
|
||||
name: string
|
||||
version: string
|
||||
runtimeVersion: string
|
||||
/** Only peer requirements not satisfied by the running dsh version. */
|
||||
peers: Record<string, string>
|
||||
exempted: boolean
|
||||
}
|
||||
|
||||
function objectOf(value: unknown, field: string): Record<string, unknown> {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||
throw new Error(`${field} must be an object`)
|
||||
}
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
|
||||
function runtimeVersionOf(value: unknown): string {
|
||||
if (typeof value !== 'string' || semver.valid(value) === null) {
|
||||
throw new Error(`Invalid dsh runtime version: ${JSON.stringify(value)}; expected a semantic version`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function identityField(manifest: Record<string, unknown>, field: 'name' | 'version'): string {
|
||||
const value = Object.hasOwn(manifest, field) ? manifest[field] : undefined
|
||||
if (typeof value !== 'string' || value.trim() === '') {
|
||||
throw new Error(`Plugin manifest ${field} must be a non-empty string when dsh peers are incompatible`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* Read this app-boot package's version in both source and bundled installations.
|
||||
* @returns the validated runtime semantic version, preserving its exact spelling.
|
||||
* @throws if package.json cannot be read or its version is missing or invalid.
|
||||
*/
|
||||
export function getDshRuntimeVersion(): string {
|
||||
// The executable's virtual filesystem intercepts string paths, not URL arguments.
|
||||
const filename = fileURLToPath(new URL('../package.json', import.meta.url))
|
||||
const manifest = objectOf(JSON.parse(fs.readFileSync(filename, 'utf8')), 'app-boot package.json')
|
||||
return runtimeVersionOf(Object.hasOwn(manifest, 'version') ? manifest.version : undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check every @deepseek-ai/dsh or @deepseek-ai/dsh-* peer against the runtime.
|
||||
* Prereleases participate in ranges. workspace:^, workspace:~, and workspace:*
|
||||
* refer to the current runtime; other invalid ranges are incompatible.
|
||||
* @param manifest - parsed plugin package.json; inherited fields are ignored.
|
||||
* @param exemptions - exact plugin name@version keys mapped to exact runtime versions.
|
||||
* @param runtimeVersion - running dsh version, defaulting to this app-boot package.
|
||||
* @returns incompatible peers and exemption status, or undefined when none are incompatible.
|
||||
* @throws for malformed manifest peer fields, invalid runtime versions, or missing identity on a mismatch.
|
||||
*/
|
||||
export function evaluatePluginCompatibility(
|
||||
manifest: object,
|
||||
exemptions: Readonly<Record<string, readonly string[]>> = {},
|
||||
runtimeVersion = getDshRuntimeVersion(),
|
||||
): PluginCompatibility | undefined {
|
||||
runtimeVersionOf(runtimeVersion)
|
||||
const fields = objectOf(manifest, 'Plugin manifest')
|
||||
if (!Object.hasOwn(fields, 'peerDependencies')) return undefined
|
||||
const dependencies = objectOf(fields.peerDependencies, 'Plugin manifest peerDependencies')
|
||||
const peers: Record<string, string> = {}
|
||||
for (const [name, range] of Object.entries(dependencies)) {
|
||||
if (typeof range !== 'string') {
|
||||
throw new Error(`Plugin manifest peerDependencies[${JSON.stringify(name)}] must be a string`)
|
||||
}
|
||||
if (name !== '@deepseek-ai/dsh' && !name.startsWith('@deepseek-ai/dsh-')) continue
|
||||
const requirement = ['workspace:^', 'workspace:~', 'workspace:*'].includes(range) ? runtimeVersion : range
|
||||
if (requirement.trim() === '' || !semver.satisfies(runtimeVersion, requirement, { includePrerelease: true })) {
|
||||
peers[name] = range
|
||||
}
|
||||
}
|
||||
if (Object.keys(peers).length === 0) return undefined
|
||||
const name = identityField(fields, 'name')
|
||||
const version = identityField(fields, 'version')
|
||||
const key = `${name}@${version}`
|
||||
const exemptedVersions = Object.hasOwn(exemptions, key) ? exemptions[key] : undefined
|
||||
const exempted = exemptedVersions?.includes(runtimeVersion) === true
|
||||
return { name, version, runtimeVersion, peers, exempted }
|
||||
}
|
||||
|
||||
/**
|
||||
* Describe incompatible peers, their risk, and the exact-version remedy.
|
||||
* Surfaces with their own grant mechanism or locale render the structured result themselves.
|
||||
* @param issue - incompatible plugin/runtime result, including exempted mismatches.
|
||||
* @returns an English diagnostic for logs and stderr.
|
||||
*/
|
||||
export function pluginCompatibilityWarning(issue: PluginCompatibility): string {
|
||||
const key = `${issue.name}@${issue.version}`
|
||||
return `Plugin ${key} is incompatible with dsh ${issue.runtimeVersion}: peerDependencies ${JSON.stringify(issue.peers)}. `
|
||||
+ 'Running it may cause crashes or data loss. '
|
||||
+ 'Update the plugin or install a plugin version compatible with this dsh runtime. '
|
||||
+ `To accept this risk explicitly, grant the exact-version exemption for ${key} on dsh ${issue.runtimeVersion} with \`dsh plugin allow-version\` or the plugin manager, then retry the installation or restart dsh. `
|
||||
+ `Exact-version exemption: ${issue.exempted ? 'active' : 'not active'}.`
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
/** Profile-local compatibility permissions readable before any Cordis plugins load. */
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { mkdir } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { parse } from 'semver'
|
||||
import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
|
||||
import { getDshRuntimeVersion } from './plugin-compatibility.ts'
|
||||
|
||||
/** Independent profile metadata; neither package manifests nor Cordis patches carry grants. */
|
||||
export const PROFILE_COMPATIBILITY_FILENAME = 'compatibility.json'
|
||||
|
||||
/** Published and scoped npm package names, as npm accepts them in a manifest dependency key. */
|
||||
const PACKAGE_NAME = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/
|
||||
|
||||
/** Whether a decoded record accepts only exact DSH versions. */
|
||||
function isVersionList(value: unknown): value is string[] {
|
||||
return Array.isArray(value) && value.every(version => typeof version === 'string' && isExactPluginVersion(version))
|
||||
}
|
||||
|
||||
/** Test whether an exemption names a canonical exact SemVer, including build metadata.
|
||||
* @param value Version supplied by a manifest or user.
|
||||
* @returns False for ranges, prefixes, whitespace, and malformed versions.
|
||||
*/
|
||||
export function isExactPluginVersion(value: string): boolean {
|
||||
const parsed = parse(value)
|
||||
return parsed !== null && value === `${parsed.version}${parsed.build.length === 0 ? '' : `+${parsed.build.join('.')}`}`
|
||||
}
|
||||
|
||||
/** Validate one explicit exemption without granting it.
|
||||
* @param packageVersion Exact npm package-name@version, never an installation spec or range.
|
||||
* @param runtimeVersion Exact DSH version, including prerelease and build metadata.
|
||||
* @throws When either identity is not canonical.
|
||||
*/
|
||||
export function validatePluginVersionExemption(packageVersion: string, runtimeVersion: string): void {
|
||||
const separator = packageVersion.lastIndexOf('@')
|
||||
if (separator <= 0 || !PACKAGE_NAME.test(packageVersion.slice(0, separator))
|
||||
|| !isExactPluginVersion(packageVersion.slice(separator + 1)) || !isExactPluginVersion(runtimeVersion)) {
|
||||
throw new Error('Version exemptions require an exact npm package-name@version and an exact DSH runtime version')
|
||||
}
|
||||
}
|
||||
|
||||
/** What one profile's compatibility file currently authorizes, and what is wrong with it. */
|
||||
export interface ProfileCompatibility {
|
||||
/** Accepted exact package-name@version keys mapped to their allowed DSH versions. */
|
||||
readonly exemptions: Record<string, string[]>
|
||||
/** Human-readable problems; empty when every record was accepted. */
|
||||
readonly warnings: string[]
|
||||
/**
|
||||
* Whether the file holds nothing this reader rejected, so a grant or revocation may rewrite it.
|
||||
* A false value means writing would discard content the user must repair by hand.
|
||||
*/
|
||||
readonly rewritable: boolean
|
||||
}
|
||||
|
||||
/** Read the profile's independent compatibility file without loading plugins.
|
||||
* A missing file authorizes nothing. An unreadable or unparsable file authorizes nothing and is
|
||||
* reported instead of failing, so a bad file can never make the profile unusable; rejected records
|
||||
* are skipped while the remaining valid ones still apply.
|
||||
* @param profileDir Absolute profile directory.
|
||||
* @returns Accepted exemptions plus every problem found; no manifest fallback is used.
|
||||
*/
|
||||
export function readProfileCompatibility(profileDir: string): ProfileCompatibility {
|
||||
const filename = join(profileDir, PROFILE_COMPATIBILITY_FILENAME)
|
||||
const unreadable = (reason: string): ProfileCompatibility =>
|
||||
({ exemptions: {}, warnings: [`${filename} ${reason}; treating the profile as having no exemptions`], rewritable: false })
|
||||
let text: string
|
||||
try { text = readFileSync(filename, 'utf8') }
|
||||
catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { exemptions: {}, warnings: [], rewritable: true }
|
||||
return unreadable(`cannot be read (${String(error)})`)
|
||||
}
|
||||
let value: unknown
|
||||
try { value = JSON.parse(text) }
|
||||
catch (error) { return unreadable(`is not valid JSON (${String(error)})`) }
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return unreadable('must map exact package@version keys to DSH version lists')
|
||||
}
|
||||
const exemptions: Record<string, string[]> = {}
|
||||
const warnings: string[] = []
|
||||
for (const [key, versions] of Object.entries(value as Record<string, unknown>)) {
|
||||
const separator = key.lastIndexOf('@')
|
||||
if (separator <= 0 || !PACKAGE_NAME.test(key.slice(0, separator)) || !isExactPluginVersion(key.slice(separator + 1))) {
|
||||
warnings.push(`${filename}: ${JSON.stringify(key)} is not an exact package-name@version key; the record is ignored`)
|
||||
continue
|
||||
}
|
||||
if (!isVersionList(versions)) {
|
||||
warnings.push(`${filename}: ${key} must contain a list of exact DSH versions; the record is ignored`)
|
||||
continue
|
||||
}
|
||||
exemptions[key] = versions
|
||||
}
|
||||
return { exemptions, warnings, rewritable: warnings.length === 0 }
|
||||
}
|
||||
|
||||
/** Read only the accepted exemptions of a profile.
|
||||
* @param profileDir Absolute profile directory.
|
||||
* @returns Exact package-name@version keys mapped to their allowed DSH versions.
|
||||
*/
|
||||
export function readProfileVersionExemptions(profileDir: string): Record<string, string[]> {
|
||||
return readProfileCompatibility(profileDir).exemptions
|
||||
}
|
||||
|
||||
/** Persist one informed grant or revocation under the compatibility file's own lock.
|
||||
* @param profileDir Profile directory; no package manifest is created or modified.
|
||||
* @param packageVersion Exact manifest package-name@version.
|
||||
* @param runtimeVersion Exact DSH version; grants must name the current runtime, revocations may name historical ones.
|
||||
* @param enabled Whether to grant rather than revoke.
|
||||
* @param acceptRisk Required true for grants after explicit acknowledgement of possible crashes or data loss.
|
||||
* @returns After the atomic write. Existing plugin instances are not reloaded by this operation.
|
||||
* @throws For invalid identities, missing consent, a stale runtime, or a file the reader rejected,
|
||||
* which the user must repair by hand because rewriting it would discard their content.
|
||||
*/
|
||||
export async function setProfileVersionExemption(
|
||||
profileDir: string, packageVersion: string, runtimeVersion: string, enabled: boolean, acceptRisk: boolean,
|
||||
): Promise<void> {
|
||||
validatePluginVersionExemption(packageVersion, runtimeVersion)
|
||||
if (enabled && !acceptRisk) {
|
||||
throw new Error('Incompatible plugins may cause crashes or data loss. To grant this exact-version exemption, explicitly acknowledge the risk with --accept-risk (acceptRisk: true).')
|
||||
}
|
||||
const current = getDshRuntimeVersion()
|
||||
if (enabled && runtimeVersion !== current) {
|
||||
throw new Error(`Cannot approve DSH ${runtimeVersion}: this application runs DSH ${current}. Use --dsh-version ${current}.`)
|
||||
}
|
||||
await mkdir(profileDir, { recursive: true })
|
||||
const filename = join(profileDir, PROFILE_COMPATIBILITY_FILENAME)
|
||||
await withFileLock(filename, async () => {
|
||||
const current = readProfileCompatibility(profileDir)
|
||||
if (!current.rewritable) {
|
||||
throw new Error(`${PROFILE_COMPATIBILITY_FILENAME} must be repaired before exemptions change:\n${current.warnings.join('\n')}`)
|
||||
}
|
||||
const exemptions = current.exemptions
|
||||
const versions = exemptions[packageVersion] ?? []
|
||||
if (enabled) exemptions[packageVersion] = [...new Set([...versions, runtimeVersion])]
|
||||
else {
|
||||
const retained = versions.filter(version => version !== runtimeVersion)
|
||||
if (retained.length) exemptions[packageVersion] = retained
|
||||
else Reflect.deleteProperty(exemptions, packageVersion)
|
||||
}
|
||||
await writeFileAtomic(filename, JSON.stringify(exemptions, undefined, 2) + '\n', { mode: 0o600 })
|
||||
})
|
||||
}
|
||||
@@ -28,6 +28,8 @@ import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
|
||||
import { applyEntryPatches, type PatchOptions } from '@deepseek-ai/cordis-plugin-include'
|
||||
import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
|
||||
import type { DshBundleManifest, DshPackageManifest } from '@deepseek-ai/dsh-package-manifest'
|
||||
import { evaluatePluginCompatibility, pluginCompatibilityWarning } from './plugin-compatibility.ts'
|
||||
import { readProfileVersionExemptions } from './profile-compatibility.ts'
|
||||
import { loadOverlayPatches } from './index.ts'
|
||||
import { realModuleDirectory } from './profile-resolution/legacy-links.ts'
|
||||
|
||||
@@ -629,7 +631,8 @@ export function resolveBundleDir(
|
||||
* Load an already initialized profile directory without resolving it through
|
||||
* the shared Harness home. This is used by application-owned profiles whose
|
||||
* package project and lifecycle belong to that application.
|
||||
* Unreadable bundles are reported on stderr and skipped without changing the manifest.
|
||||
* Unreadable bundles, and bundles whose own dsh peers the profile does not exempt, are reported
|
||||
* on stderr and skipped without changing the manifest.
|
||||
* @param binName - the diagnostic prefix on thrown errors.
|
||||
* @param dir - absolute profile package directory.
|
||||
* @param installAnchor - absolute path of the owning dsh app's package.json.
|
||||
@@ -645,6 +648,7 @@ export function loadProfileDirectory(
|
||||
const manifest = readProfileManifest(binName, dir)
|
||||
const bundles = manifest.dsh?.profile?.bundles ?? []
|
||||
const layers: ProfileLayer[] = []
|
||||
const exemptions = bundles.length === 0 ? {} : readProfileVersionExemptions(dir)
|
||||
for (const packageName of bundles) {
|
||||
try {
|
||||
const packageDir = resolveBundleDir(binName, packageName, installAnchor, dir)
|
||||
@@ -653,6 +657,9 @@ export function loadProfileDirectory(
|
||||
if (bundle === undefined) {
|
||||
throw new Error(`${binName}: profile bundle ${JSON.stringify(packageName)} declares no dsh.bundle in its package.json`)
|
||||
}
|
||||
// A bundle is not a plugin row, so row admission never reads its own peers.
|
||||
const issue = evaluatePluginCompatibility(bundleManifest, exemptions)
|
||||
if (issue !== undefined && !issue.exempted) throw new Error(pluginCompatibilityWarning(issue))
|
||||
const patchPaths = bundlePatchPaths(packageDir, bundle)
|
||||
const patches = patchPaths.flatMap(patchPath => loadOverlayPatches(binName, patchPath))
|
||||
layers.push({ packageName, packageDir, patchPaths, patches })
|
||||
@@ -669,8 +676,8 @@ export function loadProfileDirectory(
|
||||
|
||||
/**
|
||||
* Load a profile: resolve every `dsh.profile.bundles` entry to its patch
|
||||
* layer and parse the profile's own patch file. Unreadable bundles are reported
|
||||
* on stderr and skipped; profile manifest and user patch errors still throw.
|
||||
* layer and parse the profile's own patch file. Unreadable or incompatible bundles
|
||||
* are reported on stderr and skipped; profile manifest and user patch errors still throw.
|
||||
* @param binName - the diagnostic prefix on thrown errors.
|
||||
* @param name - the profile name.
|
||||
* @param installAnchor - absolute path of the dsh app's package.json (first resolution anchor).
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
/** Admission of profile composition rows before the Loader imports any plugin code. */
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { entryListSchema, type PatchOptions } from '@deepseek-ai/cordis-plugin-include'
|
||||
import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
|
||||
import { load } from 'js-yaml'
|
||||
import { describe, expect, it, onTestFinished, vi } from 'vitest'
|
||||
import {
|
||||
PROFILE_COMPATIBILITY_FILENAME, PluginPackages, boot, getDshRuntimeVersion,
|
||||
prepareProfileEntries, prepareProfilePatches, type ProfileContext,
|
||||
} from '../src/index.ts'
|
||||
|
||||
const runtime = getDshRuntimeVersion()
|
||||
|
||||
function fixture() {
|
||||
const temporary = mkdtempSync(join(tmpdir(), 'dsh-preflight-'))
|
||||
let ctx: Context | undefined
|
||||
onTestFinished(async () => {
|
||||
try { await ctx?.fiber.dispose() } finally { rmSync(temporary, { recursive: true, force: true }) }
|
||||
})
|
||||
const dir = realpathSync(temporary)
|
||||
const effects = join(dir, 'effects')
|
||||
const warnings: string[] = []
|
||||
// Admission diagnostics precede the composed tree, so stderr is the channel under test.
|
||||
const stderr = vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => {
|
||||
warnings.push(String(chunk))
|
||||
return true
|
||||
})
|
||||
onTestFinished(() => { stderr.mockRestore() })
|
||||
|
||||
function plugin(name: string, options: { version?: string; peer?: string; alias?: string } = {}): void {
|
||||
const packageDir = join(dir, 'node_modules', options.alias ?? name)
|
||||
mkdirSync(packageDir, { recursive: true })
|
||||
writeFileSync(join(packageDir, 'package.json'), JSON.stringify({
|
||||
name, version: options.version ?? '1.0.0', type: 'module', exports: './index.mjs',
|
||||
...options.peer === undefined ? {} : { peerDependencies: { '@deepseek-ai/dsh-test': options.peer } },
|
||||
}))
|
||||
writeFileSync(join(packageDir, 'index.mjs'), `import { appendFileSync } from 'node:fs'
|
||||
const record = (value) => appendFileSync(new URL('${pathToFileURL(effects).href}'), value + '\\n')
|
||||
record('import:${name}')
|
||||
export function apply() { record('apply:${name}') }
|
||||
`)
|
||||
}
|
||||
|
||||
function exemptions(value: Record<string, string[]>): void {
|
||||
writeFileSync(join(dir, PROFILE_COMPATIBILITY_FILENAME), JSON.stringify(value))
|
||||
}
|
||||
|
||||
const profile: ProfileContext = {
|
||||
name: 'preflight', dir, patchPath: join(dir, 'cordis.patch.yml'),
|
||||
installAnchor: join(dir, 'package.json'), cwd: dir, home: dir,
|
||||
startedBundles: [], overlays: [], telemetryDisabledEnv: undefined,
|
||||
}
|
||||
const configPath = join(dir, 'cordis.yml')
|
||||
writeFileSync(configPath, '[]\n')
|
||||
exemptions({})
|
||||
|
||||
/** Boot the profile with these patches and return the recorded plugin effects. */
|
||||
async function run(patches: PatchOptions[], packages = false): Promise<string[]> {
|
||||
ctx = await boot('preflight', configPath, patches, async (host) => {
|
||||
host.provide('profileContext', profile)
|
||||
if (packages) await host.plugin(PluginPackages)
|
||||
host.logger.exporter({ levels: { default: 3 }, export(message) {
|
||||
if (message.type === 'warn' || message.type === 'error') warnings.push(String(message.args[0]))
|
||||
} })
|
||||
})
|
||||
return existsSync(effects) ? readFileSync(effects, 'utf8').trim().split('\n') : []
|
||||
}
|
||||
|
||||
return { dir, profile, configPath, warnings, plugin, exemptions, run }
|
||||
}
|
||||
|
||||
const insert = (...names: string[]) => [{ insert: names.map((name, index) => ({ id: `row-${String(index)}`, name })) }]
|
||||
|
||||
it('blocks an incompatible plugin before its module is imported while a compatible sibling loads', async () => {
|
||||
const f = fixture()
|
||||
f.plugin('denied-plugin', { peer: '^9.0.0' })
|
||||
f.plugin('allowed-plugin')
|
||||
const effects = await f.run(insert('allowed-plugin', 'denied-plugin'))
|
||||
expect(effects).toEqual(['import:allowed-plugin', 'apply:allowed-plugin'])
|
||||
expect(f.warnings.join('\n')).toContain('[email protected]')
|
||||
expect(f.warnings.join('\n')).toContain('data loss')
|
||||
})
|
||||
|
||||
it('admits the same plugin only for its exact version and the running runtime', async () => {
|
||||
const granted = fixture()
|
||||
granted.plugin('denied-plugin', { peer: '^9.0.0' })
|
||||
granted.exemptions({ '[email protected]': [runtime] })
|
||||
expect(await granted.run(insert('denied-plugin'))).toEqual(['import:denied-plugin', 'apply:denied-plugin'])
|
||||
|
||||
const otherVersion = fixture()
|
||||
otherVersion.plugin('denied-plugin', { version: '2.0.0', peer: '^9.0.0' })
|
||||
otherVersion.exemptions({ '[email protected]': [runtime] })
|
||||
expect(await otherVersion.run(insert('denied-plugin'))).toEqual([])
|
||||
|
||||
const otherRuntime = fixture()
|
||||
otherRuntime.plugin('denied-plugin', { peer: '^9.0.0' })
|
||||
otherRuntime.exemptions({ '[email protected]': ['9.9.9'] })
|
||||
expect(await otherRuntime.run(insert('denied-plugin'))).toEqual([])
|
||||
})
|
||||
|
||||
const denialSpecifiers: Array<[string, (name: string, dir: string) => string]> = [
|
||||
['a package name', () => 'denied-plugin'],
|
||||
['a relative module', () => './node_modules/denied-plugin/index.mjs'],
|
||||
['an absolute module', (name, dir) => join(dir, 'node_modules', name, 'index.mjs')],
|
||||
['a file URL', (name, dir) => pathToFileURL(join(dir, 'node_modules', name, 'index.mjs')).href],
|
||||
]
|
||||
|
||||
it.each(denialSpecifiers)('blocks an incompatible %s before import', async (_label, specifier) => {
|
||||
const f = fixture()
|
||||
f.plugin('denied-plugin', { peer: '^9.0.0' })
|
||||
expect(await f.run(insert(specifier('denied-plugin', f.dir)))).toEqual([])
|
||||
})
|
||||
|
||||
it('blocks a denied plugin reached through a package-imports alias', async () => {
|
||||
const f = fixture()
|
||||
const aliased = join(f.dir, 'aliased')
|
||||
mkdirSync(aliased)
|
||||
writeFileSync(join(aliased, 'package.json'), JSON.stringify({
|
||||
name: 'denied-plugin', version: '1.0.0', type: 'module', exports: './index.mjs',
|
||||
peerDependencies: { '@deepseek-ai/dsh-test': '^9.0.0' },
|
||||
}))
|
||||
writeFileSync(join(aliased, 'index.mjs'), `import { appendFileSync } from 'node:fs'
|
||||
appendFileSync(new URL('${pathToFileURL(join(f.dir, 'effects')).href}'), 'import:denied-plugin\\n')
|
||||
export function apply() {}
|
||||
`)
|
||||
writeFileSync(join(f.dir, 'package.json'), JSON.stringify({
|
||||
name: 'preflight-profile', private: true, type: 'module',
|
||||
imports: { '#denied': './aliased/index.mjs' },
|
||||
}))
|
||||
expect(await f.run(insert('#denied'))).toEqual([])
|
||||
expect(f.warnings.join('\n')).toContain('[email protected]')
|
||||
})
|
||||
|
||||
it('blocks an incompatible descendant of a native group while its compatible sibling loads', async () => {
|
||||
const f = fixture()
|
||||
f.plugin('denied-plugin', { peer: '^9.0.0' })
|
||||
f.plugin('allowed-plugin')
|
||||
const effects = await f.run([{ insert: [{ id: 'group', name: 'cordis:group', group: true, config: [
|
||||
{ id: 'allowed', name: 'allowed-plugin' },
|
||||
{ id: 'denied', name: 'denied-plugin' },
|
||||
] }] }])
|
||||
expect(effects).toEqual(['import:allowed-plugin', 'apply:allowed-plugin'])
|
||||
})
|
||||
|
||||
it('omits a native Include that reaches an incompatible plugin and keeps an unrelated Include', async () => {
|
||||
const f = fixture()
|
||||
f.plugin('denied-plugin', { peer: '^9.0.0' })
|
||||
f.plugin('allowed-plugin')
|
||||
const deniedList = join(f.dir, 'denied.cordis.yml')
|
||||
const allowedList = join(f.dir, 'allowed.cordis.yml')
|
||||
writeFileSync(deniedList, JSON.stringify([{ id: 'denied', name: 'denied-plugin' }]))
|
||||
writeFileSync(allowedList, JSON.stringify([{ id: 'allowed', name: 'allowed-plugin' }]))
|
||||
const effects = await f.run([{ insert: [
|
||||
{ id: 'denied-include', name: 'cordis:include', config: { path: pathToFileURL(deniedList).href } },
|
||||
{ id: 'allowed-include', name: 'cordis:include', config: { path: pathToFileURL(allowedList).href } },
|
||||
] }])
|
||||
expect(effects).toEqual(['import:allowed-plugin', 'apply:allowed-plugin'])
|
||||
expect(f.warnings.join('\n')).toContain('never rewritten')
|
||||
})
|
||||
|
||||
it('leaves an unresolvable row to the Loader instead of denying it', async () => {
|
||||
const f = fixture()
|
||||
expect(await f.run(insert('./missing.mjs'))).toEqual([])
|
||||
expect(f.warnings.join('\n')).toContain('missing.mjs')
|
||||
expect(f.warnings.join('\n')).not.toContain('disabled:')
|
||||
})
|
||||
|
||||
it('denies a row whose peer metadata cannot be validated', async () => {
|
||||
const f = fixture()
|
||||
f.plugin('malformed-plugin')
|
||||
writeFileSync(join(f.dir, 'node_modules', 'malformed-plugin', 'package.json'),
|
||||
JSON.stringify({ name: 'malformed-plugin', version: '1.0.0', peerDependencies: ['@deepseek-ai/dsh'] }))
|
||||
expect(await f.run(insert('malformed-plugin'))).toEqual([])
|
||||
expect(f.warnings.join('\n')).toContain('cannot be validated')
|
||||
})
|
||||
|
||||
it('leaves an explicitly disabled row and a non-profile composition untouched', async () => {
|
||||
const f = fixture()
|
||||
f.plugin('denied-plugin', { peer: '^9.0.0' })
|
||||
expect(await f.run([{ insert: [{ id: 'off', name: 'denied-plugin', disabled: true }] }])).toEqual([])
|
||||
|
||||
const unrelated = new Context()
|
||||
onTestFinished(() => unrelated.fiber.dispose())
|
||||
const rows = [{ id: 'row', name: 'denied-plugin' }]
|
||||
expect(prepareProfileEntries(unrelated, rows, f.dir)).toEqual(rows)
|
||||
})
|
||||
|
||||
it('reports a rejected record while the records it accepted still apply', async () => {
|
||||
const f = fixture()
|
||||
f.plugin('denied-plugin', { peer: '^9.0.0' })
|
||||
f.plugin('allowed-plugin')
|
||||
writeFileSync(join(f.dir, PROFILE_COMPATIBILITY_FILENAME),
|
||||
JSON.stringify({ '[email protected]': [runtime], 'broken@*': [runtime] }))
|
||||
// The accepted grant still admits its plugin; only the broken record is reported and dropped.
|
||||
expect((await f.run(insert('allowed-plugin', 'denied-plugin'))).sort())
|
||||
.toEqual(['apply:allowed-plugin', 'apply:denied-plugin', 'import:allowed-plugin', 'import:denied-plugin'])
|
||||
expect(f.warnings.join('\n')).toContain('is not an exact package-name@version key')
|
||||
})
|
||||
|
||||
it('starts with no exemptions and a warning when the permission file is unparsable', async () => {
|
||||
const f = fixture()
|
||||
f.plugin('denied-plugin', { peer: '^9.0.0' })
|
||||
writeFileSync(join(f.dir, PROFILE_COMPATIBILITY_FILENAME), '{ not json')
|
||||
expect(await f.run(insert('denied-plugin'))).toEqual([])
|
||||
expect(f.warnings.join('\n')).toContain('is not valid JSON')
|
||||
expect(f.warnings.join('\n')).toContain('is incompatible with dsh')
|
||||
})
|
||||
|
||||
describe('manifest resolution', () => {
|
||||
function context(f: ReturnType<typeof fixture>): Context {
|
||||
const ctx = new Context()
|
||||
onTestFinished(() => ctx.fiber.dispose())
|
||||
ctx.provide('profileContext', f.profile)
|
||||
return ctx
|
||||
}
|
||||
|
||||
it('leaves rows with no resolvable manifest to the Loader', () => {
|
||||
const f = fixture()
|
||||
writeFileSync(join(f.dir, 'standalone.mjs'), 'export function apply() {}\n')
|
||||
const rows = [
|
||||
{ id: 'outside', name: './standalone.mjs' },
|
||||
{ id: 'builtin', name: 'node:fs' },
|
||||
{ id: 'missing', name: 'missing-package' },
|
||||
]
|
||||
expect(prepareProfileEntries(context(f), rows, pathToFileURL(f.dir).href + '/')).toEqual(rows)
|
||||
})
|
||||
|
||||
it('denies through the mounted package service when the launcher supplies one', async () => {
|
||||
const f = fixture()
|
||||
f.plugin('denied-plugin', { peer: '^9.0.0' })
|
||||
expect(await f.run(insert('denied-plugin'), true)).toEqual([])
|
||||
expect(f.warnings.join('\n')).toContain('[email protected]')
|
||||
})
|
||||
|
||||
it('stops at a recursive Include instead of recursing forever', () => {
|
||||
const f = fixture()
|
||||
const inner = join(f.dir, 'inner.cordis.yml')
|
||||
const outer = join(f.dir, 'outer.cordis.yml')
|
||||
writeFileSync(inner, `- id: nested\n name: cordis:include\n config: { path: ${JSON.stringify(outer)} }\n`)
|
||||
writeFileSync(outer, `- id: outer\n name: cordis:include\n config: { path: ${JSON.stringify(inner)} }\n`)
|
||||
const rows = prepareProfileEntries(context(f), [{ id: 'cycle', name: 'cordis:include', config: { path: inner } }],
|
||||
pathToFileURL(f.dir).href + '/')
|
||||
expect(rows[0]?.disabled).toBeUndefined()
|
||||
})
|
||||
|
||||
it('leaves an Include whose file is not an entry list undecided', () => {
|
||||
const f = fixture()
|
||||
const mapping = join(f.dir, 'mapping.cordis.yml')
|
||||
writeFileSync(mapping, 'id: nested\nname: denied-plugin\n')
|
||||
const rows = prepareProfileEntries(context(f), [{ id: 'row', name: 'cordis:include', config: { path: mapping } }],
|
||||
pathToFileURL(f.dir).href + '/')
|
||||
expect(rows[0]?.disabled).toBeUndefined()
|
||||
})
|
||||
|
||||
it('warns about ignored patches while the included rows still decide', () => {
|
||||
const f = fixture()
|
||||
f.plugin('allowed-plugin')
|
||||
const list = join(f.dir, 'patched.cordis.yml')
|
||||
writeFileSync(list, '- id: nested\n name: allowed-plugin\n')
|
||||
const ctx = context(f)
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
|
||||
onTestFinished(() => { warn.mockRestore() })
|
||||
const rows = prepareProfileEntries(ctx, [{ id: 'outer', name: 'cordis:include',
|
||||
config: { path: list, patches: [{ id: 'absent', disabled: true }] } }], pathToFileURL(f.dir).href + '/')
|
||||
expect(rows[0]?.disabled).toBeUndefined()
|
||||
expect(warn.mock.calls.flat().map(String).join(' ')).toContain('absent')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['an unreadable manifest', (f: ReturnType<typeof fixture>) => {
|
||||
f.plugin('denied-plugin')
|
||||
rmSync(join(f.dir, 'node_modules', 'denied-plugin', 'package.json'))
|
||||
mkdirSync(join(f.dir, 'node_modules', 'denied-plugin', 'package.json'))
|
||||
}],
|
||||
['a module path that loops', (f: ReturnType<typeof fixture>) => {
|
||||
f.plugin('denied-plugin')
|
||||
symlinkSync('loop.mjs', join(f.dir, 'node_modules', 'denied-plugin', 'loop.mjs'))
|
||||
}],
|
||||
])('refuses a row whose %s cannot be read', (_label, prepare) => {
|
||||
const f = fixture()
|
||||
prepare(f)
|
||||
const name = _label === 'a module path that loops'
|
||||
? './node_modules/denied-plugin/loop.mjs' : './node_modules/denied-plugin/index.mjs'
|
||||
const rows = prepareProfileEntries(context(f), [{ id: 'row', name }], pathToFileURL(f.dir).href + '/')
|
||||
expect(rows[0]?.disabled).toBe(true)
|
||||
expect(f.warnings.join('\n')).toContain('cannot be validated')
|
||||
})
|
||||
|
||||
it('labels a denied row by its id, or by its module when a preset row omits the id', () => {
|
||||
const f = fixture()
|
||||
f.plugin('denied-plugin', { peer: '^9.0.0' })
|
||||
const base = pathToFileURL(f.dir).href + '/'
|
||||
prepareProfileEntries(context(f), [{ id: 'row', name: './node_modules/denied-plugin/index.mjs' }], base)
|
||||
// A preset declaration may omit the id that the Loader type requires.
|
||||
prepareProfileEntries(context(f), [{ name: 'denied-plugin' } as EntryOptions], base)
|
||||
expect(f.warnings).toEqual([
|
||||
expect.stringMatching(/^dsh: disabling profile plugin row "row": Plugin denied-plugin@1\.0\.0 /),
|
||||
expect.stringMatching(/^dsh: disabling profile plugin denied-plugin: Plugin denied-plugin@1\.0\.0 /),
|
||||
])
|
||||
})
|
||||
|
||||
it('denies a group-shaped row and clears its group flag', () => {
|
||||
const f = fixture()
|
||||
f.plugin('denied-plugin', { peer: '^9.0.0' })
|
||||
const rows = prepareProfileEntries(context(f), [{ id: 'row', name: 'denied-plugin', group: true, config: [] }],
|
||||
pathToFileURL(f.dir).href + '/')
|
||||
expect(rows[0]).toMatchObject({ disabled: true, group: false })
|
||||
})
|
||||
|
||||
it('ignores a native Include whose path is not a literal YAML or JSON file', () => {
|
||||
const f = fixture()
|
||||
const ctx = context(f)
|
||||
for (const config of [undefined, { path: 7 }, { path: './plugin.mjs' }]) {
|
||||
expect(prepareProfileEntries(ctx, [{ id: 'row', name: 'cordis:include', config }],
|
||||
pathToFileURL(f.dir).href + '/')).toEqual([{ id: 'row', name: 'cordis:include', config }])
|
||||
}
|
||||
})
|
||||
|
||||
it('ignores an absent literal Include without initial entries and reads them when provided', () => {
|
||||
const f = fixture()
|
||||
f.plugin('denied-plugin', { peer: '^9.0.0' })
|
||||
const ctx = context(f)
|
||||
const base = pathToFileURL(f.dir).href + '/'
|
||||
const missing = join(f.dir, 'missing.cordis.yml')
|
||||
expect(prepareProfileEntries(ctx, [{ id: 'row', name: 'cordis:include', config: { path: missing } }], base))
|
||||
.toEqual([{ id: 'row', name: 'cordis:include', config: { path: missing } }])
|
||||
const rows = prepareProfileEntries(ctx, [{ id: 'row', name: 'cordis:include',
|
||||
config: { path: missing, initial: [{ id: 'denied', name: 'denied-plugin' }] } }], base)
|
||||
expect(rows[0]?.disabled).toBe(true)
|
||||
expect(f.warnings.join('\n')).toContain('never rewritten')
|
||||
})
|
||||
|
||||
it('returns no patches for a profile whose composition has no rows and warns about an unmatched patch', () => {
|
||||
const f = fixture()
|
||||
const ctx = context(f)
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
|
||||
onTestFinished(() => { warn.mockRestore() })
|
||||
const base = pathToFileURL(f.dir).href + '/'
|
||||
expect(prepareProfilePatches(ctx, [], base)).toEqual([])
|
||||
expect(prepareProfilePatches(ctx, [{ id: 'absent', disabled: true }], base)).toEqual([])
|
||||
expect(warn.mock.calls.flat().map(String).join(' ')).toContain('absent')
|
||||
})
|
||||
|
||||
it('requires a resolution base for a profile composition', () => {
|
||||
const f = fixture()
|
||||
expect(() => { prepareProfileEntries(context(f), [{ id: 'row', name: 'allowed-plugin' }], undefined) })
|
||||
.toThrow('requires a resolution base')
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
it('reuses the documented literal Include dialect when reading included entries', async () => {
|
||||
const f = fixture()
|
||||
f.plugin('allowed-plugin')
|
||||
const list = join(f.dir, 'nested.cordis.yml')
|
||||
writeFileSync(list, '- id: nested\n name: allowed-plugin\n')
|
||||
expect(load(readFileSync(list, 'utf8'), { schema: entryListSchema })).toEqual([{ id: 'nested', name: 'allowed-plugin' }])
|
||||
// The launcher mounts an Include with a file URL, which a Windows path cannot stand in for: `C:` reads as a scheme.
|
||||
expect(await f.run([{ insert: [{ id: 'outer', name: 'cordis:include', config: { path: pathToFileURL(list).href } }] }]))
|
||||
.toEqual(['import:allowed-plugin', 'apply:allowed-plugin'])
|
||||
})
|
||||
@@ -0,0 +1,178 @@
|
||||
/** Plugin peer checks retain exact mismatches and never inherit exemption entries. */
|
||||
|
||||
import fs from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import semver from 'semver'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { evaluatePluginCompatibility, getDshRuntimeVersion, pluginCompatibilityWarning } from '../src/plugin-compatibility.ts'
|
||||
|
||||
const runtime = '0.1.7-alpha.1'
|
||||
const identity = { name: '@example/plugin', version: '2.0.0' }
|
||||
const incompatible = { ...identity, peerDependencies: { '@deepseek-ai/dsh': '^0.2.0' } }
|
||||
|
||||
function check(peerDependencies: object) {
|
||||
return evaluatePluginCompatibility({ ...identity, peerDependencies }, {}, runtime)
|
||||
}
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
describe('dsh runtime version', () => {
|
||||
it('reads the exact version with a string path accepted by executable virtual filesystems', () => {
|
||||
const { version: expected } = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { version: string }
|
||||
const read = vi.spyOn(fs, 'readFileSync')
|
||||
expect(getDshRuntimeVersion()).toBe(expected)
|
||||
expect(semver.valid(expected)).not.toBeNull()
|
||||
expect(read).toHaveBeenCalledWith(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8')
|
||||
})
|
||||
|
||||
it.each([{}, { version: null }, { version: 1 }, { version: '' }, { version: 'not-semver' }])(
|
||||
'rejects an invalid or absent package version: %j', (manifest) => {
|
||||
vi.spyOn(fs, 'readFileSync').mockReturnValueOnce(JSON.stringify(manifest))
|
||||
expect(() => getDshRuntimeVersion()).toThrow('Invalid dsh runtime version')
|
||||
},
|
||||
)
|
||||
|
||||
it.each(['null', '[]', '1', '"text"'])('rejects non-object package JSON: %s', (contents) => {
|
||||
vi.spyOn(fs, 'readFileSync').mockReturnValueOnce(contents)
|
||||
expect(() => getDshRuntimeVersion()).toThrow('app-boot package.json must be an object')
|
||||
})
|
||||
|
||||
it('propagates malformed JSON and read failures', () => {
|
||||
const read = vi.spyOn(fs, 'readFileSync').mockReturnValueOnce('{')
|
||||
expect(() => getDshRuntimeVersion()).toThrow(SyntaxError)
|
||||
read.mockImplementationOnce(() => { throw new Error('manifest unavailable') })
|
||||
expect(() => getDshRuntimeVersion()).toThrow('manifest unavailable')
|
||||
})
|
||||
})
|
||||
|
||||
describe('plugin compatibility', () => {
|
||||
it('defaults to the installed runtime and needs no identity without mismatches', () => {
|
||||
expect(evaluatePluginCompatibility({})).toBeUndefined()
|
||||
expect(evaluatePluginCompatibility({ peerDependencies: { '@deepseek-ai/dsh': getDshRuntimeVersion() } })).toBeUndefined()
|
||||
expect(check({})).toBeUndefined()
|
||||
})
|
||||
|
||||
it.each(['*', '^0.1.0', '~0.1.0', '>=0.1.0 <0.2.0', '0.1.x', '0.1.0 - 0.1.9', '^0.2.0 || ^0.1.0', runtime])(
|
||||
'includes prereleases in supported semver range %s', (range) => {
|
||||
expect(check({ '@deepseek-ai/dsh': range, '@deepseek-ai/dsh-session': range })).toBeUndefined()
|
||||
},
|
||||
)
|
||||
|
||||
it.each(['workspace:^', 'workspace:~', 'workspace:*'])('uses the source runtime for %s', (range) => {
|
||||
expect(check({ '@deepseek-ai/dsh-session': range })).toBeUndefined()
|
||||
})
|
||||
|
||||
it('checks the intersection of dsh requirements and returns only mismatches', () => {
|
||||
expect(check({
|
||||
'@deepseek-ai/dsh': '^0.1.0',
|
||||
'@deepseek-ai/dsh-session': '>=0.2.0',
|
||||
'@deepseek-ai/dsh-tools': '<0.1.0',
|
||||
'@deepseek-ai/cordis': '^99.0.0',
|
||||
})).toEqual({
|
||||
...identity,
|
||||
runtimeVersion: runtime,
|
||||
peers: { '@deepseek-ai/dsh-session': '>=0.2.0', '@deepseek-ai/dsh-tools': '<0.1.0' },
|
||||
exempted: false,
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['^0.2.0', '>=0.1.7', 'broken', '', ' ', 'workspace:>=0.2.0', 'file:../dsh', 'npm:dsh@*'])(
|
||||
'fails closed for an incompatible or malformed range %j', (range) => {
|
||||
expect(check({ '@deepseek-ai/dsh': range })?.peers).toEqual({ '@deepseek-ai/dsh': range })
|
||||
},
|
||||
)
|
||||
|
||||
it('ignores unrelated names and dependency fields', () => {
|
||||
expect(check({
|
||||
'@deepseek-ai/cordis': 'broken', '@deepseek-ai/cordis-plugin-loader': '^99',
|
||||
'@other/dsh': '^99', '@deepseek-ai/dshx': '^99', dsh: '^99',
|
||||
constructor: '^99', hasOwnProperty: '^99', ['__proto__']: '^99',
|
||||
})).toBeUndefined()
|
||||
expect(evaluatePluginCompatibility({ dependencies: { '@deepseek-ai/dsh': '^99' } }, {}, runtime)).toBeUndefined()
|
||||
})
|
||||
|
||||
it.each([null, [], 'bad', 1, false, undefined])('rejects malformed peerDependencies: %j', (peerDependencies) => {
|
||||
expect(() => evaluatePluginCompatibility({ ...identity, peerDependencies }, {}, runtime)).toThrow('peerDependencies must be an object')
|
||||
})
|
||||
|
||||
it.each([null, [], {}, 1, false, undefined])('rejects non-string peer ranges: %j', (range) => {
|
||||
for (const name of ['@deepseek-ai/dsh', '@deepseek-ai/cordis']) {
|
||||
expect(() => check({ [name]: range })).toThrow(`peerDependencies[${JSON.stringify(name)}] must be a string`)
|
||||
}
|
||||
})
|
||||
|
||||
it.each(['name', 'version'] as const)('requires own non-empty %s on a mismatch', (field) => {
|
||||
for (const value of [undefined, null, '', ' ', 3]) {
|
||||
expect(() => evaluatePluginCompatibility({ ...incompatible, [field]: value }, {}, runtime))
|
||||
.toThrow(`Plugin manifest ${field} must be a non-empty string`)
|
||||
}
|
||||
const inherited = Object.assign(
|
||||
Object.create({ [field]: identity[field] }) as object,
|
||||
Object.fromEntries(Object.entries(incompatible).filter(([name]) => name !== field)),
|
||||
)
|
||||
expect(() => evaluatePluginCompatibility(inherited, {}, runtime)).toThrow(`Plugin manifest ${field}`)
|
||||
})
|
||||
|
||||
it('ignores inherited peer declarations and inherited peer entries', () => {
|
||||
expect(evaluatePluginCompatibility(Object.create(incompatible) as object, {}, runtime)).toBeUndefined()
|
||||
expect(check(Object.create(incompatible.peerDependencies) as object)).toBeUndefined()
|
||||
expect(check(Object.assign(Object.create({ '@deepseek-ai/dsh': '^99' }) as object, { '@deepseek-ai/dsh-session': '*' }))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects an invalid explicit runtime even when no peers are declared', () => {
|
||||
expect(() => evaluatePluginCompatibility({}, {}, 'invalid')).toThrow('Invalid dsh runtime version')
|
||||
})
|
||||
|
||||
it('rejects array plugin manifests', () => {
|
||||
expect(() => evaluatePluginCompatibility([], {}, runtime)).toThrow('Plugin manifest must be an object')
|
||||
})
|
||||
})
|
||||
|
||||
describe('exact-version exemptions', () => {
|
||||
const exemptions = { '@example/[email protected]': [runtime] }
|
||||
|
||||
it('exempts only the exact scoped plugin and runtime versions', () => {
|
||||
expect(evaluatePluginCompatibility(incompatible, exemptions, runtime)?.exempted).toBe(true)
|
||||
expect(evaluatePluginCompatibility({ ...incompatible, version: '2.0.1' }, exemptions, runtime)?.exempted).toBe(false)
|
||||
expect(evaluatePluginCompatibility({ ...incompatible, name: '@other/plugin' }, exemptions, runtime)?.exempted).toBe(false)
|
||||
expect(evaluatePluginCompatibility(incompatible, exemptions, '0.1.7-alpha.2')?.exempted).toBe(false)
|
||||
expect(evaluatePluginCompatibility(incompatible, exemptions, `${runtime}+build`)?.exempted).toBe(false)
|
||||
})
|
||||
|
||||
it.each(['*', '^0.1.0', '0.1.7', '0.1.7-alpha.2'])('does not interpret exemption value %s as a range', (version) => {
|
||||
expect(evaluatePluginCompatibility(incompatible, { '@example/[email protected]': [version] }, runtime)?.exempted).toBe(false)
|
||||
})
|
||||
|
||||
it('does not accept inherited exemptions, but accepts own entries on null-prototype maps', () => {
|
||||
expect(evaluatePluginCompatibility(incompatible, Object.create(exemptions) as typeof exemptions, runtime)?.exempted).toBe(false)
|
||||
const ownExemptions = Object.assign(Object.create(null) as object, exemptions)
|
||||
expect(evaluatePluginCompatibility(incompatible, ownExemptions, runtime)?.exempted).toBe(true)
|
||||
})
|
||||
|
||||
it.each(['constructor', 'hasOwnProperty', '__proto__'])('handles plugin identity %s without prototype lookup', (name) => {
|
||||
const manifest = { ...incompatible, name }
|
||||
expect(evaluatePluginCompatibility(manifest, {}, runtime)?.exempted).toBe(false)
|
||||
expect(evaluatePluginCompatibility(manifest, { [`${name}@2.0.0`]: [runtime] }, runtime)?.exempted).toBe(true)
|
||||
})
|
||||
|
||||
it('does not produce issues for compatible exempted plugins', () => {
|
||||
expect(evaluatePluginCompatibility({ ...identity, peerDependencies: { '@deepseek-ai/dsh': '*' } }, exemptions, runtime)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('compatibility warning', () => {
|
||||
it.each([false, true])('retains mismatch details and risk when exemption status is %s', (exempted) => {
|
||||
expect(pluginCompatibilityWarning({
|
||||
...identity, runtimeVersion: runtime,
|
||||
peers: { '@deepseek-ai/dsh': '^0.2.0', '@deepseek-ai/dsh-session': 'broken' }, exempted,
|
||||
})).toBe(
|
||||
'Plugin @example/[email protected] is incompatible with dsh 0.1.7-alpha.1: '
|
||||
+ 'peerDependencies {"@deepseek-ai/dsh":"^0.2.0","@deepseek-ai/dsh-session":"broken"}. '
|
||||
+ 'Running it may cause crashes or data loss. '
|
||||
+ 'Update the plugin or install a plugin version compatible with this dsh runtime. '
|
||||
+ 'To accept this risk explicitly, grant the exact-version exemption for @example/[email protected] on dsh 0.1.7-alpha.1 '
|
||||
+ 'with `dsh plugin allow-version` or the plugin manager, then retry the installation or restart dsh. '
|
||||
+ `Exact-version exemption: ${exempted ? 'active' : 'not active'}.`,
|
||||
)
|
||||
})
|
||||
})
|
||||
Loaded 100 of 553 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user